Merge remote-tracking branch 'upstream/dev' into electon-desktop

This commit is contained in:
CREDO23 2026-03-29 10:41:05 +02:00
commit ab3c636bcd
85 changed files with 4642 additions and 414 deletions

View file

@ -16,6 +16,7 @@ export function getDocumentTypeLabel(type: string): string {
FILE: "File",
SLACK_CONNECTOR: "Slack",
TEAMS_CONNECTOR: "Microsoft Teams",
ONEDRIVE_FILE: "OneDrive",
NOTION_CONNECTOR: "Notion",
YOUTUBE_VIDEO: "YouTube Video",
GITHUB_CONNECTOR: "GitHub",

View file

@ -38,7 +38,6 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
@ -234,6 +233,7 @@ export function DocumentsTableShell({
mentionedDocIds,
onToggleChatMention,
isSearchMode = false,
onOpenInTab,
}: {
documents: Document[];
loading: boolean;
@ -253,6 +253,8 @@ export function DocumentsTableShell({
onToggleChatMention?: (doc: Document, mentioned: boolean) => void;
/** Whether results are filtered by a search query or type filters */
isSearchMode?: boolean;
/** When provided, desktop "Preview" opens a document tab instead of the popup dialog */
onOpenInTab?: (doc: Document) => void;
}) {
const t = useTranslations("documents");
const { openDialog } = useDocumentUploadDialog();
@ -742,9 +744,9 @@ export function DocumentsTableShell({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => handleViewDocument(doc)}>
<DropdownMenuItem onClick={() => onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)}>
<Eye className="h-4 w-4" />
Preview
Open
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem
@ -923,26 +925,18 @@ export function DocumentsTableShell({
</div>
)}
{/* Document Content Viewer */}
<Dialog open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
<DialogContent className="max-w-4xl max-w-[92%] md:max-w-4xl max-h-[75vh] md:max-h-[80vh] flex flex-col overflow-hidden pb-0 p-3 md:p-6 gap-2 md:gap-4">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="text-sm md:text-lg leading-tight pr-6">
{/* Document Content Viewer (mobile drawer) */}
<Drawer open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
<DrawerContent className="max-h-[85vh] flex flex-col">
<DrawerHandle />
<DrawerHeader className="text-left shrink-0">
<DrawerTitle className="text-base leading-tight break-words">
{viewingDoc?.title}
</DialogTitle>
</DialogHeader>
</DrawerTitle>
</DrawerHeader>
<div
onScroll={handlePreviewScroll}
className={[
"overflow-y-auto flex-1 min-h-0 px-1 md:px-6 select-text",
"max-md:text-xs",
"max-md:[&_h1]:text-base! max-md:[&_h1]:mt-3!",
"max-md:[&_h2]:text-sm! max-md:[&_h2]:mt-2!",
"max-md:[&_h3]:text-xs! max-md:[&_h3]:mt-2!",
"max-md:[&_h4]:text-xs!",
"max-md:[&_td]:text-[11px]! max-md:[&_td]:px-2! max-md:[&_td]:py-1.5!",
"max-md:[&_th]:text-[11px]! max-md:[&_th]:px-2! max-md:[&_th]:py-1.5!",
].join(" ")}
className="overflow-y-auto flex-1 min-h-0 px-4 pb-6 select-text text-xs [&_h1]:text-base! [&_h1]:mt-3! [&_h2]:text-sm! [&_h2]:mt-2! [&_h3]:text-xs! [&_h3]:mt-2! [&_h4]:text-xs! [&_td]:text-[11px]! [&_td]:px-2! [&_td]:py-1.5! [&_th]:text-[11px]! [&_th]:px-2! [&_th]:py-1.5!"
style={{
maskImage: `linear-gradient(to bottom, ${previewScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${previewScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${previewScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${previewScrollPos === "bottom" ? "black" : "transparent"})`,
@ -956,8 +950,8 @@ export function DocumentsTableShell({
<MarkdownViewer content={viewingContent} />
)}
</div>
</DialogContent>
</Dialog>
</DrawerContent>
</Drawer>
{/* Document Metadata Viewer (Ctrl+Click) */}
<JsonMetadataViewer
@ -1027,7 +1021,7 @@ export function DocumentsTableShell({
}}
>
<Eye className="h-4 w-4" />
Preview
Open
</Button>
{mobileActionDoc &&
EDITABLE_DOCUMENT_TYPES.includes(

View file

@ -40,7 +40,6 @@ import { Thread } from "@/components/assistant-ui/thread";
import { MobileEditorPanel } from "@/components/editor-panel/editor-panel";
import { MobileHitlEditPanel } from "@/components/hitl-edit-panel/hitl-edit-panel";
import { MobileReportPanel } from "@/components/report-panel/report-panel";
import { Skeleton } from "@/components/ui/skeleton";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesSync } from "@/hooks/use-messages-sync";
import { documentsApiService } from "@/lib/apis/documents-api.service";
@ -144,6 +143,8 @@ const TOOLS_WITH_UI = new Set([
"delete_linear_issue",
"create_google_drive_file",
"delete_google_drive_file",
"create_onedrive_file",
"delete_onedrive_file",
"create_calendar_event",
"update_calendar_event",
"delete_calendar_event",
@ -902,6 +903,7 @@ export default function NewChatPage() {
currentThread,
currentUser,
disabledTools,
updateChatTabTitle,
]
);

View file

@ -763,7 +763,7 @@ function CreateInviteDialog({
</div>
</div>
</div>
<DialogFooter className="gap-3 sm:gap-2">
<DialogFooter>
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>

View file

@ -181,6 +181,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/microsoft-onedrive",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/microsoft-teams",
lastModified,

View file

@ -40,6 +40,10 @@ import {
CreateGoogleDriveFileToolUI,
DeleteGoogleDriveFileToolUI,
} from "@/components/tool-ui/google-drive";
import {
CreateOneDriveFileToolUI,
DeleteOneDriveFileToolUI,
} from "@/components/tool-ui/onedrive";
import {
CreateJiraIssueToolUI,
DeleteJiraIssueToolUI,
@ -97,6 +101,8 @@ const AssistantMessageInner: FC = () => {
delete_linear_issue: DeleteLinearIssueToolUI,
create_google_drive_file: CreateGoogleDriveFileToolUI,
delete_google_drive_file: DeleteGoogleDriveFileToolUI,
create_onedrive_file: CreateOneDriveFileToolUI,
delete_onedrive_file: DeleteOneDriveFileToolUI,
create_calendar_event: CreateCalendarEventToolUI,
update_calendar_event: UpdateCalendarEventToolUI,
delete_calendar_event: DeleteCalendarEventToolUI,

View file

@ -340,10 +340,11 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
onBack={handleBackFromEdit}
onQuickIndex={(() => {
const cfg = connectorConfig || editingConnector.config;
const isDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const hasDriveItems = isDrive
const isDriveOrOneDrive =
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR";
const hasDriveItems = isDriveOrOneDrive
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
((cfg?.selected_files as unknown[]) ?? []).length > 0
: true;

View file

@ -13,7 +13,7 @@ import {
} from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useState } from "react";
import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree";
import { DriveFolderTree, type SelectedFolder } from "@/components/connectors/drive-folder-tree";
import { Label } from "@/components/ui/label";
import {
Select,
@ -23,13 +23,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { ConnectorConfigProps } from "../index";
interface SelectedFolder {
id: string;
name: string;
}
interface IndexingOptions {
max_files_per_folder: number;
incremental_sync: boolean;
@ -102,6 +98,16 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
setAuthError(true);
}, []);
const fetchItems = useCallback(
async (parentId?: string) => {
return connectorsApiService.listComposioDriveFolders({
connector_id: connector.id,
parent_id: parentId,
});
},
[connector.id]
);
const [isEditMode] = useState(() => existingFolders.length > 0 || existingFiles.length > 0);
const [isFolderTreeOpen, setIsFolderTreeOpen] = useState(!isEditMode);
@ -255,24 +261,28 @@ export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onCon
)}
</button>
{isFolderTreeOpen && (
<ComposioDriveFolderTree
connectorId={connector.id}
<DriveFolderTree
fetchItems={fetchItems}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
rootLabel="My Drive"
providerName="Google Drive"
/>
)}
</div>
) : (
<ComposioDriveFolderTree
connectorId={connector.id}
<DriveFolderTree
fetchItems={fetchItems}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
rootLabel="My Drive"
providerName="Google Drive"
/>
)}
</div>

View file

@ -242,8 +242,6 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
</Button>
{pickerError && !isAuthExpired && <p className="text-xs text-destructive">{pickerError}</p>}
{isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired. Please re-authenticate using the button

View file

@ -0,0 +1,350 @@
"use client";
import {
ChevronDown,
ChevronRight,
File,
FileSpreadsheet,
FileText,
FolderClosed,
Image,
Presentation,
X,
} from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useState } from "react";
import { DriveFolderTree, type SelectedFolder } from "@/components/connectors/drive-folder-tree";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { ConnectorConfigProps } from "../index";
interface IndexingOptions {
max_files_per_folder: number;
incremental_sync: boolean;
include_subfolders: boolean;
}
const DEFAULT_INDEXING_OPTIONS: IndexingOptions = {
max_files_per_folder: 100,
incremental_sync: true,
include_subfolders: true,
};
function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") {
const lowerName = fileName.toLowerCase();
if (lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls") || lowerName.endsWith(".csv")) {
return <FileSpreadsheet className={`${className} text-muted-foreground`} />;
}
if (lowerName.endsWith(".pptx") || lowerName.endsWith(".ppt")) {
return <Presentation className={`${className} text-muted-foreground`} />;
}
if (lowerName.endsWith(".docx") || lowerName.endsWith(".doc") || lowerName.endsWith(".txt")) {
return <FileText className={`${className} text-muted-foreground`} />;
}
if (/\.(png|jpe?g|gif|webp|svg)$/.test(lowerName)) {
return <Image className={`${className} text-muted-foreground`} />;
}
return <File className={`${className} text-muted-foreground`} />;
}
export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
const existingFolders =
(connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
const existingIndexingOptions =
(connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS;
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(existingFolders);
const [selectedFiles, setSelectedFiles] = useState<SelectedFolder[]>(existingFiles);
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
const [authError, setAuthError] = useState(false);
const isAuthExpired = connector.config?.auth_expired === true || authError;
const handleAuthError = useCallback(() => {
setAuthError(true);
}, []);
const fetchItems = useCallback(
async (parentId?: string) => {
return connectorsApiService.listOneDriveFolders({
connector_id: connector.id,
parent_id: parentId,
});
},
[connector.id]
);
const [isEditMode] = useState(() => existingFolders.length > 0 || existingFiles.length > 0);
const [isFolderTreeOpen, setIsFolderTreeOpen] = useState(!isEditMode);
useEffect(() => {
const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
const options =
(connector.config?.indexing_options as IndexingOptions | undefined) ||
DEFAULT_INDEXING_OPTIONS;
setSelectedFolders(folders);
setSelectedFiles(files);
setIndexingOptions(options);
}, [connector.config]);
const updateConfig = (
folders: SelectedFolder[],
files: SelectedFolder[],
options: IndexingOptions
) => {
if (onConfigChange) {
onConfigChange({
...connector.config,
selected_folders: folders,
selected_files: files,
indexing_options: options,
});
}
};
const handleSelectFolders = (folders: SelectedFolder[]) => {
setSelectedFolders(folders);
updateConfig(folders, selectedFiles, indexingOptions);
};
const handleSelectFiles = (files: SelectedFolder[]) => {
setSelectedFiles(files);
updateConfig(selectedFolders, files, indexingOptions);
};
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
const newOptions = { ...indexingOptions, [key]: value };
setIndexingOptions(newOptions);
updateConfig(selectedFolders, selectedFiles, newOptions);
};
const handleRemoveFolder = (folderId: string) => {
const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
setSelectedFolders(newFolders);
updateConfig(newFolders, selectedFiles, indexingOptions);
};
const handleRemoveFile = (fileId: string) => {
const newFiles = selectedFiles.filter((file) => file.id !== fileId);
setSelectedFiles(newFiles);
updateConfig(selectedFolders, newFiles, indexingOptions);
};
const totalSelected = selectedFolders.length + selectedFiles.length;
return (
<div className="space-y-6">
{/* Folder & File Selection */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Folder & File Selection</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Select specific folders and/or individual files to index from your OneDrive.
</p>
</div>
{totalSelected > 0 && (
<div className="p-2 sm:p-3 bg-muted rounded-lg text-xs sm:text-sm space-y-1 sm:space-y-2">
<p className="font-medium">
Selected {totalSelected} item{totalSelected > 1 ? "s" : ""}: {(() => {
const parts: string[] = [];
if (selectedFolders.length > 0) {
parts.push(
`${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}`
);
}
if (selectedFiles.length > 0) {
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
}
return parts.length > 0 ? `(${parts.join(", ")})` : "";
})()}
</p>
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
{selectedFolders.map((folder) => (
<div
key={folder.id}
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
title={folder.name}
>
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{folder.name}</span>
<button
type="button"
onClick={() => handleRemoveFolder(folder.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
aria-label={`Remove ${folder.name}`}
>
<X className="size-3.5" />
</button>
</div>
))}
{selectedFiles.map((file) => (
<div
key={file.id}
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
title={file.name}
>
{getFileIconFromName(file.name)}
<span className="flex-1 truncate">{file.name}</span>
<button
type="button"
onClick={() => handleRemoveFile(file.id)}
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500">
Your OneDrive authentication has expired. Please re-authenticate using the button
below.
</p>
)}
{isEditMode ? (
<div className="space-y-2">
<button
type="button"
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
Change Selection
{isFolderTreeOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
{isFolderTreeOpen && (
<DriveFolderTree
fetchItems={fetchItems}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
rootLabel="OneDrive"
providerName="OneDrive"
/>
)}
</div>
) : (
<DriveFolderTree
fetchItems={fetchItems}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
onAuthError={handleAuthError}
rootLabel="OneDrive"
providerName="OneDrive"
/>
)}
</div>
{/* Indexing Options */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Indexing Options</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Configure how files are indexed from your OneDrive.
</p>
</div>
{/* Max files per folder */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="od-max-files" className="text-sm font-medium">
Max files per folder
</Label>
<p className="text-xs text-muted-foreground">
Maximum number of files to index from each folder
</p>
</div>
<Select
value={indexingOptions.max_files_per_folder.toString()}
onValueChange={(value) =>
handleIndexingOptionChange("max_files_per_folder", parseInt(value, 10))
}
>
<SelectTrigger
id="od-max-files"
className="w-[140px] bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select limit" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="50" className="text-xs sm:text-sm">
50 files
</SelectItem>
<SelectItem value="100" className="text-xs sm:text-sm">
100 files
</SelectItem>
<SelectItem value="250" className="text-xs sm:text-sm">
250 files
</SelectItem>
<SelectItem value="500" className="text-xs sm:text-sm">
500 files
</SelectItem>
<SelectItem value="1000" className="text-xs sm:text-sm">
1000 files
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Incremental sync toggle */}
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
<div className="space-y-0.5">
<Label htmlFor="od-incremental-sync" className="text-sm font-medium">
Incremental sync
</Label>
<p className="text-xs text-muted-foreground">
Only sync changes since last index (faster). Disable for a full re-index.
</p>
</div>
<Switch
id="od-incremental-sync"
checked={indexingOptions.incremental_sync}
onCheckedChange={(checked) => handleIndexingOptionChange("incremental_sync", checked)}
/>
</div>
{/* Include subfolders toggle */}
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
<div className="space-y-0.5">
<Label htmlFor="od-include-subfolders" className="text-sm font-medium">
Include subfolders
</Label>
<p className="text-xs text-muted-foreground">
Recursively index files in subfolders of selected folders
</p>
</div>
<Switch
id="od-include-subfolders"
checked={indexingOptions.include_subfolders}
onCheckedChange={(checked) => handleIndexingOptionChange("include_subfolders", checked)}
/>
</div>
</div>
</div>
);
};

View file

@ -21,6 +21,7 @@ import { MCPConfig } from "./components/mcp-config";
import { ObsidianConfig } from "./components/obsidian-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
import { OneDriveConfig } from "./components/onedrive-config";
import { TeamsConfig } from "./components/teams-config";
import { WebcrawlerConfig } from "./components/webcrawler-config";
@ -58,6 +59,8 @@ export function getConnectorConfigComponent(
return DiscordConfig;
case "TEAMS_CONNECTOR":
return TeamsConfig;
case "ONEDRIVE_CONNECTOR":
return OneDriveConfig;
case "CONFLUENCE_CONNECTOR":
return ConfluenceConfig;
case "BOOKSTACK_CONNECTOR":

View file

@ -27,6 +27,7 @@ const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth",
};
interface ConnectorEditViewProps {

View file

@ -61,6 +61,13 @@ export const OAUTH_CONNECTORS = [
connectorType: EnumConnectorName.TEAMS_CONNECTOR,
authEndpoint: "/api/v1/auth/teams/connector/add/",
},
{
id: "onedrive-connector",
title: "OneDrive",
description: "Search your OneDrive files",
connectorType: EnumConnectorName.ONEDRIVE_CONNECTOR,
authEndpoint: "/api/v1/auth/onedrive/connector/add/",
},
{
id: "discord-connector",
title: "Discord",

View file

@ -729,10 +729,11 @@ export const useConnectorDialog = () => {
async (refreshConnectors: () => void) => {
if (!indexingConfig || !searchSpaceId) return;
// Validate date range (skip for Google Drive, Composio Drive, and Webcrawler)
// Validate date range (skip for Google Drive, Composio Drive, OneDrive, and Webcrawler)
if (
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
indexingConfig.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
indexingConfig.connectorType !== "ONEDRIVE_CONNECTOR" &&
indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR"
) {
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
@ -778,10 +779,11 @@ export const useConnectorDialog = () => {
});
}
// Handle Google Drive folder selection (regular and Composio)
if (
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR") &&
// Handle Google Drive / OneDrive folder selection (regular and Composio)
if (
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
indexingConfig.connectorType === "ONEDRIVE_CONNECTOR") &&
indexingConnectorConfig
) {
const selectedFolders = indexingConnectorConfig.selected_folders as
@ -967,10 +969,11 @@ export const useConnectorDialog = () => {
async (refreshConnectors: () => void) => {
if (!editingConnector || !searchSpaceId || isSaving) return;
// Validate date range (skip for Google Drive which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
// Validate date range (skip for Google Drive/OneDrive which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
if (
editingConnector.is_indexable &&
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
editingConnector.connector_type !== "ONEDRIVE_CONNECTOR" &&
editingConnector.connector_type !== "WEBCRAWLER_CONNECTOR"
) {
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
@ -986,11 +989,12 @@ export const useConnectorDialog = () => {
return;
}
// Prevent periodic indexing for Google Drive (regular or Composio) without folders/files selected
// Prevent periodic indexing for Google Drive / OneDrive (regular or Composio) without folders/files selected
if (
periodicEnabled &&
(editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR")
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR")
) {
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
| Array<{ id: string; name: string }>
@ -1043,7 +1047,8 @@ export const useConnectorDialog = () => {
indexingDescription = "Settings saved.";
} else if (
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR"
) {
// Google Drive (both regular and Composio) uses folder selection from config, not date ranges
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as

View file

@ -12,6 +12,7 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
// Direct mappings (connector type matches document type)
SLACK_CONNECTOR: "SLACK_CONNECTOR",
TEAMS_CONNECTOR: "TEAMS_CONNECTOR",
ONEDRIVE_CONNECTOR: "ONEDRIVE_FILE",
NOTION_CONNECTOR: "NOTION_CONNECTOR",
GITHUB_CONNECTOR: "GITHUB_CONNECTOR",
LINEAR_CONNECTOR: "LINEAR_CONNECTOR",

View file

@ -25,6 +25,7 @@ const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
[EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth",
[EnumConnectorName.JIRA_CONNECTOR]: "/api/v1/auth/jira/connector/reauth",
[EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth",
};

View file

@ -394,7 +394,7 @@ const defaultComponents = memoizeMarkdownComponents({
if (!isCodeBlock) {
return (
<code
className={cn("aui-md-inline-code rounded border bg-muted font-semibold", className)}
className={cn("aui-md-inline-code rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[0.9em] font-normal", className)}
{...props}
>
{children}

View file

@ -1267,6 +1267,12 @@ const TOOL_GROUPS: ToolGroup[] = [
connectorIcon: "google_drive",
tooltip: "Create and delete files in Google Drive.",
},
{
label: "OneDrive",
tools: ["create_onedrive_file", "delete_onedrive_file"],
connectorIcon: "onedrive",
tooltip: "Create and delete files in OneDrive.",
},
{
label: "Notion",
tools: ["create_notion_page", "update_notion_page", "delete_notion_page"],

View file

@ -12,15 +12,13 @@ import {
Image,
Presentation,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { useComposioDriveFolders } from "@/hooks/use-composio-drive-folders";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils";
interface DriveItem {
export interface DriveItem {
id: string;
name: string;
mimeType: string;
@ -32,73 +30,92 @@ interface DriveItem {
interface ItemTreeNode {
item: DriveItem;
children: DriveItem[] | null; // null = not loaded, [] = loaded but empty
children: DriveItem[] | null;
isExpanded: boolean;
isLoading: boolean;
}
interface SelectedFolder {
export interface SelectedFolder {
id: string;
name: string;
}
interface ComposioDriveFolderTreeProps {
connectorId: number;
interface DriveFolderTreeProps {
fetchItems: (parentId?: string) => Promise<{ items: DriveItem[] }>;
selectedFolders: SelectedFolder[];
onSelectFolders: (folders: SelectedFolder[]) => void;
selectedFiles?: SelectedFolder[];
onSelectFiles?: (files: SelectedFolder[]) => void;
onAuthError?: (message: string) => void;
rootLabel?: string;
providerName?: string;
}
// Helper to get appropriate icon for file type
function getFileIcon(mimeType: string, className: string = "h-4 w-4") {
if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) {
function getFileIcon(mimeType?: string, className: string = "h-4 w-4") {
const type = mimeType ?? "";
if (type.includes("spreadsheet") || type.includes("excel")) {
return <FileSpreadsheet className={`${className} text-muted-foreground`} />;
}
if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) {
if (type.includes("presentation") || type.includes("powerpoint")) {
return <Presentation className={`${className} text-muted-foreground`} />;
}
if (mimeType.includes("document") || mimeType.includes("word") || mimeType.includes("text")) {
if (type.includes("document") || type.includes("word") || type.includes("text")) {
return <FileText className={`${className} text-muted-foreground`} />;
}
if (mimeType.includes("image")) {
if (type.includes("image")) {
return <Image className={`${className} text-muted-foreground`} />;
}
return <File className={`${className} text-muted-foreground`} />;
}
export function ComposioDriveFolderTree({
connectorId,
export function DriveFolderTree({
fetchItems,
selectedFolders,
onSelectFolders,
selectedFiles = [],
onSelectFiles = () => {},
onAuthError,
}: ComposioDriveFolderTreeProps) {
rootLabel = "My Drive",
providerName = "Drive",
}: DriveFolderTreeProps) {
const [itemStates, setItemStates] = useState<Map<string, ItemTreeNode>>(new Map());
const {
data: rootData,
isLoading: isLoadingRoot,
error: rootError,
} = useComposioDriveFolders({
connectorId,
});
const [rootItems, setRootItems] = useState<DriveItem[]>([]);
const [isLoadingRoot, setIsLoadingRoot] = useState(true);
const [rootError, setRootError] = useState<Error | null>(null);
useEffect(() => {
if (rootError && onAuthError) {
const msg = rootError instanceof Error ? rootError.message : String(rootError);
if (
msg.toLowerCase().includes("authentication expired") ||
msg.toLowerCase().includes("re-authenticate")
) {
onAuthError(msg);
}
}
}, [rootError, onAuthError]);
let cancelled = false;
setIsLoadingRoot(true);
setRootError(null);
const rootItems = rootData?.items || [];
fetchItems()
.then((data) => {
if (!cancelled) {
setRootItems(data.items || []);
setIsLoadingRoot(false);
}
})
.catch((err) => {
if (!cancelled) {
const error = err instanceof Error ? err : new Error(String(err));
setRootError(error);
setIsLoadingRoot(false);
if (onAuthError) {
const msg = error.message;
if (
msg.toLowerCase().includes("authentication expired") ||
msg.toLowerCase().includes("re-authenticate")
) {
onAuthError(msg);
}
}
}
});
return () => {
cancelled = true;
};
}, [fetchItems, onAuthError]);
const isFolderSelected = (folderId: string): boolean => {
return selectedFolders.some((f) => f.id === folderId);
@ -124,89 +141,81 @@ export function ComposioDriveFolderTree({
}
};
/**
* Find an item by ID across all loaded items (root and nested).
*/
const findItem = (itemId: string): DriveItem | undefined => {
const state = itemStates.get(itemId);
if (state?.item) return state.item;
const findItem = useCallback(
(itemId: string): DriveItem | undefined => {
const state = itemStates.get(itemId);
if (state?.item) return state.item;
const rootItem = rootItems.find((item) => item.id === itemId);
if (rootItem) return rootItem;
const rootItem = rootItems.find((item) => item.id === itemId);
if (rootItem) return rootItem;
for (const [, nodeState] of itemStates) {
if (nodeState.children) {
const found = nodeState.children.find((child) => child.id === itemId);
if (found) return found;
for (const [, nodeState] of itemStates) {
if (nodeState.children) {
const found = nodeState.children.find((child) => child.id === itemId);
if (found) return found;
}
}
}
return undefined;
};
return undefined;
},
[itemStates, rootItems]
);
const loadFolderContents = useCallback(
async (folderId: string) => {
try {
setItemStates((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(folderId);
if (existing) {
newMap.set(folderId, { ...existing, isLoading: true });
} else {
const item = findItem(folderId);
if (item) {
newMap.set(folderId, {
item,
children: null,
isExpanded: false,
isLoading: true,
});
}
}
return newMap;
});
const data = await fetchItems(folderId);
const items = data.items || [];
setItemStates((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(folderId);
const item = existing?.item || findItem(folderId);
/**
* Load and display contents of a specific folder.
*/
const loadFolderContents = async (folderId: string) => {
try {
setItemStates((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(folderId);
if (existing) {
newMap.set(folderId, { ...existing, isLoading: true });
} else {
const item = findItem(folderId);
if (item) {
newMap.set(folderId, {
item,
children: null,
isExpanded: false,
isLoading: true,
children: items,
isExpanded: true,
isLoading: false,
});
}
}
return newMap;
});
return newMap;
});
} catch (error) {
console.error("Error loading folder contents:", error);
setItemStates((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(folderId);
if (existing) {
newMap.set(folderId, { ...existing, isLoading: false });
}
return newMap;
});
}
},
[fetchItems, findItem]
);
const data = await connectorsApiService.listComposioDriveFolders({
connector_id: connectorId,
parent_id: folderId,
});
const items = data.items || [];
setItemStates((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(folderId);
const item = existing?.item || findItem(folderId);
if (item) {
newMap.set(folderId, {
item,
children: items,
isExpanded: true,
isLoading: false,
});
} else {
console.error(`Could not find item for folderId: ${folderId}`);
}
return newMap;
});
} catch (error) {
console.error("Error loading folder contents:", error);
setItemStates((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(folderId);
if (existing) {
newMap.set(folderId, { ...existing, isLoading: false });
}
return newMap;
});
}
};
/**
* Toggle folder expand/collapse state.
*/
const toggleFolder = async (item: DriveItem) => {
if (!item.isFolder) return;
@ -226,9 +235,6 @@ export function ComposioDriveFolderTree({
}
};
/**
* Render a single item (folder or file) with its children.
*/
const renderItem = (item: DriveItem, level: number = 0) => {
const state = itemStates.get(item.id);
const isExpanded = state?.isExpanded || false;
@ -240,7 +246,7 @@ export function ComposioDriveFolderTree({
const childFolders = children?.filter((c) => c.isFolder) || [];
const childFiles = children?.filter((c) => !c.isFolder) || [];
const indentSize = 0.75; // Smaller indent for mobile
const indentSize = 0.75;
return (
<div
@ -346,16 +352,16 @@ export function ComposioDriveFolderTree({
<div className="flex items-center gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md hover:bg-accent cursor-pointer">
<Checkbox
checked={isFolderSelected("root")}
onCheckedChange={() => toggleFolderSelection("root", "My Drive")}
onCheckedChange={() => toggleFolderSelection("root", rootLabel)}
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
/>
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-muted-foreground shrink-0" />
<button
type="button"
className="font-semibold truncate text-xs sm:text-sm cursor-pointer bg-transparent border-0 p-0 text-left"
onClick={() => toggleFolderSelection("root", "My Drive")}
onClick={() => toggleFolderSelection("root", rootLabel)}
>
My Drive
{rootLabel}
</button>
</div>
</div>
@ -372,17 +378,15 @@ export function ComposioDriveFolderTree({
{!isLoadingRoot && rootError && (
<div className="text-center text-xs sm:text-sm text-amber-600 dark:text-amber-500 py-4 sm:py-8">
{(rootError instanceof Error ? rootError.message : String(rootError)).includes(
"authentication expired"
)
? "Google Drive authentication has expired. Please re-authenticate above."
: "Failed to load Google Drive contents."}
{rootError.message.includes("authentication expired")
? `${providerName} authentication has expired. Please re-authenticate above.`
: `Failed to load ${providerName} contents.`}
</div>
)}
{!isLoadingRoot && !rootError && rootItems.length === 0 && (
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4 sm:py-8">
No files or folders found in your Google Drive
No files or folders found in your {providerName}
</div>
)}
</div>

View file

@ -84,7 +84,7 @@ export function CreateFolderDialog({
/>
</div>
<DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
<DialogFooter className="flex-row justify-end pt-2 sm:pt-3">
<Button
type="button"
variant="secondary"

View file

@ -214,7 +214,7 @@ export const DocumentNode = React.memo(function DocumentNode({
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Open
@ -254,7 +254,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuTrigger>
{contextMenuOpen && (
<ContextMenuContent className="w-40">
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
<ContextMenuItem onClick={() => onPreview(doc)}>
<Eye className="mr-2 h-4 w-4" />
Open

View file

@ -155,7 +155,7 @@ export function FolderPickerDialog({
{renderPickerLevel(null, 1)}
</div>
<DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
<DialogFooter className="flex-row justify-end pt-2 sm:pt-3">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}

View file

@ -238,9 +238,9 @@ export function FolderTreeView({
if (treeNodes.length === 0 && folders.length === 0 && documents.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
<CirclePlus className="h-10 w-10 rotate-45" />
<p className="text-sm">No documents yet</p>
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground">
<p className="text-sm font-medium">No documents found</p>
<p className="text-xs text-muted-foreground/70">Use the upload button or connect a source above</p>
</div>
);
}

View file

@ -255,7 +255,7 @@ function MobileEditorDrawer() {
shouldScaleBackground={false}
>
<DrawerContent
className="h-[95vh] max-h-[95vh] z-80 bg-sidebar overflow-hidden"
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />

View file

@ -355,7 +355,7 @@ function MobileHitlEditDrawer() {
shouldScaleBackground={false}
>
<DrawerContent
className="h-[95vh] max-h-[95vh] z-80 bg-sidebar overflow-hidden"
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />

View file

@ -824,7 +824,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}
}}
/>
<DialogFooter className="flex gap-2 sm:justify-end">
<DialogFooter className="flex sm:justify-end">
<Button
variant="secondary"
onClick={() => setShowRenameChatDialog(false)}

View file

@ -138,7 +138,7 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
)}
/>
<DialogFooter className="flex-row justify-end gap-2 pt-2 sm:pt-3">
<DialogFooter className="flex-row justify-end pt-2 sm:pt-3">
<Button
type="button"
variant="secondary"

View file

@ -496,7 +496,7 @@ export function AllPrivateChatsSidebarContent({
}
}}
/>
<DialogFooter className="flex gap-2 sm:justify-end">
<DialogFooter className="flex sm:justify-end">
<Button
variant="secondary"
onClick={() => setShowRenameDialog(false)}

View file

@ -496,7 +496,7 @@ export function AllSharedChatsSidebarContent({
}
}}
/>
<DialogFooter className="flex gap-2 sm:justify-end">
<DialogFooter className="flex sm:justify-end">
<Button
variant="secondary"
onClick={() => setShowRenameDialog(false)}

View file

@ -7,12 +7,14 @@ import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom";
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
@ -33,13 +35,22 @@ import {
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHandle,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useIsMobile } from "@/hooks/use-mobile";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
@ -83,10 +94,13 @@ export function DocumentsSidebar({
const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
const openDocumentTab = useSetAtom(openDocumentTabAtom);
const { data: connectors } = useAtomValue(connectorsAtom);
const connectorCount = connectors?.length ?? 0;
const isMobileLayout = useIsMobile();
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
@ -360,6 +374,31 @@ export function DocumentsSidebar({
[]
);
// Document popup viewer state (for tree view "Open" and mobile preview)
const [viewingDoc, setViewingDoc] = useState<DocumentNodeDoc | null>(null);
const [viewingContent, setViewingContent] = useState<string>("");
const [viewingLoading, setViewingLoading] = useState(false);
const handleViewDocumentPopup = useCallback(async (doc: DocumentNodeDoc) => {
setViewingDoc(doc);
setViewingLoading(true);
try {
const fullDoc = await documentsApiService.getDocument({ id: doc.id });
setViewingContent(fullDoc.content);
} catch (err) {
console.error("[DocumentsSidebar] Failed to fetch document content:", err);
setViewingContent("Failed to load document content.");
} finally {
setViewingLoading(false);
}
}, []);
const handleCloseViewer = useCallback(() => {
setViewingDoc(null);
setViewingContent("");
setViewingLoading(false);
}, []);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) {
@ -505,12 +544,16 @@ export function DocumentsSidebar({
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
if (isMobile) {
onOpenChange(false);
} else {
setRightPanelCollapsed(true);
}
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
const documentsContent = (
<>
@ -673,18 +716,24 @@ export function DocumentsSidebar({
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
if (isMobileLayout) {
handleViewDocumentPopup(doc);
} else {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}
}}
onEditDocument={(doc) => {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
if (!isMobileLayout) {
openDocumentTab({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
@ -712,6 +761,26 @@ export function DocumentsSidebar({
onConfirm={handleCreateFolderConfirm}
/>
<Drawer open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
<DrawerContent className="max-h-[85vh] flex flex-col">
<DrawerHandle />
<DrawerHeader className="text-left shrink-0">
<DrawerTitle className="text-base leading-tight break-words">
{viewingDoc?.title}
</DrawerTitle>
</DrawerHeader>
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-6 select-text text-xs [&_h1]:text-base! [&_h1]:mt-3! [&_h2]:text-sm! [&_h2]:mt-2! [&_h3]:text-xs! [&_h3]:mt-2!">
{viewingLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" className="text-muted-foreground" />
</div>
) : (
<MarkdownViewer content={viewingContent} />
)}
</div>
</DrawerContent>
</Drawer>
<AlertDialog
open={bulkDeleteConfirmOpen}
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}

View file

@ -455,7 +455,7 @@ function MobileReportDrawer() {
shouldScaleBackground={false}
>
<DrawerContent
className="h-[95vh] max-h-[95vh] z-80 bg-sidebar overflow-hidden"
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />

View file

@ -17,6 +17,7 @@ export {
export { GeneratePodcastToolUI } from "./generate-podcast";
export { GenerateReportToolUI } from "./generate-report";
export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive";
export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive";
export {
Image,
ImageErrorBoundary,

View file

@ -0,0 +1,403 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface OneDriveAccount {
id: number;
name: string;
user_email?: string;
auth_expired?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: OneDriveAccount[];
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
error?: string;
};
}
interface SuccessResult {
status: "success";
file_id: string;
name: string;
web_url?: string;
message?: string;
}
interface ErrorResult {
status: "error";
message: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_type?: string;
}
type CreateOneDriveFileResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as ErrorResult).status === "error"
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function ApprovalCard({
args,
interruptData,
onDecision,
}: {
args: { name: string; content?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
const [pendingEdits, setPendingEdits] = useState<{ name: string; content: string } | null>(null);
const accounts = interruptData.context?.accounts ?? [];
const validAccounts = accounts.filter((a) => !a.auth_expired);
const expiredAccounts = accounts.filter((a) => a.auth_expired);
const defaultAccountId = useMemo(() => {
if (validAccounts.length === 1) return String(validAccounts[0].id);
return "";
}, [validAccounts]);
const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId);
const [parentFolderId, setParentFolderId] = useState<string>("__root__");
const parentFolders = interruptData.context?.parent_folders ?? {};
const availableParentFolders = useMemo(() => {
if (!selectedAccountId) return [];
return parentFolders[Number(selectedAccountId)] ?? [];
}, [selectedAccountId, parentFolders]);
const handleAccountChange = useCallback((value: string) => {
setSelectedAccountId(value);
setParentFolderId("__root__");
}, []);
const isNameValid = useMemo(() => {
const name = pendingEdits?.name ?? args.name;
return name && typeof name === "string" && name.trim().length > 0;
}, [pendingEdits?.name, args.name]);
const canApprove = !!selectedAccountId && isNameValid;
const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const handleApprove = useCallback(() => {
if (phase !== "pending" || isPanelOpen || !canApprove) return;
if (!allowedDecisions.includes("approve")) return;
const isEdited = pendingEdits !== null;
setProcessing();
onDecision({
type: isEdited ? "edit" : "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: {
...args,
...(pendingEdits && { name: pendingEdits.name, content: pendingEdits.content }),
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
parent_folder_id: parentFolderId === "__root__" ? null : parentFolderId,
},
},
});
}, [
phase,
setProcessing,
isPanelOpen,
canApprove,
allowedDecisions,
onDecision,
interruptData,
args,
selectedAccountId,
parentFolderId,
pendingEdits,
]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) handleApprove();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove]);
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{phase === "rejected"
? "Word Document Rejected"
: phase === "processing" || phase === "complete"
? "Word Document Approved"
: "Create Word Document"}
</p>
{phase === "processing" ? (
<TextShimmerLoader
text={pendingEdits ? "Creating file with your changes" : "Creating file"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits ? "File created with your changes" : "File created"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">File creation was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
</p>
)}
</div>
{phase === "pending" && canEdit && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => {
setIsPanelOpen(true);
openHitlEditPanel({
title: pendingEdits?.name ?? args.name ?? "",
content: pendingEdits?.content ?? args.content ?? "",
toolName: "Word Document",
onSave: (newName, newContent) => {
setIsPanelOpen(false);
setPendingEdits({ name: newName, content: newContent });
},
onClose: () => setIsPanelOpen(false),
});
}}
>
<Pen className="size-3.5" />
Edit
</Button>
)}
</div>
{/* Context section — pickers in pending */}
{phase === "pending" && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
OneDrive Account <span className="text-destructive">*</span>
</p>
<Select value={selectedAccountId} onValueChange={handleAccountChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name}
</SelectItem>
))}
{expiredAccounts.map((a) => (
<div
key={a.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{a.name} (expired, retry after re-auth)
</div>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
File Type
</p>
<Select value="docx" disabled>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="docx">Word Document (.docx)</SelectItem>
</SelectContent>
</Select>
</div>
{selectedAccountId && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Parent Folder</p>
<Select value={parentFolderId} onValueChange={setParentFolderId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="OneDrive Root" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__root__">OneDrive Root</SelectItem>
{availableParentFolders.map((folder) => (
<SelectItem key={folder.folder_id} value={folder.folder_id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
{availableParentFolders.length === 0 && (
<p className="text-xs text-muted-foreground">
No folders found. File will be created at OneDrive root.
</p>
)}
</div>
)}
</>
)}
</div>
</>
)}
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3">
{(pendingEdits?.name ?? args.name) != null && (
<p className="text-sm font-medium text-foreground">{String(pendingEdits?.name ?? args.name)}</p>
)}
{(pendingEdits?.content ?? args.content) != null && (
<div className="mt-2 max-h-[7rem] overflow-hidden text-sm" style={{ maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)", WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)" }}>
<PlateEditor markdown={String(pendingEdits?.content ?? args.content)} readOnly preset="readonly" editorVariant="none" className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0" />
</div>
)}
</div>
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove} disabled={!canApprove || isPanelOpen}>
Approve <CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button size="sm" variant="ghost" className="rounded-lg text-muted-foreground" disabled={isPanelOpen} onClick={() => { setRejected(); onDecision({ type: "reject", message: "User rejected the action." }); }}>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to create OneDrive file</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div>
</div>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-foreground">{result.message || "OneDrive file created successfully"}</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2 text-xs">
<div className="flex items-center gap-1.5">
<FileIcon className="size-3.5 text-muted-foreground" />
<span className="font-medium">{result.name}</span>
</div>
{result.web_url && (
<div>
<a href={result.web_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Open in OneDrive</a>
</div>
)}
</div>
</div>
);
}
export const CreateOneDriveFileToolUI = ({ args, result }: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
if (!result) return null;
if (isInterruptResult(result)) {
return <ApprovalCard args={args} interruptData={result} onDecision={(decision) => { window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })); }} />;
}
if (typeof result === "object" && result !== null && "status" in result && (result as { status: string }).status === "rejected") return null;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
};

View file

@ -0,0 +1,2 @@
export { CreateOneDriveFileToolUI } from "./create-file";
export { DeleteOneDriveFileToolUI } from "./trash-file";

View file

@ -0,0 +1,219 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { CornerDownLeftIcon, InfoIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface OneDriveAccount {
id: number;
name: string;
user_email?: string;
auth_expired?: boolean;
}
interface OneDriveFile {
file_id: string;
name: string;
document_id?: number;
web_url?: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{ action_name: string; allowed_decisions: Array<"approve" | "reject"> }>;
context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string };
}
interface SuccessResult { status: "success"; file_id: string; message?: string; deleted_from_kb?: boolean }
interface ErrorResult { status: "error"; message: string }
interface NotFoundResult { status: "not_found"; message: string }
interface AuthErrorResult { status: "auth_error"; message: string; connector_type?: string }
type DeleteOneDriveFileResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return typeof result === "object" && result !== null && "__interrupt__" in result && (result as InterruptResult).__interrupt__ === true;
}
function isErrorResult(result: unknown): result is ErrorResult {
return typeof result === "object" && result !== null && "status" in result && (result as ErrorResult).status === "error";
}
function isNotFoundResult(result: unknown): result is NotFoundResult {
return typeof result === "object" && result !== null && "status" in result && (result as NotFoundResult).status === "not_found";
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return typeof result === "object" && result !== null && "status" in result && (result as AuthErrorResult).status === "auth_error";
}
function ApprovalCard({ interruptData, onDecision }: {
interruptData: InterruptResult;
onDecision: (decision: { type: "approve" | "reject"; message?: string; edited_action?: { name: string; args: Record<string, unknown> } }) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
const context = interruptData.context;
const account = context?.account;
const file = context?.file;
const handleApprove = useCallback(() => {
if (phase !== "pending") return;
setProcessing();
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0].name,
args: { file_id: file?.file_id, connector_id: account?.id, delete_from_kb: deleteFromKb },
},
});
}, [phase, setProcessing, onDecision, interruptData, file?.file_id, account?.id, deleteFromKb]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) handleApprove();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove]);
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{phase === "rejected" ? "OneDrive File Deletion Rejected" : phase === "processing" || phase === "complete" ? "OneDrive File Deletion Approved" : "Delete OneDrive File"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text="Trashing file" size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">File trashed</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">File deletion was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">Requires your approval to proceed</p>
)}
</div>
</div>
{phase !== "rejected" && context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4 select-none">
{context.error ? (
<p className="text-sm text-destructive">{context.error}</p>
) : (
<>
{account && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">OneDrive Account</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">{account.name}</div>
</div>
)}
{file && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">File to Delete</p>
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm space-y-0.5">
<div className="font-medium">{file.name}</div>
{file.web_url && (
<a href={file.web_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">Open in OneDrive</a>
)}
</div>
</div>
)}
</>
)}
</div>
</>
)}
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-3 select-none">
<p className="text-xs text-muted-foreground">The file will be moved to the OneDrive recycle bin. You can restore it within 93 days.</p>
<div className="flex items-center gap-2.5">
<Checkbox id="od-delete-from-kb" checked={deleteFromKb} onCheckedChange={(v) => setDeleteFromKb(v === true)} className="shrink-0" />
<label htmlFor="od-delete-from-kb" className="flex-1 cursor-pointer">
<span className="text-sm text-foreground">Also remove from knowledge base</span>
<p className="text-xs text-muted-foreground mt-0.5">This will permanently delete the file from your knowledge base</p>
</label>
</div>
</div>
</>
)}
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>Approve <CornerDownLeftIcon className="size-3 opacity-60" /></Button>
<Button size="sm" variant="ghost" className="rounded-lg text-muted-foreground" onClick={() => { setRejected(); onDecision({ type: "reject", message: "User rejected the action." }); }}>Reject</Button>
</div>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4"><p className="text-sm font-semibold text-destructive">Failed to delete file</p></div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div>
</div>
);
}
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="flex items-start gap-3 px-5 py-4">
<InfoIcon className="size-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4"><p className="text-sm font-semibold text-destructive">OneDrive authentication expired</p></div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4"><p className="text-sm text-muted-foreground">{result.message}</p></div>
</div>
);
}
function SuccessCard({ result }: { result: SuccessResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4"><p className="text-sm font-semibold text-foreground">{result.message || "File moved to recycle bin"}</p></div>
{result.deleted_from_kb && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 text-xs"><span className="text-green-600 dark:text-green-500">Also removed from knowledge base</span></div>
</>
)}
</div>
);
}
export const DeleteOneDriveFileToolUI = ({ result }: ToolCallMessagePartProps<{ file_name: string; delete_from_kb?: boolean }, DeleteOneDriveFileResult>) => {
if (!result) return null;
if (isInterruptResult(result)) {
return <ApprovalCard interruptData={result} onDecision={(decision) => { window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })); }} />;
}
if (typeof result === "object" && result !== null && "status" in result && (result as { status: string }).status === "rejected") return null;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;
};

View file

@ -68,7 +68,7 @@ function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">)
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
className={cn("flex flex-col-reverse gap-3 sm:flex-row sm:justify-end", className)}
{...props}
/>
);

View file

@ -60,7 +60,7 @@ DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
className={cn("flex flex-col-reverse gap-3 sm:flex-row sm:justify-end", className)}
{...props}
/>
);

View file

@ -53,6 +53,11 @@ Connect SurfSense to your favorite tools and services. Browse the available inte
description="Connect your Microsoft Teams to SurfSense"
href="/docs/connectors/microsoft-teams"
/>
<Card
title="Microsoft OneDrive"
description="Connect your Microsoft OneDrive to SurfSense"
href="/docs/connectors/microsoft-onedrive"
/>
<Card
title="Confluence"
description="Connect your Confluence spaces to SurfSense"

View file

@ -11,6 +11,7 @@
"jira",
"linear",
"microsoft-teams",
"microsoft-onedrive",
"confluence",
"airtable",
"clickup",

View file

@ -0,0 +1,104 @@
---
title: Microsoft OneDrive
description: Connect your Microsoft OneDrive to SurfSense
---
# Microsoft OneDrive OAuth Integration Setup Guide
This guide walks you through setting up a Microsoft OneDrive OAuth integration for SurfSense using Azure App Registration.
<Callout type="info">
Microsoft OneDrive and [Microsoft Teams](/docs/connectors/microsoft-teams) share the same Azure App Registration. If you have already created an app for Teams, you can reuse the same Client ID and Client Secret. Just make sure both redirect URIs are added (see Step 3).
</Callout>
## Step 1: Access Azure App Registrations
1. Navigate to [portal.azure.com](https://portal.azure.com)
2. In the search bar, type **"app reg"**
3. Select **"App registrations"** from the Services results
## Step 2: Create New Registration
1. On the **App registrations** page, click **"+ New registration"**
## Step 3: Register the Application
Fill in the application details:
| Field | Value |
|-------|-------|
| **Name** | `SurfSense` |
| **Supported account types** | Select **"Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts"** |
| **Redirect URI** | Platform: `Web`, URI: `http://localhost:8000/api/v1/auth/onedrive/connector/callback` |
Click **"Register"**
After registration, add the Teams redirect URI as well (if you plan to use the Teams connector):
1. Go to **Authentication** in the left sidebar
2. Under **Platform configurations** > **Web** > **Redirect URIs**, click **Add URI**
3. Add: `http://localhost:8000/api/v1/auth/teams/connector/callback`
4. Click **Save**
## Step 4: Get Application (Client) ID
After registration, you will be taken to the app's **Overview** page. Here you will find:
1. Copy the **Application (client) ID** - this is your Client ID
2. Note the **Directory (tenant) ID** if needed
## Step 5: Create Client Secret
1. In the left sidebar under **Manage**, click **"Certificates & secrets"**
2. Select the **"Client secrets"** tab
3. Click **"+ New client secret"**
4. Enter a description (e.g., `SurfSense`) and select an expiration period
5. Click **"Add"**
6. **Important**: Copy the secret **Value** immediately. It will not be shown again!
<Callout type="warn">
Never share your client secret publicly or include it in code repositories.
</Callout>
## Step 6: Configure API Permissions
1. In the left sidebar under **Manage**, click **"API permissions"**
2. Click **"+ Add a permission"**
3. Select **"Microsoft Graph"**
4. Select **"Delegated permissions"**
5. Add the following permissions:
| Permission | Type | Description | Admin Consent |
|------------|------|-------------|---------------|
| `Files.Read.All` | Delegated | Read all files the user can access | No |
| `Files.ReadWrite.All` | Delegated | Read and write all files the user can access | No |
| `offline_access` | Delegated | Maintain access to data you have given it access to | No |
| `User.Read` | Delegated | Sign in and read user profile | No |
6. Click **"Add permissions"**
<Callout type="warn">
All four permissions listed above are required. The connector will not authenticate successfully if any are missing.
</Callout>
---
## Running SurfSense with Microsoft OneDrive Connector
Add the Microsoft OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash
MICROSOFT_CLIENT_ID=your_microsoft_client_id
MICROSOFT_CLIENT_SECRET=your_microsoft_client_secret
ONEDRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/onedrive/connector/callback
```
<Callout type="info">
The `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET` are shared between the OneDrive and Teams connectors. You only need to set them once.
</Callout>
Then restart the services:
```bash
docker compose up -d
```

View file

@ -7,6 +7,10 @@ description: Connect your Microsoft Teams to SurfSense
This guide walks you through setting up a Microsoft Teams OAuth integration for SurfSense using Azure App Registration.
<Callout type="info">
Microsoft Teams and [Microsoft OneDrive](/docs/connectors/microsoft-onedrive) share the same Azure App Registration. If you have already created an app for OneDrive, you can reuse the same Client ID and Client Secret. Just make sure both redirect URIs are added (see Step 3).
</Callout>
## Step 1: Access Azure App Registrations
1. Navigate to [portal.azure.com](https://portal.azure.com)
@ -33,11 +37,18 @@ Fill in the application details:
Click **"Register"**
After registration, add the OneDrive redirect URI as well:
1. Go to **Authentication** in the left sidebar
2. Under **Platform configurations** > **Web** > **Redirect URIs**, click **Add URI**
3. Add: `http://localhost:8000/api/v1/auth/onedrive/connector/callback`
4. Click **Save**
![Register Application Form](/docs/connectors/microsoft-teams/azure-register-app.png)
## Step 4: Get Application (Client) ID
After registration, you'll be taken to the app's **Overview** page. Here you'll find:
After registration, you will be taken to the app's **Overview** page. Here you will find:
1. Copy the **Application (client) ID** - this is your Client ID
2. Note the **Directory (tenant) ID** if needed
@ -54,7 +65,7 @@ After registration, you'll be taken to the app's **Overview** page. Here you'll
![Certificates & Secrets - Empty](/docs/connectors/microsoft-teams/azure-certificates-empty.png)
6. **Important**: Copy the secret **Value** immediately - it won't be shown again!
6. **Important**: Copy the secret **Value** immediately. It will not be shown again!
![Certificates & Secrets - Created](/docs/connectors/microsoft-teams/azure-certificates-created.png)
@ -90,14 +101,18 @@ After registration, you'll be taken to the app's **Overview** page. Here you'll
## Running SurfSense with Microsoft Teams Connector
Add the Microsoft Teams credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
Add the Microsoft OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
```bash
TEAMS_CLIENT_ID=your_microsoft_client_id
TEAMS_CLIENT_SECRET=your_microsoft_client_secret
MICROSOFT_CLIENT_ID=your_microsoft_client_id
MICROSOFT_CLIENT_SECRET=your_microsoft_client_secret
TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback
```
<Callout type="info">
The `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET` are shared between the Teams and OneDrive connectors. You only need to set them once.
</Callout>
Then restart the services:
```bash

View file

@ -117,7 +117,7 @@ Uncomment the connectors you want to use. Redirect URIs follow the pattern `http
| Linear | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_REDIRECT_URI` |
| ClickUp | `CLICKUP_CLIENT_ID`, `CLICKUP_CLIENT_SECRET`, `CLICKUP_REDIRECT_URI` |
| Airtable | `AIRTABLE_CLIENT_ID`, `AIRTABLE_CLIENT_SECRET`, `AIRTABLE_REDIRECT_URI` |
| Microsoft Teams | `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_REDIRECT_URI` |
| Microsoft (Teams & OneDrive) | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `TEAMS_REDIRECT_URI`, `ONEDRIVE_REDIRECT_URI` |
### Observability (optional)

View file

@ -127,9 +127,10 @@ Edit the `.env` file and set the following variables:
| SLACK_CLIENT_ID | (Optional) Slack OAuth client ID |
| SLACK_CLIENT_SECRET | (Optional) Slack OAuth client secret |
| SLACK_REDIRECT_URI | (Optional) Redirect URI for Slack connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/slack/connector/callback`) |
| TEAMS_CLIENT_ID | (Optional) Microsoft Teams OAuth client ID |
| TEAMS_CLIENT_SECRET | (Optional) Microsoft Teams OAuth client secret |
| MICROSOFT_CLIENT_ID | (Optional) Microsoft OAuth client ID (shared for Teams and OneDrive) |
| MICROSOFT_CLIENT_SECRET | (Optional) Microsoft OAuth client secret (shared for Teams and OneDrive) |
| TEAMS_REDIRECT_URI | (Optional) Redirect URI for Teams connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/teams/connector/callback`) |
| ONEDRIVE_REDIRECT_URI | (Optional) Redirect URI for OneDrive connector OAuth callback (e.g., `http://localhost:8000/api/v1/auth/onedrive/connector/callback`) |
**(Optional) Backend LangSmith Observability:**
| ENV VARIABLE | DESCRIPTION |

View file

@ -6,6 +6,7 @@ export enum EnumConnectorName {
BAIDU_SEARCH_API = "BAIDU_SEARCH_API",
SLACK_CONNECTOR = "SLACK_CONNECTOR",
TEAMS_CONNECTOR = "TEAMS_CONNECTOR",
ONEDRIVE_CONNECTOR = "ONEDRIVE_CONNECTOR",
NOTION_CONNECTOR = "NOTION_CONNECTOR",
GITHUB_CONNECTOR = "GITHUB_CONNECTOR",
LINEAR_CONNECTOR = "LINEAR_CONNECTOR",

View file

@ -39,6 +39,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <Image src="/connectors/slack.svg" alt="Slack" {...imgProps} />;
case EnumConnectorName.TEAMS_CONNECTOR:
return <Image src="/connectors/microsoft-teams.svg" alt="Microsoft Teams" {...imgProps} />;
case EnumConnectorName.ONEDRIVE_CONNECTOR:
return <Image src="/connectors/onedrive.svg" alt="OneDrive" {...imgProps} />;
case EnumConnectorName.NOTION_CONNECTOR:
return <Image src="/connectors/notion.svg" alt="Notion" {...imgProps} />;
case EnumConnectorName.DISCORD_CONNECTOR:
@ -98,6 +100,9 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <File {...iconProps} />;
case "GOOGLE_DRIVE_FILE":
return <Image src="/connectors/google-drive.svg" alt="Google Drive" {...imgProps} />;
case "ONEDRIVE_FILE":
case "ONEDRIVE_CONNECTOR":
return <Image src="/connectors/onedrive.svg" alt="OneDrive" {...imgProps} />;
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
return <Image src="/connectors/google-drive.svg" alt="Google Drive" {...imgProps} />;
case "COMPOSIO_GMAIL_CONNECTOR":

View file

@ -9,6 +9,7 @@ export const searchSourceConnectorTypeEnum = z.enum([
"BAIDU_SEARCH_API",
"SLACK_CONNECTOR",
"TEAMS_CONNECTOR",
"ONEDRIVE_CONNECTOR",
"NOTION_CONNECTOR",
"GITHUB_CONNECTOR",
"LINEAR_CONNECTOR",
@ -53,7 +54,7 @@ export const searchSourceConnector = z.object({
export const googleDriveItem = z.object({
id: z.string(),
name: z.string(),
mimeType: z.string(),
mimeType: z.string().optional().default("application/octet-stream"),
isFolder: z.boolean(),
parents: z.array(z.string()).optional(),
size: z.coerce.number().optional(),

View file

@ -7,6 +7,7 @@ export const documentTypeEnum = z.enum([
"FILE",
"SLACK_CONNECTOR",
"TEAMS_CONNECTOR",
"ONEDRIVE_FILE",
"NOTION_CONNECTOR",
"YOUTUBE_VIDEO",
"GITHUB_CONNECTOR",

View file

@ -1,28 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
interface UseComposioDriveFoldersOptions {
connectorId: number;
parentId?: string;
enabled?: boolean;
}
export function useComposioDriveFolders({
connectorId,
parentId,
enabled = true,
}: UseComposioDriveFoldersOptions) {
return useQuery({
queryKey: cacheKeys.connectors.composioDrive.folders(connectorId, parentId),
queryFn: async () => {
return connectorsApiService.listComposioDriveFolders({
connector_id: connectorId,
parent_id: parentId,
});
},
enabled: enabled && !!connectorId,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
});
}

View file

@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
export interface PickerItem {
@ -159,7 +160,9 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption
}
if (action === google.picker.Action.ERROR) {
setError("Google Drive encountered an error. Please try again.");
const msg = "Google Drive encountered an error. Please try again.";
setError(msg);
toast.error("Google Drive Picker failed", { description: msg });
}
if (
@ -180,6 +183,7 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption
openingRef.current = false;
const msg = err instanceof Error ? err.message : "Failed to open Google Picker";
setError(msg);
toast.error("Google Drive Picker failed", { description: msg });
console.error("Google Picker error:", err);
} finally {
setLoading(false);

View file

@ -277,6 +277,19 @@ class ConnectorsApiService {
}>(`/api/v1/connectors/${connectorId}/drive-picker-token`);
};
/**
* List OneDrive folders and files
*/
listOneDriveFolders = async (request: { connector_id: number; parent_id?: string }) => {
const queryParams = request.parent_id
? `?parent_id=${encodeURIComponent(request.parent_id)}`
: "";
return baseApiService.get(
`/api/v1/connectors/${request.connector_id}/onedrive/folders${queryParams}`,
listGoogleDriveFoldersResponse
);
};
// =============================================================================
// MCP Connector Methods
// =============================================================================

View file

@ -132,11 +132,30 @@ export function buildContentForPersistence(
return parts.length > 0 ? parts : [{ type: "text", text: "" }];
}
export type SSEEvent =
| { type: "text-delta"; delta: string }
| { type: "tool-input-start"; toolCallId: string; toolName: string }
| {
type: "tool-input-available";
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
}
| {
type: "tool-output-available";
toolCallId: string;
output: Record<string, unknown>;
}
| { type: "data-thinking-step"; data: ThinkingStepData }
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
| { type: "data-interrupt-request"; data: Record<string, unknown> }
| { type: "error"; errorText: string };
/**
* Async generator that reads an SSE stream and yields parsed JSON objects.
* Handles buffering, event splitting, and skips malformed JSON / [DONE] lines.
*/
export async function* readSSEStream(response: Response): AsyncGenerator<unknown> {
export async function* readSSEStream(response: Response): AsyncGenerator<SSEEvent> {
if (!response.body) {
throw new Error("No response body");
}

View file

@ -8,6 +8,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
BAIDU_SEARCH_API: "Baidu Search",
SLACK_CONNECTOR: "Slack",
TEAMS_CONNECTOR: "Microsoft Teams",
ONEDRIVE_CONNECTOR: "OneDrive",
NOTION_CONNECTOR: "Notion",
GITHUB_CONNECTOR: "GitHub",
LINEAR_CONNECTOR: "Linear",

View file

@ -79,10 +79,6 @@ export const cacheKeys = {
folders: (connectorId: number, parentId?: string) =>
["connectors", "google-drive", connectorId, "folders", parentId] as const,
},
composioDrive: {
folders: (connectorId: number, parentId?: string) =>
["connectors", "composio-drive", connectorId, "folders", parentId] as const,
},
},
comments: {
byMessage: (messageId: number) => ["comments", "message", messageId] as const,

View file

@ -1,155 +1 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 100 100;" version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#F5BB41;}
.st2{fill:#2167D1;}
.st3{fill:#3D84F3;}
.st4{fill:#4CA853;}
.st5{fill:#398039;}
.st6{fill:#D74F3F;}
.st7{fill:#D43C89;}
.st8{fill:#B2005F;}
.st9{fill:none;stroke:#000000;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#000000;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:none;stroke:#040404;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
.st12{fill-rule:evenodd;clip-rule:evenodd;}
.st13{fill-rule:evenodd;clip-rule:evenodd;fill:#040404;}
.st14{fill:url(#SVGID_1_);}
.st15{fill:url(#SVGID_2_);}
.st16{fill:url(#SVGID_3_);}
.st17{fill:url(#SVGID_4_);}
.st18{fill:url(#SVGID_5_);}
.st19{fill:url(#SVGID_6_);}
.st20{fill:url(#SVGID_7_);}
.st21{fill:url(#SVGID_8_);}
.st22{fill:url(#SVGID_9_);}
.st23{fill:url(#SVGID_10_);}
.st24{fill:url(#SVGID_11_);}
.st25{fill:url(#SVGID_12_);}
.st26{fill:url(#SVGID_13_);}
.st27{fill:url(#SVGID_14_);}
.st28{fill:url(#SVGID_15_);}
.st29{fill:url(#SVGID_16_);}
.st30{fill:url(#SVGID_17_);}
.st31{fill:url(#SVGID_18_);}
.st32{fill:url(#SVGID_19_);}
.st33{fill:url(#SVGID_20_);}
.st34{fill:url(#SVGID_21_);}
.st35{fill:url(#SVGID_22_);}
.st36{fill:url(#SVGID_23_);}
.st37{fill:url(#SVGID_24_);}
.st38{fill:url(#SVGID_25_);}
.st39{fill:url(#SVGID_26_);}
.st40{fill:url(#SVGID_27_);}
.st41{fill:url(#SVGID_28_);}
.st42{fill:url(#SVGID_29_);}
.st43{fill:url(#SVGID_30_);}
.st44{fill:url(#SVGID_31_);}
.st45{fill:url(#SVGID_32_);}
.st46{fill:url(#SVGID_33_);}
.st47{fill:url(#SVGID_34_);}
.st48{fill:url(#SVGID_35_);}
.st49{fill:url(#SVGID_36_);}
.st50{fill:url(#SVGID_37_);}
.st51{fill:url(#SVGID_38_);}
.st52{fill:url(#SVGID_39_);}
.st53{fill:url(#SVGID_40_);}
.st54{fill:url(#SVGID_41_);}
.st55{fill:url(#SVGID_42_);}
.st56{fill:url(#SVGID_43_);}
.st57{fill:url(#SVGID_44_);}
.st58{fill:url(#SVGID_45_);}
.st59{fill:#040404;}
.st60{fill:url(#SVGID_46_);}
.st61{fill:url(#SVGID_47_);}
.st62{fill:url(#SVGID_48_);}
.st63{fill:url(#SVGID_49_);}
.st64{fill:url(#SVGID_50_);}
.st65{fill:url(#SVGID_51_);}
.st66{fill:url(#SVGID_52_);}
.st67{fill:url(#SVGID_53_);}
.st68{fill:url(#SVGID_54_);}
.st69{fill:url(#SVGID_55_);}
.st70{fill:url(#SVGID_56_);}
.st71{fill:url(#SVGID_57_);}
.st72{fill:url(#SVGID_58_);}
.st73{fill:url(#SVGID_59_);}
.st74{fill:url(#SVGID_60_);}
.st75{fill:url(#SVGID_61_);}
.st76{fill:url(#SVGID_62_);}
.st77{fill:none;stroke:#000000;stroke-width:3;stroke-miterlimit:10;}
.st78{fill:none;stroke:#FFFFFF;stroke-miterlimit:10;}
.st79{fill:#4BC9FF;}
.st80{fill:#5500DD;}
.st81{fill:#FF3A00;}
.st82{fill:#E6162D;}
.st83{fill:#F1F1F1;}
.st84{fill:#FF9933;}
.st85{fill:#B92B27;}
.st86{fill:#00ACED;}
.st87{fill:#BD2125;}
.st88{fill:#1877F2;}
.st89{fill:#6665D2;}
.st90{fill:#CE3056;}
.st91{fill:#5BB381;}
.st92{fill:#61C3EC;}
.st93{fill:#E4B34B;}
.st94{fill:#181EF2;}
.st95{fill:#FF0000;}
.st96{fill:#FE466C;}
.st97{fill:#FA4778;}
.st98{fill:#FF7700;}
.st99{fill-rule:evenodd;clip-rule:evenodd;fill:#1F6BF6;}
.st100{fill:#520094;}
.st101{fill:#4477E8;}
.st102{fill:#3D1D1C;}
.st103{fill:#FFE812;}
.st104{fill:#344356;}
.st105{fill:#00CC76;}
.st106{fill-rule:evenodd;clip-rule:evenodd;fill:#345E90;}
.st107{fill:#1F65D8;}
.st108{fill:#EB3587;}
.st109{fill-rule:evenodd;clip-rule:evenodd;fill:#603A88;}
.st110{fill:#E3CE99;}
.st111{fill:#783AF9;}
.st112{fill:#FF515E;}
.st113{fill:#FF4906;}
.st114{fill:#503227;}
.st115{fill:#4C7BD9;}
.st116{fill:#69C9D0;}
.st117{fill:#1B92D1;}
.st118{fill:#EB4F4A;}
.st119{fill:#513728;}
.st120{fill:#FF6600;}
.st121{fill-rule:evenodd;clip-rule:evenodd;fill:#B61438;}
.st122{fill:#FFFC00;}
.st123{fill:#141414;}
.st124{fill:#94D137;}
.st125{fill-rule:evenodd;clip-rule:evenodd;fill:#F1F1F1;}
.st126{fill-rule:evenodd;clip-rule:evenodd;fill:#66E066;}
.st127{fill:#2D8CFF;}
.st128{fill:#F1A300;}
.st129{fill:#4BA2F2;}
.st130{fill:#1A5099;}
.st131{fill:#EE6060;}
.st132{fill-rule:evenodd;clip-rule:evenodd;fill:#F48120;}
.st133{fill:#222222;}
.st134{fill:url(#SVGID_63_);}
.st135{fill:#0077B5;}
.st136{fill:#FFCC00;}
.st137{fill:#EB3352;}
.st138{fill:#F9D265;}
.st139{fill:#F5B955;}
.st140{fill:#DD2A7B;}
.st141{fill:#66E066;}
.st142{fill:#EB4E00;}
.st143{fill:#FFC794;}
.st144{fill:#B5332A;}
.st145{fill:#4E85EB;}
.st146{fill:#58A45C;}
.st147{fill:#F2BC42;}
.st148{fill:#D85040;}
.st149{fill:#464EB8;}
.st150{fill:#7B83EB;}
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st149" d="M84.025,35.881c5.797,0,10.513-4.729,10.513-10.54c-0.577-13.983-20.45-13.979-21.026,0 C73.512,31.152,78.229,35.881,84.025,35.881z"/><path class="st149" d="M90.958,38.71H72.193h-0.036c-0.005,0-0.003,0-0.009,0c-1.123,0-15.805,0-20.538,0v-3.68 c0.784,0.139,1.605,0.232,2.467,0.268c0.093,0.001,0.186-0.006,0.279-0.007c0.358-0.006,0.713-0.023,1.063-0.053 c0.12-0.011,0.239-0.021,0.357-0.035c0.403-0.045,0.801-0.104,1.193-0.181c0.024-0.005,0.05-0.008,0.074-0.012 c1.858-0.379,3.61-1.12,5.167-2.17c1.44-0.971,2.687-2.203,3.693-3.615c0.26-0.341,0.497-0.697,0.718-1.061 c0.021-0.036,0.044-0.07,0.065-0.107c0.17-0.287,0.32-0.584,0.466-0.884c0.064-0.13,0.13-0.26,0.19-0.392 c0.154-0.345,0.296-0.696,0.421-1.053c0.011-0.03,0.022-0.059,0.032-0.088c1.427-4.208,0.774-9.156-1.676-12.856 c-0.648-0.949-1.417-1.806-2.268-2.574c-0.176-0.153-0.344-0.314-0.529-0.457c-0.714-0.588-1.485-1.109-2.304-1.552 c-0.41-0.222-0.831-0.425-1.263-0.607c-0.434-0.192-0.887-0.35-1.347-0.493c-0.264-0.081-0.538-0.141-0.808-0.207 c-0.239-0.058-0.475-0.121-0.717-0.166c-0.2-0.038-0.405-0.062-0.607-0.092c-0.352-0.05-0.704-0.096-1.06-0.121 c-0.122-0.009-0.245-0.012-0.368-0.018C54.486,6.479,54.123,6.48,53.76,6.49c-2.08,0.121-3.926,0.558-5.543,1.24 c-0.33,0.149-0.664,0.294-0.975,0.47C44,9.966,41.52,12.972,40.375,16.493c-0.794,2.629-0.862,5.468-0.187,8.129 c0.007,0.025,0.013,0.051,0.02,0.076c0.032,0.115,0.065,0.23,0.097,0.345c0.039,0.137,0.085,0.273,0.128,0.409 c0.039,0.11,0.08,0.219,0.121,0.329H8.774c-2.846,0-5.162,2.316-5.162,5.162v37.672c0,2.847,2.316,5.162,5.162,5.162h20.122 c0.026,0.118,0.059,0.232,0.087,0.349C31.753,85.025,41.446,92.733,52.9,93.011c9.503-0.231,17.666-5.721,21.753-13.592 c0.061,0.022,0.124,0.038,0.185,0.059c10.182,3.851,21.752-4.229,21.546-15.131V44.122C96.385,41.138,93.95,38.71,90.958,38.71z"/><g><g><path class="st150" d="M77.444,44.232c0.069-2.971-2.287-5.448-5.251-5.521c-0.012,0-21.432,0-21.432,0 c-0.789,0-1.429,0.641-1.429,1.433v29.095c0,1.342-1.089,2.433-2.428,2.433H30.199c-0.429,0-0.836,0.194-1.107,0.527 c-0.271,0.334-0.379,0.772-0.292,1.194c2.367,11.561,12.248,19.837,24.1,20.126c13.856-0.34,24.866-11.914,24.544-25.767 L77.444,44.232z"/></g><path class="st150" d="M54.077,35.298c0.093,0.001,0.186-0.006,0.279-0.007c0.358-0.005,0.713-0.023,1.064-0.053 c0.12-0.011,0.239-0.021,0.357-0.035c0.402-0.045,0.801-0.104,1.193-0.181c0.024-0.005,0.05-0.008,0.074-0.013 c1.858-0.379,3.61-1.12,5.167-2.17c1.441-0.971,2.687-2.203,3.694-3.615c0.26-0.341,0.497-0.697,0.718-1.061 c0.021-0.036,0.044-0.07,0.065-0.107c0.17-0.287,0.32-0.585,0.466-0.884c0.064-0.13,0.13-0.259,0.19-0.392 c0.154-0.345,0.297-0.696,0.421-1.053c0.011-0.03,0.022-0.059,0.032-0.088c1.427-4.208,0.774-9.157-1.676-12.856 c-0.648-0.949-1.417-1.806-2.268-2.574c-0.176-0.153-0.344-0.314-0.529-0.457c-0.714-0.588-1.485-1.109-2.304-1.552 c-0.41-0.222-0.831-0.425-1.263-0.607c-0.434-0.192-0.887-0.35-1.347-0.493c-0.264-0.081-0.538-0.14-0.808-0.207 c-0.239-0.058-0.475-0.121-0.717-0.166c-0.2-0.038-0.404-0.062-0.607-0.092c-0.352-0.05-0.704-0.096-1.06-0.121 c-0.122-0.009-0.245-0.012-0.367-0.018c-0.362-0.016-0.725-0.015-1.088-0.005c-2.08,0.121-3.926,0.557-5.543,1.24 c-0.33,0.149-0.664,0.294-0.975,0.47c-3.242,1.767-5.723,4.773-6.867,8.294c-0.794,2.629-0.862,5.468-0.187,8.129 c0.007,0.025,0.013,0.051,0.02,0.076c0.032,0.115,0.065,0.23,0.097,0.345c0.039,0.137,0.085,0.273,0.128,0.409 c0.06,0.171,0.123,0.34,0.187,0.51h-0.027C42.371,30.941,46.864,34.993,54.077,35.298z"/></g><g><path class="st149" d="M46.448,25.783H8.774c-2.846,0-5.162,2.316-5.162,5.162v37.672c0,2.847,2.316,5.162,5.162,5.162h37.674 c2.846,0,5.161-2.316,5.161-5.162V30.945C51.61,28.099,49.295,25.783,46.448,25.783z"/><path class="st0" d="M37.109,36.271h-19.28c-0.771,0-1.395,0.625-1.395,1.396l0,3.514c0,0.771,0.624,1.396,1.395,1.396h6.22 l0,19.575c0,0.771,0.624,1.396,1.395,1.396h4.134c0.771,0,1.395-0.625,1.395-1.396l0-19.575h6.136 c0.771,0,1.395-0.625,1.395-1.396l0-3.514C38.504,36.896,37.88,36.271,37.109,36.271z"/></g></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="4 4 36 38"><path fill="url(#a)" d="M21.9999 20h12c3.3137 0 6 2.6863 6 6v10c0 3.3137-2.6863 6-6 6s-6-2.6863-6-6V26c0-3.3137-2.6863-6-6-6"/><path fill="url(#b)" d="M7.99988 24c0-3.3137 2.68632-6 6.00002-6h8c3.3137 0 6 2.6863 6 6v12c0 3.3137 2.6863 6 6 6l-16.0001-.0001c-5.5228 0-9.99992-4.4771-9.99992-10z"/><path fill="url(#c)" fill-opacity=".7" d="M7.99988 24c0-3.3137 2.68632-6 6.00002-6h8c3.3137 0 6 2.6863 6 6v12c0 3.3137 2.6863 6 6 6l-16.0001-.0001c-5.5228 0-9.99992-4.4771-9.99992-10z"/><path fill="url(#d)" fill-opacity=".7" d="M7.99988 24c0-3.3137 2.68632-6 6.00002-6h8c3.3137 0 6 2.6863 6 6v12c0 3.3137 2.6863 6 6 6l-16.0001-.0001c-5.5228 0-9.99992-4.4771-9.99992-10z"/><path fill="url(#e)" d="M32.9999 18c2.7614 0 5-2.2386 5-5s-2.2386-5-5-5-5 2.2386-5 5 2.2386 5 5 5"/><path fill="url(#f)" fill-opacity=".46" d="M32.9999 18c2.7614 0 5-2.2386 5-5s-2.2386-5-5-5-5 2.2386-5 5 2.2386 5 5 5"/><path fill="url(#g)" fill-opacity=".4" d="M32.9999 18c2.7614 0 5-2.2386 5-5s-2.2386-5-5-5-5 2.2386-5 5 2.2386 5 5 5"/><path fill="url(#h)" d="M17.9999 16c3.3137 0 6-2.6863 6-6 0-3.31371-2.6863-6-6-6s-6 2.68629-6 6c0 3.3137 2.6863 6 6 6"/><path fill="url(#i)" fill-opacity=".6" d="M17.9999 16c3.3137 0 6-2.6863 6-6 0-3.31371-2.6863-6-6-6s-6 2.68629-6 6c0 3.3137 2.6863 6 6 6"/><path fill="url(#j)" fill-opacity=".5" d="M17.9999 16c3.3137 0 6-2.6863 6-6 0-3.31371-2.6863-6-6-6s-6 2.68629-6 6c0 3.3137 2.6863 6 6 6"/><rect width="16" height="16" x="4" y="23" fill="url(#k)" rx="3.25"/><rect width="16" height="16" x="4" y="23" fill="url(#l)" fill-opacity=".7" rx="3.25"/><path fill="#fff" d="M15.4792 28.1054h-2.4471v7.466h-2.0648v-7.466H8.52014v-1.6768h6.95906z"/><defs><radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="matrix(13.4784 0 0 33.2694 39.7967 22.1739)" gradientUnits="userSpaceOnUse"><stop stop-color="#a98aff"/><stop offset=".14" stop-color="#8c75ff"/><stop offset=".565" stop-color="#5f50e2"/><stop offset=".9" stop-color="#3c2cb8"/></radialGradient><radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="rotate(68.1539 -7.71566095 14.71355834)scale(32.752 33.1231)" gradientUnits="userSpaceOnUse"><stop stop-color="#85c2ff"/><stop offset=".69" stop-color="#7588ff"/><stop offset="1" stop-color="#6459fe"/></radialGradient><radialGradient id="d" cx="0" cy="0" r="1" gradientTransform="rotate(113.326 8.09285255 17.64474501)scale(19.2186 15.4273)" gradientUnits="userSpaceOnUse"><stop stop-color="#bd96ff"/><stop offset=".686685" stop-color="#bd96ff" stop-opacity="0"/></radialGradient><radialGradient id="e" cx="0" cy="0" r="1" gradientTransform="matrix(0 -10 12.6216 0 32.9999 11.5714)" gradientUnits="userSpaceOnUse"><stop offset=".268201" stop-color="#6868f7"/><stop offset="1" stop-color="#3923b1"/></radialGradient><radialGradient id="f" cx="0" cy="0" r="1" gradientTransform="rotate(40.0516 -.03068196 44.8729095)scale(7.14629 10.3363)" gradientUnits="userSpaceOnUse"><stop offset=".270711" stop-color="#a1d3ff"/><stop offset=".813393" stop-color="#a1d3ff" stop-opacity="0"/></radialGradient><radialGradient id="g" cx="0" cy="0" r="1" gradientTransform="rotate(-41.6581 32.11799918 -43.41948423)scale(8.51275 20.8824)" gradientUnits="userSpaceOnUse"><stop stop-color="#e3acfd"/><stop offset=".816041" stop-color="#9fa2ff" stop-opacity="0"/></radialGradient><radialGradient id="h" cx="0" cy="0" r="1" gradientTransform="matrix(0 -12 15.146 0 17.9999 8.28571)" gradientUnits="userSpaceOnUse"><stop offset=".268201" stop-color="#8282ff"/><stop offset="1" stop-color="#3923b1"/></radialGradient><radialGradient id="i" cx="0" cy="0" r="1" gradientTransform="rotate(40.0516 -3.15465147 21.41641466)scale(8.57554 12.4035)" gradientUnits="userSpaceOnUse"><stop offset=".270711" stop-color="#a1d3ff"/><stop offset=".813393" stop-color="#a1d3ff" stop-opacity="0"/></radialGradient><radialGradient id="j" cx="0" cy="0" r="1" gradientTransform="rotate(-41.6581 20.38180375 -26.51566158)scale(10.2153 25.0589)" gradientUnits="userSpaceOnUse"><stop stop-color="#e3acfd"/><stop offset=".816041" stop-color="#9fa2ff" stop-opacity="0"/></radialGradient><radialGradient id="k" cx="0" cy="0" r="1" gradientTransform="rotate(45 -25.76345597 16.32842712)scale(22.6274)" gradientUnits="userSpaceOnUse"><stop offset=".046875" stop-color="#688eff"/><stop offset=".946875" stop-color="#230f94"/></radialGradient><radialGradient id="l" cx="0" cy="0" r="1" gradientTransform="matrix(0 11.2 -13.0702 0 12 32.6)" gradientUnits="userSpaceOnUse"><stop offset=".570647" stop-color="#6965f6" stop-opacity="0"/><stop offset="1" stop-color="#8f8fff"/></radialGradient><linearGradient id="c" x1="20.5936" x2="20.5936" y1="18" y2="42" gradientUnits="userSpaceOnUse"><stop offset=".801159" stop-color="#6864f6" stop-opacity="0"/><stop offset="1" stop-color="#5149de"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before After
Before After

View file

@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="35.98 139.2 648.03 430.85">
<defs>
<radialGradient id="radial0" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(130.864814,156.804864,-260.089994,217.063603,48.669602,228.766494)">
<stop offset="0" style="stop-color:rgb(28.235294%,58.039216%,99.607843%);stop-opacity:1;"/>
<stop offset="0.695072" style="stop-color:rgb(3.529412%,20.392157%,70.196078%);stop-opacity:1;"/>
</radialGradient>
<radialGradient id="radial1" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(-575.289668,663.594003,-491.728488,-426.294267,596.956501,-6.380235)">
<stop offset="0.165327" style="stop-color:rgb(13.72549%,75.294118%,99.607843%);stop-opacity:1;"/>
<stop offset="0.534" style="stop-color:rgb(10.980392%,56.862745%,100%);stop-opacity:1;"/>
</radialGradient>
<radialGradient id="radial2" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(-136.753383,-114.806698,262.816935,-313.057562,181.196995,240.395994)">
<stop offset="0" style="stop-color:rgb(100%,100%,100%);stop-opacity:0.4;"/>
<stop offset="0.660528" style="stop-color:rgb(67.843137%,75.294118%,100%);stop-opacity:0;"/>
</radialGradient>
<radialGradient id="radial3" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(-153.638428,-130.000063,197.433014,-233.332948,375.353994,451.43549)">
<stop offset="0" style="stop-color:rgb(1.176471%,22.745098%,80%);stop-opacity:1;"/>
<stop offset="1" style="stop-color:rgb(21.176471%,55.686275%,100%);stop-opacity:0;"/>
</radialGradient>
<radialGradient id="radial4" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(175.585899,405.198026,-437.434522,189.555055,169.378495,125.589294)">
<stop offset="0.592618" style="stop-color:rgb(20.392157%,39.215686%,89.019608%);stop-opacity:0;"/>
<stop offset="1" style="stop-color:rgb(1.176471%,22.745098%,80%);stop-opacity:0.6;"/>
</radialGradient>
<radialGradient id="radial5" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(-459.329491,459.329491,-719.614455,-719.614455,589.876499,39.484649)">
<stop offset="0" style="stop-color:rgb(29.411765%,99.215686%,90.980392%);stop-opacity:0.898039;"/>
<stop offset="0.543937" style="stop-color:rgb(29.411765%,99.215686%,90.980392%);stop-opacity:0;"/>
</radialGradient>
<linearGradient id="linear0" gradientUnits="userSpaceOnUse" x1="29.999701" y1="37.9823" x2="29.999701" y2="18.398199" gradientTransform="matrix(15,0,0,15,0,0)">
<stop offset="0" style="stop-color:rgb(0%,52.54902%,100%);stop-opacity:1;"/>
<stop offset="0.49" style="stop-color:rgb(0%,73.333333%,100%);stop-opacity:1;"/>
</linearGradient>
<radialGradient id="radial6" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(273.622108,108.513684,-205.488428,518.148261,296.488495,307.441492)">
<stop offset="0" style="stop-color:rgb(100%,100%,100%);stop-opacity:0.4;"/>
<stop offset="0.785262" style="stop-color:rgb(100%,100%,100%);stop-opacity:0;"/>
</radialGradient>
<radialGradient id="radial7" gradientUnits="userSpaceOnUse" cx="0" cy="0" fx="0" fy="0" r="1" gradientTransform="matrix(-305.683909,263.459223,-264.352324,-306.720147,674.845505,249.378004)">
<stop offset="0" style="stop-color:rgb(29.411765%,99.215686%,90.980392%);stop-opacity:0.898039;"/>
<stop offset="0.584724" style="stop-color:rgb(29.411765%,99.215686%,90.980392%);stop-opacity:0;"/>
</radialGradient>
</defs>
<g id="surface1">
<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial0);" d="M 215.078125 205.089844 C 116.011719 205.09375 41.957031 286.1875 36.382812 376.527344 C 39.835938 395.992188 51.175781 434.429688 68.941406 432.457031 C 91.144531 429.988281 147.066406 432.457031 194.765625 346.105469 C 229.609375 283.027344 301.285156 205.085938 215.078125 205.089844 Z M 215.078125 205.089844 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial1);" d="M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial2);" d="M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial3);" d="M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial4);" d="M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial5);" d="M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear0);" d="M 215.699219 569.496094 C 215.699219 569.496094 489.320312 570.035156 535.734375 570.035156 C 619.960938 570.035156 684 501.273438 684 421.03125 C 684 340.789062 618.671875 272.445312 535.734375 272.445312 C 452.792969 272.445312 405.027344 334.492188 369.152344 402.226562 C 327.117188 481.59375 273.488281 568.546875 215.699219 569.496094 Z M 215.699219 569.496094 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial6);" d="M 215.699219 569.496094 C 215.699219 569.496094 489.320312 570.035156 535.734375 570.035156 C 619.960938 570.035156 684 501.273438 684 421.03125 C 684 340.789062 618.671875 272.445312 535.734375 272.445312 C 452.792969 272.445312 405.027344 334.492188 369.152344 402.226562 C 327.117188 481.59375 273.488281 568.546875 215.699219 569.496094 Z M 215.699219 569.496094 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:url(#radial7);" d="M 215.699219 569.496094 C 215.699219 569.496094 489.320312 570.035156 535.734375 570.035156 C 619.960938 570.035156 684 501.273438 684 421.03125 C 684 340.789062 618.671875 272.445312 535.734375 272.445312 C 452.792969 272.445312 405.027344 334.492188 369.152344 402.226562 C 327.117188 481.59375 273.488281 568.546875 215.699219 569.496094 Z M 215.699219 569.496094 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8 KiB