mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 01:06:23 +02:00
Merge remote-tracking branch 'upstream/dev' into feature/prompt-library
This commit is contained in:
commit
1aeb5ba645
66 changed files with 4561 additions and 139 deletions
|
|
@ -69,6 +69,7 @@ import {
|
|||
DeleteNotionPageToolUI,
|
||||
UpdateNotionPageToolUI,
|
||||
} from "@/components/tool-ui/notion";
|
||||
import { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "@/components/tool-ui/dropbox";
|
||||
import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive";
|
||||
import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute";
|
||||
import {
|
||||
|
|
@ -261,6 +262,8 @@ const AssistantMessageInner: FC = () => {
|
|||
delete_google_drive_file: DeleteGoogleDriveFileToolUI,
|
||||
create_onedrive_file: CreateOneDriveFileToolUI,
|
||||
delete_onedrive_file: DeleteOneDriveFileToolUI,
|
||||
create_dropbox_file: CreateDropboxFileToolUI,
|
||||
delete_dropbox_file: DeleteDropboxFileToolUI,
|
||||
create_calendar_event: CreateCalendarEventToolUI,
|
||||
update_calendar_event: UpdateCalendarEventToolUI,
|
||||
delete_calendar_event: DeleteCalendarEventToolUI,
|
||||
|
|
|
|||
|
|
@ -298,10 +298,11 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={(() => {
|
||||
const cfg = connectorConfig || editingConnector.config;
|
||||
const isDriveOrOneDrive =
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR";
|
||||
const isDriveOrOneDrive =
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
|
||||
const hasDriveItems = isDriveOrOneDrive
|
||||
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
|
||||
((cfg?.selected_files as unknown[]) ?? []).length > 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,334 @@
|
|||
"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 DropboxConfig: 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.listDropboxFolders({
|
||||
connector_id: connector.id,
|
||||
parent_path: 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">
|
||||
<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 Dropbox.
|
||||
</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 Dropbox 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="Dropbox"
|
||||
providerName="Dropbox"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<DriveFolderTree
|
||||
fetchItems={fetchItems}
|
||||
selectedFolders={selectedFolders}
|
||||
onSelectFolders={handleSelectFolders}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectFiles={handleSelectFiles}
|
||||
onAuthError={handleAuthError}
|
||||
rootLabel="Dropbox"
|
||||
providerName="Dropbox"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 Dropbox.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="db-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="db-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>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="db-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="db-incremental-sync"
|
||||
checked={indexingOptions.incremental_sync}
|
||||
onCheckedChange={(checked) => handleIndexingOptionChange("incremental_sync", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="db-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="db-include-subfolders"
|
||||
checked={indexingOptions.include_subfolders}
|
||||
onCheckedChange={(checked) => handleIndexingOptionChange("include_subfolders", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ import { ComposioDriveConfig } from "./components/composio-drive-config";
|
|||
import { ComposioGmailConfig } from "./components/composio-gmail-config";
|
||||
import { ConfluenceConfig } from "./components/confluence-config";
|
||||
import { DiscordConfig } from "./components/discord-config";
|
||||
import { DropboxConfig } from "./components/dropbox-config";
|
||||
import { ElasticsearchConfig } from "./components/elasticsearch-config";
|
||||
import { GithubConfig } from "./components/github-config";
|
||||
import { GoogleDriveConfig } from "./components/google-drive-config";
|
||||
|
|
@ -59,6 +60,8 @@ export function getConnectorConfigComponent(
|
|||
return DiscordConfig;
|
||||
case "TEAMS_CONNECTOR":
|
||||
return TeamsConfig;
|
||||
case "DROPBOX_CONNECTOR":
|
||||
return DropboxConfig;
|
||||
case "ONEDRIVE_CONNECTOR":
|
||||
return OneDriveConfig;
|
||||
case "CONFLUENCE_CONNECTOR":
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
|
|||
[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.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth",
|
||||
};
|
||||
|
||||
interface ConnectorEditViewProps {
|
||||
|
|
@ -270,9 +271,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
{/* AI Summary toggle */}
|
||||
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
|
||||
|
||||
{/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "DROPBOX_CONNECTOR" &&
|
||||
connector.connector_type !== "ONEDRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
|
||||
connector.connector_type !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
|
|
|
|||
|
|
@ -158,11 +158,13 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
{/* AI Summary toggle */}
|
||||
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
|
||||
|
||||
{/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
|
|
@ -219,20 +221,14 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-muted">
|
||||
<Button
|
||||
onClick={onStartIndexing}
|
||||
disabled={isStartingIndexing}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{isStartingIndexing ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Starting
|
||||
</>
|
||||
) : (
|
||||
"Start Indexing"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onStartIndexing}
|
||||
disabled={isStartingIndexing}
|
||||
className="text-xs sm:text-sm relative"
|
||||
>
|
||||
<span className={isStartingIndexing ? "opacity-0" : ""}>Start Indexing</span>
|
||||
{isStartingIndexing && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -68,6 +68,13 @@ export const OAUTH_CONNECTORS = [
|
|||
connectorType: EnumConnectorName.ONEDRIVE_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/onedrive/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "dropbox-connector",
|
||||
title: "Dropbox",
|
||||
description: "Search your Dropbox files",
|
||||
connectorType: EnumConnectorName.DROPBOX_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/dropbox/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "discord-connector",
|
||||
title: "Discord",
|
||||
|
|
|
|||
|
|
@ -729,11 +729,12 @@ export const useConnectorDialog = () => {
|
|||
async (refreshConnectors: () => void) => {
|
||||
if (!indexingConfig || !searchSpaceId) return;
|
||||
|
||||
// Validate date range (skip for Google Drive, Composio Drive, OneDrive, and Webcrawler)
|
||||
// Validate date range (skip for Google Drive, Composio Drive, OneDrive, Dropbox, and Webcrawler)
|
||||
if (
|
||||
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
indexingConfig.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
indexingConfig.connectorType !== "ONEDRIVE_CONNECTOR" &&
|
||||
indexingConfig.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR"
|
||||
) {
|
||||
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
|
||||
|
|
@ -779,11 +780,12 @@ export const useConnectorDialog = () => {
|
|||
});
|
||||
}
|
||||
|
||||
// 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") &&
|
||||
// Handle Google Drive / OneDrive / Dropbox folder selection (regular and Composio)
|
||||
if (
|
||||
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "ONEDRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "DROPBOX_CONNECTOR") &&
|
||||
indexingConnectorConfig
|
||||
) {
|
||||
const selectedFolders = indexingConnectorConfig.selected_folders as
|
||||
|
|
@ -969,11 +971,12 @@ export const useConnectorDialog = () => {
|
|||
async (refreshConnectors: () => void) => {
|
||||
if (!editingConnector || !searchSpaceId || isSaving) return;
|
||||
|
||||
// Validate date range (skip for Google Drive/OneDrive which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
|
||||
// Validate date range (skip for Google Drive/OneDrive/Dropbox 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 !== "DROPBOX_CONNECTOR" &&
|
||||
editingConnector.connector_type !== "WEBCRAWLER_CONNECTOR"
|
||||
) {
|
||||
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
|
||||
|
|
@ -989,12 +992,13 @@ export const useConnectorDialog = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Prevent periodic indexing for Google Drive / OneDrive (regular or Composio) without folders/files selected
|
||||
// Prevent periodic indexing for Google Drive / OneDrive / Dropbox (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 === "ONEDRIVE_CONNECTOR")
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR")
|
||||
) {
|
||||
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
|
|
@ -1045,12 +1049,13 @@ export const useConnectorDialog = () => {
|
|||
if (!editingConnector.is_indexable) {
|
||||
// Non-indexable connectors (like Tavily API) don't need re-indexing
|
||||
indexingDescription = "Settings saved.";
|
||||
} else if (
|
||||
editingConnector.connector_type === "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
|
||||
} else if (
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR"
|
||||
) {
|
||||
// Google Drive (both regular and Composio) / OneDrive / Dropbox uses folder selection from config, not date ranges
|
||||
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
|
|||
SLACK_CONNECTOR: "SLACK_CONNECTOR",
|
||||
TEAMS_CONNECTOR: "TEAMS_CONNECTOR",
|
||||
ONEDRIVE_CONNECTOR: "ONEDRIVE_FILE",
|
||||
DROPBOX_CONNECTOR: "DROPBOX_FILE",
|
||||
NOTION_CONNECTOR: "NOTION_CONNECTOR",
|
||||
GITHUB_CONNECTOR: "GITHUB_CONNECTOR",
|
||||
LINEAR_CONNECTOR: "LINEAR_CONNECTOR",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
|
|||
[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.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth",
|
||||
[EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="text-[10px] font-bold bg-primary/80 hover:bg-primary text-primary-foreground rounded-full min-w-4 h-4 px-1 inline-flex items-center justify-center align-super cursor-pointer transition-colors ml-0.5"
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-super shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
title={`View source chunk #${chunkId}`}
|
||||
>
|
||||
{chunkId}
|
||||
|
|
|
|||
|
|
@ -897,24 +897,6 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
return result;
|
||||
}, [filteredTools, connectedTypes]);
|
||||
|
||||
const { visibleTotal, visibleEnabled } = useMemo(() => {
|
||||
let total = 0;
|
||||
let enabled = 0;
|
||||
for (const group of groupedTools) {
|
||||
if (group.connectorIcon) {
|
||||
total += 1;
|
||||
const allDisabled = group.tools.every((t) => disabledTools.includes(t.name));
|
||||
if (!allDisabled) enabled += 1;
|
||||
} else {
|
||||
for (const tool of group.tools) {
|
||||
total += 1;
|
||||
if (!disabledTools.includes(tool.name)) enabled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { visibleTotal: total, visibleEnabled: enabled };
|
||||
}, [groupedTools, disabledTools]);
|
||||
|
||||
useEffect(() => {
|
||||
hydrateDisabled();
|
||||
}, [hydrateDisabled]);
|
||||
|
|
@ -963,11 +945,8 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
<Drawer open={toolsPopoverOpen} onOpenChange={setToolsPopoverOpen}>
|
||||
<DrawerContent className="max-h-[60dvh]">
|
||||
<DrawerHandle />
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<DrawerTitle className="text-sm font-medium">Agent Tools</DrawerTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{visibleEnabled}/{visibleTotal} enabled
|
||||
</span>
|
||||
<div className="px-4 py-2">
|
||||
<DrawerTitle className="text-sm font-medium">Manage Tools</DrawerTitle>
|
||||
</div>
|
||||
<div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}>
|
||||
{groupedTools
|
||||
|
|
@ -1082,12 +1061,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
className="w-[calc(100vw-2rem)] max-w-56 sm:max-w-72 sm:w-72 p-0 select-none"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2.5 py-2 sm:px-3 sm:py-2.5 border-b">
|
||||
<span className="text-xs sm:text-sm font-medium">Agent Tools</span>
|
||||
<span className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
{visibleEnabled}/{visibleTotal} enabled
|
||||
</span>
|
||||
</div>
|
||||
<div className="sr-only">Manage Tools</div>
|
||||
<div
|
||||
className="max-h-48 sm:max-h-64 overflow-y-auto py-0.5 sm:py-1"
|
||||
onScroll={handleToolsScroll}
|
||||
|
|
@ -1325,49 +1299,55 @@ const TOOL_GROUPS: ToolGroup[] = [
|
|||
label: "Gmail",
|
||||
tools: ["create_gmail_draft", "update_gmail_draft", "send_gmail_email", "trash_gmail_email"],
|
||||
connectorIcon: "gmail",
|
||||
tooltip: "Create drafts, update drafts, send emails, and trash emails in Gmail.",
|
||||
tooltip: "Create drafts, update drafts, send emails, and trash emails in Gmail",
|
||||
},
|
||||
{
|
||||
label: "Google Calendar",
|
||||
tools: ["create_calendar_event", "update_calendar_event", "delete_calendar_event"],
|
||||
connectorIcon: "google_calendar",
|
||||
tooltip: "Create, update, and delete events in Google Calendar.",
|
||||
tooltip: "Create, update, and delete events in Google Calendar",
|
||||
},
|
||||
{
|
||||
label: "Google Drive",
|
||||
tools: ["create_google_drive_file", "delete_google_drive_file"],
|
||||
connectorIcon: "google_drive",
|
||||
tooltip: "Create and delete files in 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.",
|
||||
tooltip: "Create and delete files in OneDrive",
|
||||
},
|
||||
{
|
||||
label: "Dropbox",
|
||||
tools: ["create_dropbox_file", "delete_dropbox_file"],
|
||||
connectorIcon: "dropbox",
|
||||
tooltip: "Create and delete files in Dropbox",
|
||||
},
|
||||
{
|
||||
label: "Notion",
|
||||
tools: ["create_notion_page", "update_notion_page", "delete_notion_page"],
|
||||
connectorIcon: "notion",
|
||||
tooltip: "Create, update, and delete pages in Notion.",
|
||||
tooltip: "Create, update, and delete pages in Notion",
|
||||
},
|
||||
{
|
||||
label: "Linear",
|
||||
tools: ["create_linear_issue", "update_linear_issue", "delete_linear_issue"],
|
||||
connectorIcon: "linear",
|
||||
tooltip: "Create, update, and delete issues in Linear.",
|
||||
tooltip: "Create, update, and delete issues in Linear",
|
||||
},
|
||||
{
|
||||
label: "Jira",
|
||||
tools: ["create_jira_issue", "update_jira_issue", "delete_jira_issue"],
|
||||
connectorIcon: "jira",
|
||||
tooltip: "Create, update, and delete issues in Jira.",
|
||||
tooltip: "Create, update, and delete issues in Jira",
|
||||
},
|
||||
{
|
||||
label: "Confluence",
|
||||
tools: ["create_confluence_page", "update_confluence_page", "delete_confluence_page"],
|
||||
connectorIcon: "confluence",
|
||||
tooltip: "Create, update, and delete pages in Confluence.",
|
||||
tooltip: "Create, update, and delete pages in Confluence",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
|
||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
|
||||
{/* Left side - Mobile menu trigger + Model selector */}
|
||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||
{mobileMenuTrigger}
|
||||
|
|
|
|||
|
|
@ -55,14 +55,14 @@ export function RightPanelExpandButton() {
|
|||
if (!collapsed || !hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 right-4 z-20 flex h-12 items-center">
|
||||
<div className="flex shrink-0 items-center px-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => startTransition(() => setCollapsed(false))}
|
||||
className="h-8 w-8 shrink-0"
|
||||
className="h-7 w-7 shrink-0"
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
<span className="sr-only">Expand panel</span>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,6 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
|
|
@ -121,42 +116,34 @@ function MainContentPanel({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useAtomValue(activeTabAtom);
|
||||
const rightPanelCollapsed = useAtomValue(rightPanelCollapsedAtom);
|
||||
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
const isDocumentTab = activeTab?.type === "document";
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const showRightPanelExpandButton =
|
||||
rightPanelCollapsed && (documentsOpen || reportOpen || editorOpen || hitlEditOpen);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0">
|
||||
<RightPanelExpandButton />
|
||||
<div className="relative flex flex-1 flex-col min-w-0">
|
||||
<TabBar
|
||||
onTabSwitch={onTabSwitch}
|
||||
onNewChat={onNewChat}
|
||||
className={showRightPanelExpandButton ? "pr-14" : undefined}
|
||||
rightActions={<RightPanelExpandButton />}
|
||||
className="min-w-0"
|
||||
/>
|
||||
<Header />
|
||||
<div className="relative flex flex-1 flex-col rounded-xl border bg-main-panel overflow-hidden min-w-0">
|
||||
<Header />
|
||||
|
||||
{isDocumentTab && activeTab.documentId && activeTab.searchSpaceId ? (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DocumentTabContent
|
||||
key={activeTab.documentId}
|
||||
documentId={activeTab.documentId}
|
||||
searchSpaceId={activeTab.searchSpaceId}
|
||||
title={activeTab.title}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{isDocumentTab && activeTab.documentId && activeTab.searchSpaceId ? (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DocumentTabContent
|
||||
key={activeTab.documentId}
|
||||
documentId={activeTab.documentId}
|
||||
searchSpaceId={activeTab.searchSpaceId}
|
||||
title={activeTab.title}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -521,7 +521,7 @@ export function DocumentsSidebar({
|
|||
|
||||
const documentsContent = (
|
||||
<>
|
||||
<div className="shrink-0 flex h-12 items-center px-4">
|
||||
<div className="shrink-0 flex h-14 items-center px-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
|||
CIRCLEBACK_CONNECTOR: "Circleback",
|
||||
MCP_CONNECTOR: "MCP",
|
||||
OBSIDIAN_CONNECTOR: "Obsidian",
|
||||
ONEDRIVE_CONNECTOR: "OneDrive",
|
||||
DROPBOX_CONNECTOR: "Dropbox",
|
||||
TAVILY_API: "Tavily",
|
||||
SEARXNG_API: "SearXNG",
|
||||
LINKUP_API: "Linkup",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export function Sidebar({
|
|||
>
|
||||
{/* Header - search space name or collapse button when collapsed */}
|
||||
{isCollapsed ? (
|
||||
<div className="flex h-12 shrink-0 items-center justify-center border-b">
|
||||
<div className="flex h-14 shrink-0 items-center justify-center border-b">
|
||||
<SidebarCollapseButton
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={onToggleCollapse ?? (() => {})}
|
||||
|
|
@ -113,7 +113,7 @@ export function Sidebar({
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-12 shrink-0 items-center gap-0 px-1 border-b">
|
||||
<div className="flex h-14 shrink-0 items-center gap-0 px-1 border-b">
|
||||
<SidebarHeader
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={isCollapsed}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ import { cn } from "@/lib/utils";
|
|||
interface TabBarProps {
|
||||
onTabSwitch?: (tab: Tab) => void;
|
||||
onNewChat?: () => void;
|
||||
rightActions?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
||||
export function TabBar({ onTabSwitch, onNewChat, rightActions, className }: TabBarProps) {
|
||||
const tabs = useAtomValue(tabsAtom);
|
||||
const activeTabId = useAtomValue(activeTabIdAtom);
|
||||
const switchTab = useSetAtom(switchTabAtom);
|
||||
|
|
@ -45,12 +46,25 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
|||
[closeTab, onTabSwitch]
|
||||
);
|
||||
|
||||
// Scroll active tab into view
|
||||
// Keep active tab visible with minimal scroll shift.
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current || !activeTabId) return;
|
||||
const activeEl = scrollRef.current.querySelector(`[data-tab-id="${activeTabId}"]`);
|
||||
if (activeEl) {
|
||||
activeEl.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
|
||||
const scroller = scrollRef.current;
|
||||
const activeEl = scroller.querySelector<HTMLElement>(`[data-tab-id="${activeTabId}"]`);
|
||||
if (!activeEl) return;
|
||||
|
||||
const viewLeft = scroller.scrollLeft;
|
||||
const viewRight = viewLeft + scroller.clientWidth;
|
||||
const tabLeft = activeEl.offsetLeft;
|
||||
const tabRight = tabLeft + activeEl.offsetWidth;
|
||||
|
||||
if (tabLeft < viewLeft) {
|
||||
scroller.scrollTo({ left: tabLeft, behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabRight > viewRight) {
|
||||
scroller.scrollTo({ left: tabRight - scroller.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
}, [activeTabId]);
|
||||
|
||||
|
|
@ -60,13 +74,13 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 items-stretch shrink-0 border-b border-border/35 bg-main-panel",
|
||||
"mb-2 flex h-9 items-center shrink-0 px-1 gap-0.5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex h-full items-stretch flex-1 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
className="flex h-full items-center flex-1 gap-0.5 overflow-x-auto overflow-y-hidden scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-1"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
|
|
@ -78,13 +92,13 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
|||
data-tab-id={tab.id}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className={cn(
|
||||
"group relative flex h-full w-[170px] items-center self-stretch px-3 min-w-0 overflow-hidden text-sm font-medium border-r border-border/35 transition-colors shrink-0",
|
||||
"group relative flex h-full w-[150px] items-center px-3 min-h-0 overflow-hidden text-[13px] font-medium rounded-lg transition-all duration-150 shrink-0",
|
||||
isActive
|
||||
? "bg-muted/50 text-foreground"
|
||||
: "bg-transparent text-muted-foreground hover:bg-muted/25 hover:text-foreground"
|
||||
? "bg-muted/60 text-foreground"
|
||||
: "bg-transparent text-muted-foreground hover:bg-muted/30 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="block min-w-0 flex-1 truncate text-left transition-[padding-right] duration-150 group-hover:pr-5 group-focus-within:pr-5">
|
||||
<span className="block min-w-0 flex-1 truncate text-left group-hover:pr-5 group-focus-within:pr-5">
|
||||
{tab.title}
|
||||
</span>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: cannot nest button inside button */}
|
||||
|
|
@ -99,7 +113,7 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-2 top-1/2 -translate-y-1/2 shrink-0 rounded-sm p-0.5 transition-colors",
|
||||
"absolute right-2 top-1/2 -translate-y-1/2 shrink-0 rounded-full p-0.5 transition-all duration-150 hover:bg-muted-foreground/15",
|
||||
isActive
|
||||
? "opacity-0 group-hover:opacity-70 group-focus-within:opacity-70 hover:opacity-100"
|
||||
: "opacity-0 group-hover:opacity-60 group-focus-within:opacity-60 hover:opacity-100!"
|
||||
|
|
@ -110,18 +124,19 @@ export function TabBar({ onTabSwitch, onNewChat, className }: TabBarProps) {
|
|||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{onNewChat && (
|
||||
<div className="flex h-full items-center px-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||
title="New Chat"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex h-6 w-6 items-center justify-center shrink-0 rounded-md text-muted-foreground transition-all duration-150 hover:text-foreground hover:bg-muted/40"
|
||||
title="New Chat"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{rightActions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
478
surfsense_web/components/tool-ui/dropbox/create-file.tsx
Normal file
478
surfsense_web/components/tool-ui/dropbox/create-file.tsx
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
"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 DropboxAccount {
|
||||
id: number;
|
||||
name: string;
|
||||
user_email?: string;
|
||||
auth_expired?: boolean;
|
||||
}
|
||||
|
||||
interface SupportedType {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
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?: DropboxAccount[];
|
||||
parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>;
|
||||
supported_types?: SupportedType[];
|
||||
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 CreateDropboxFileResult = 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; file_type?: 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 supportedTypes = interruptData.context?.supported_types ?? [
|
||||
{ value: "paper", label: "Dropbox Paper (.paper)" },
|
||||
{ value: "docx", label: "Word Document (.docx)" },
|
||||
];
|
||||
|
||||
const defaultAccountId = useMemo(() => {
|
||||
if (validAccounts.length === 1) return String(validAccounts[0].id);
|
||||
return "";
|
||||
}, [validAccounts]);
|
||||
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId);
|
||||
const [parentFolderPath, setParentFolderPath] = useState<string>("__root__");
|
||||
const [selectedFileType, setSelectedFileType] = useState<string>(args.file_type ?? "paper");
|
||||
|
||||
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);
|
||||
setParentFolderPath("__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 fileTypeLabel = supportedTypes.find((t) => t.value === selectedFileType)?.label ?? selectedFileType;
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (phase !== "pending" || isPanelOpen || !canApprove) return;
|
||||
if (!allowedDecisions.includes("approve")) return;
|
||||
const isEdited = pendingEdits !== null || selectedFileType !== (args.file_type ?? "paper");
|
||||
setProcessing();
|
||||
onDecision({
|
||||
type: isEdited ? "edit" : "approve",
|
||||
edited_action: {
|
||||
name: interruptData.action_requests[0].name,
|
||||
args: {
|
||||
...args,
|
||||
...(pendingEdits && { name: pendingEdits.name, content: pendingEdits.content }),
|
||||
file_type: selectedFileType,
|
||||
connector_id: selectedAccountId ? Number(selectedAccountId) : null,
|
||||
parent_folder_path: parentFolderPath === "__root__" ? null : parentFolderPath,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [
|
||||
phase,
|
||||
setProcessing,
|
||||
isPanelOpen,
|
||||
canApprove,
|
||||
allowedDecisions,
|
||||
onDecision,
|
||||
interruptData,
|
||||
args,
|
||||
selectedAccountId,
|
||||
parentFolderPath,
|
||||
pendingEdits,
|
||||
selectedFileType,
|
||||
]);
|
||||
|
||||
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"
|
||||
? "Dropbox File Rejected"
|
||||
: phase === "processing" || phase === "complete"
|
||||
? "Dropbox File Approved"
|
||||
: "Create Dropbox File"}
|
||||
</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: fileTypeLabel,
|
||||
onSave: (newName, newContent) => {
|
||||
setIsPanelOpen(false);
|
||||
setPendingEdits({ name: newName, content: newContent });
|
||||
},
|
||||
onClose: () => setIsPanelOpen(false),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Pen className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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">
|
||||
Dropbox 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={selectedFileType} onValueChange={setSelectedFileType}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedTypes.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedAccountId && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Parent Folder</p>
|
||||
<Select value={parentFolderPath} onValueChange={setParentFolderPath}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Dropbox Root" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__root__">Dropbox Root</SelectItem>
|
||||
{availableParentFolders.map((folder) => (
|
||||
<SelectItem key={folder.folder_path} value={folder.folder_path}>
|
||||
{folder.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{availableParentFolders.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No folders found. File will be created at Dropbox 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 Dropbox 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">Dropbox 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 || "Dropbox 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 Dropbox
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CreateDropboxFileToolUI = ({
|
||||
args,
|
||||
result,
|
||||
}: ToolCallMessagePartProps<{ name: string; file_type?: string; content?: string }, CreateDropboxFileResult>) => {
|
||||
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} />;
|
||||
};
|
||||
2
surfsense_web/components/tool-ui/dropbox/index.ts
Normal file
2
surfsense_web/components/tool-ui/dropbox/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { CreateDropboxFileToolUI } from "./create-file";
|
||||
export { DeleteDropboxFileToolUI } from "./trash-file";
|
||||
331
surfsense_web/components/tool-ui/dropbox/trash-file.tsx
Normal file
331
surfsense_web/components/tool-ui/dropbox/trash-file.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
"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 DropboxAccount {
|
||||
id: number;
|
||||
name: string;
|
||||
user_email?: string;
|
||||
auth_expired?: boolean;
|
||||
}
|
||||
|
||||
interface DropboxFile {
|
||||
file_id: string;
|
||||
file_path: string;
|
||||
name: string;
|
||||
document_id?: number;
|
||||
}
|
||||
|
||||
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?: DropboxAccount; file?: DropboxFile; 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 DeleteDropboxFileResult =
|
||||
| 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_path: file?.file_path, connector_id: account?.id, delete_from_kb: deleteFromKb },
|
||||
},
|
||||
});
|
||||
}, [phase, setProcessing, onDecision, interruptData, file?.file_path, 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"
|
||||
? "Dropbox File Deletion Rejected"
|
||||
: phase === "processing" || phase === "complete"
|
||||
? "Dropbox File Deletion Approved"
|
||||
: "Delete Dropbox File"}
|
||||
</p>
|
||||
{phase === "processing" ? (
|
||||
<TextShimmerLoader text="Deleting file" size="sm" />
|
||||
) : phase === "complete" ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">File deleted</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">Dropbox 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.file_path && (
|
||||
<div className="text-xs text-muted-foreground">{file.file_path}</div>
|
||||
)}
|
||||
</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 permanently deleted from Dropbox.
|
||||
</p>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id="db-delete-from-kb"
|
||||
checked={deleteFromKb}
|
||||
onCheckedChange={(v) => setDeleteFromKb(v === true)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<label htmlFor="db-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">Dropbox 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 deleted from Dropbox"}
|
||||
</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 DeleteDropboxFileToolUI = ({
|
||||
result,
|
||||
}: ToolCallMessagePartProps<
|
||||
{ file_name: string; delete_from_kb?: boolean },
|
||||
DeleteDropboxFileResult
|
||||
>) => {
|
||||
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} />;
|
||||
};
|
||||
|
|
@ -32,6 +32,7 @@ export {
|
|||
UpdateLinearIssueToolUI,
|
||||
} from "./linear";
|
||||
export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion";
|
||||
export { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "./dropbox";
|
||||
export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive";
|
||||
export {
|
||||
Plan,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue