From b22ac0f266ec3fa3b839cd6035be5eac5d3a2eb4 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 21 May 2026 21:09:19 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20drag=20folders=20into=20cha?= =?UTF-8?q?t=20input=20as=20@localFile=20mentions=20on=20desktop=20(#15071?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent's runtime mode is `local` (or it's a heterogeneous agent), dragging a folder into the conversation now inserts a `` mention at the editor cursor instead of recursively uploading its contents. Mixed drops route folders to mentions and files to the existing upload pipeline in drop order. The drag overlay detects content kind on `dragenter` via `webkitGetAsEntry` and swaps the title/desc/icon between "Upload Files", "Reference Folder", and the mixed variant. Also aligns the @ mention search and server-side local file materialization gates with the same condition (`isLocalSystemEnabled || isHeterogeneous`) since `lobe-local-system` plugin presence is already overridden in toolEngineering — runtime mode is the only real gate. --- locales/en-US/components.json | 4 + locales/zh-CN/components.json | 4 + .../DragUploadZone/DragUploadProvider.tsx | 19 +- src/components/DragUploadZone/index.tsx | 70 +++++-- .../DragUploadZone/useLocalDragUpload.test.ts | 177 ++++++++++++++++++ .../DragUploadZone/useLocalDragUpload.ts | 159 +++++++++++++++- .../InputEditor/insertLocalFolderMentions.ts | 48 +++++ .../useLocalFileMention.desktop.ts | 10 +- src/locales/default/components.ts | 4 + .../agent/features/Conversation/index.tsx | 27 ++- .../aiChat/actions/conversationLifecycle.ts | 10 +- 11 files changed, 501 insertions(+), 31 deletions(-) create mode 100644 src/components/DragUploadZone/useLocalDragUpload.test.ts create mode 100644 src/features/ChatInput/InputEditor/insertLocalFolderMentions.ts diff --git a/locales/en-US/components.json b/locales/en-US/components.json index 9fb5df54c2..ea806541b4 100644 --- a/locales/en-US/components.json +++ b/locales/en-US/components.json @@ -5,6 +5,10 @@ "DragUpload.dragDesc": "Drag and drop files here to upload multiple images.", "DragUpload.dragFileDesc": "Drag and drop images and files here to upload multiple images and files.", "DragUpload.dragFileTitle": "Upload Files", + "DragUpload.dragFolderDesc": "Drop the folder to reference it as @mention in the chat input.", + "DragUpload.dragFolderTitle": "Reference Folder", + "DragUpload.dragMixedDesc": "Folders are inserted as @mentions; files are uploaded.", + "DragUpload.dragMixedTitle": "Reference Folder & Upload Files", "DragUpload.dragTitle": "Upload Images", "FileManager.actions.addToLibrary": "Add to Library", "FileManager.actions.batchChunking": "Batch Chunking", diff --git a/locales/zh-CN/components.json b/locales/zh-CN/components.json index 9470e31cae..193bd0a873 100644 --- a/locales/zh-CN/components.json +++ b/locales/zh-CN/components.json @@ -5,6 +5,10 @@ "DragUpload.dragDesc": "拖拽文件到这里,支持上传多个图片。", "DragUpload.dragFileDesc": "拖拽图片和文件到这里,支持上传多个图片和文件。", "DragUpload.dragFileTitle": "上传文件", + "DragUpload.dragFolderDesc": "释放文件夹,将以 @mention 方式插入到输入框中。", + "DragUpload.dragFolderTitle": "引用文件夹", + "DragUpload.dragMixedDesc": "文件夹将作为 @mention 插入,文件将上传。", + "DragUpload.dragMixedTitle": "引用文件夹 & 上传文件", "DragUpload.dragTitle": "上传图片", "FileManager.actions.addToLibrary": "添加到资料库", "FileManager.actions.batchChunking": "批量分块", diff --git a/src/components/DragUploadZone/DragUploadProvider.tsx b/src/components/DragUploadZone/DragUploadProvider.tsx index de04a8b3bd..4b00b38a07 100644 --- a/src/components/DragUploadZone/DragUploadProvider.tsx +++ b/src/components/DragUploadZone/DragUploadProvider.tsx @@ -3,7 +3,15 @@ import { type ReactNode } from 'react'; import { createContext, memo, use, useCallback, useEffect, useRef, useState } from 'react'; +import { detectDragContentKind, type DragContentKind } from './useLocalDragUpload'; + interface DragUploadContextValue { + /** + * Best-effort classification of the currently dragged content. Updated on + * dragenter via DataTransferItem inspection. May be 'none' when nothing is + * being dragged, or when item kinds cannot be read for security reasons. + */ + dragContentKind: DragContentKind; /** * Whether files are being dragged anywhere on the page */ @@ -11,6 +19,7 @@ interface DragUploadContextValue { } const DragUploadContext = createContext({ + dragContentKind: 'none', isDraggingGlobally: false, }); @@ -30,6 +39,7 @@ interface DragUploadProviderProps { */ export const DragUploadProvider = memo(({ children }) => { const [isDraggingGlobally, setIsDraggingGlobally] = useState(false); + const [dragContentKind, setDragContentKind] = useState('none'); const dragCounter = useRef(0); const handleDragEnter = useCallback((e: DragEvent) => { @@ -40,6 +50,7 @@ export const DragUploadProvider = memo(({ children }) = if (dragCounter.current === 1) { setIsDraggingGlobally(true); + setDragContentKind(detectDragContentKind(e.dataTransfer.items)); } }, []); @@ -56,6 +67,7 @@ export const DragUploadProvider = memo(({ children }) = if (dragCounter.current === 0) { setIsDraggingGlobally(false); + setDragContentKind('none'); } }, []); @@ -64,6 +76,7 @@ export const DragUploadProvider = memo(({ children }) = e.preventDefault(); dragCounter.current = 0; setIsDraggingGlobally(false); + setDragContentKind('none'); }, []); useEffect(() => { @@ -80,7 +93,11 @@ export const DragUploadProvider = memo(({ children }) = }; }, [handleDragEnter, handleDragOver, handleDragLeave, handleDrop]); - return {children}; + return ( + + {children} + + ); }); DragUploadProvider.displayName = 'DragUploadProvider'; diff --git a/src/components/DragUploadZone/index.tsx b/src/components/DragUploadZone/index.tsx index c39e42ce08..f330af8c80 100644 --- a/src/components/DragUploadZone/index.tsx +++ b/src/components/DragUploadZone/index.tsx @@ -2,13 +2,13 @@ import { Center, Flexbox, Icon } from '@lobehub/ui'; import { createStaticStyles, cssVar, cx } from 'antd-style'; -import { FileImage, FileText, FileUpIcon } from 'lucide-react'; +import { FileImage, FileText, FileUpIcon, FolderIcon } from 'lucide-react'; import { type CSSProperties, type ReactNode } from 'react'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useDragUploadContext } from './DragUploadProvider'; -import { useLocalDragUpload } from './useLocalDragUpload'; +import { type DroppedFolder, useLocalDragUpload } from './useLocalDragUpload'; const BLOCK_SIZE = 48; const ICON_SIZE = { size: 28, strokeWidth: 1.5 }; @@ -82,6 +82,16 @@ export interface DragUploadZoneProps { * @default true */ enabledFiles?: boolean; + /** + * Whether dropping a folder should route to onLocalFolders (as @mention) instead + * of being flattened and uploaded. Requires Electron (uses webUtils to resolve + * folder paths). Files in a mixed drop continue to flow through onUploadFiles. + */ + enableLocalFolderMention?: boolean; + /** + * Callback when top-level folders are dropped and enableLocalFolderMention is on. + */ + onLocalFolders?: (folders: DroppedFolder[]) => void | Promise; /** * Callback when files are dropped */ @@ -102,6 +112,8 @@ const DragUploadZone = memo( className, disabled = false, enabledFiles = true, + enableLocalFolderMention = false, + onLocalFolders, overlayMinHeight = 160, onUploadFiles, style, @@ -109,17 +121,43 @@ const DragUploadZone = memo( const { t } = useTranslation('components'); // Global drag state - shows overlay when dragging anywhere on page - const { isDraggingGlobally } = useDragUploadContext(); + const { isDraggingGlobally, dragContentKind } = useDragUploadContext(); // Local drop handler - only handles drop events const { getContainerProps } = useLocalDragUpload({ disabled, + enableLocalFolderMention, + onLocalFolders, onUploadFiles, }); // Show overlay when files are being dragged anywhere on the page const showOverlay = isDraggingGlobally && !disabled; + // When local folder mention is on AND dragged content includes a folder, + // surface a folder-aware hint instead of the default upload hint. + const overlayCopy = useMemo(() => { + if (enableLocalFolderMention && dragContentKind === 'folders') { + return { + desc: t('DragUpload.dragFolderDesc'), + showFolderIcon: true, + title: t('DragUpload.dragFolderTitle'), + }; + } + if (enableLocalFolderMention && dragContentKind === 'mixed') { + return { + desc: t('DragUpload.dragMixedDesc'), + showFolderIcon: true, + title: t('DragUpload.dragMixedTitle'), + }; + } + return { + desc: t(enabledFiles ? 'DragUpload.dragFileDesc' : 'DragUpload.dragDesc'), + showFolderIcon: false, + title: t(enabledFiles ? 'DragUpload.dragFileTitle' : 'DragUpload.dragTitle'), + }; + }, [dragContentKind, enableLocalFolderMention, enabledFiles, t]); + return (
{children} @@ -137,7 +175,10 @@ const DragUploadZone = memo( transform: 'rotateZ(-20deg) translateX(8px)', }} > - +
( zIndex: 1, }} > - +
( transform: 'rotateZ(20deg) translateX(-8px)', }} > - +
- - {t(enabledFiles ? 'DragUpload.dragFileTitle' : 'DragUpload.dragTitle')} - - - {t(enabledFiles ? 'DragUpload.dragFileDesc' : 'DragUpload.dragDesc')} - + {overlayCopy.title} + {overlayCopy.desc}
@@ -181,6 +224,7 @@ const DragUploadZone = memo( DragUploadZone.displayName = 'DragUploadZone'; +export type { DroppedFolder } from './useLocalDragUpload'; export { usePasteFile } from './usePasteFile'; export { useUploadFiles } from './useUploadFiles'; export default DragUploadZone; diff --git a/src/components/DragUploadZone/useLocalDragUpload.test.ts b/src/components/DragUploadZone/useLocalDragUpload.test.ts new file mode 100644 index 0000000000..2ca77d34d5 --- /dev/null +++ b/src/components/DragUploadZone/useLocalDragUpload.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + detectDragContentKind, + type DroppedFolder, + partitionDroppedItems, +} from './useLocalDragUpload'; + +type EntryShape = { isDirectory: boolean; isFile: boolean; name?: string }; + +const makeItem = ({ + file, + entry, + kind = 'file', +}: { + entry?: EntryShape | null; + file?: File | null; + kind?: 'file' | 'string'; +}): DataTransferItem => + ({ + getAsFile: () => file ?? null, + kind, + webkitGetAsEntry: () => (entry as unknown as FileSystemEntry) ?? null, + }) as unknown as DataTransferItem; + +const makeFile = (name: string) => new File([new Blob(['x'])], name); + +describe('detectDragContentKind', () => { + it('returns "none" for empty or null input', () => { + expect(detectDragContentKind(null)).toBe('none'); + expect(detectDragContentKind([] as unknown as DataTransferItemList)).toBe('none'); + }); + + it('detects "files" when only files are present', () => { + const items = [ + makeItem({ entry: { isDirectory: false, isFile: true } }), + makeItem({ entry: { isDirectory: false, isFile: true } }), + ]; + expect(detectDragContentKind(items as unknown as DataTransferItemList)).toBe('files'); + }); + + it('detects "folders" when only directories are present', () => { + const items = [makeItem({ entry: { isDirectory: true, isFile: false } })]; + expect(detectDragContentKind(items as unknown as DataTransferItemList)).toBe('folders'); + }); + + it('detects "mixed" when both folders and files are present', () => { + const items = [ + makeItem({ entry: { isDirectory: true, isFile: false } }), + makeItem({ entry: { isDirectory: false, isFile: true } }), + ]; + expect(detectDragContentKind(items as unknown as DataTransferItemList)).toBe('mixed'); + }); + + it('falls back to "files" when entry metadata is unavailable', () => { + const items = [makeItem({ entry: null })]; + expect(detectDragContentKind(items as unknown as DataTransferItemList)).toBe('files'); + }); + + it('ignores items whose kind is not "file"', () => { + const items = [makeItem({ kind: 'string', entry: { isDirectory: true, isFile: false } })]; + expect(detectDragContentKind(items as unknown as DataTransferItemList)).toBe('none'); + }); +}); + +describe('partitionDroppedItems', () => { + const originalElectron = (globalThis as any).window?.electron; + + beforeEach(() => { + (globalThis as any).window = (globalThis as any).window ?? {}; + (globalThis as any).window.electron = { + webUtils: { + getPathForFile: (file: File) => `/abs/${file.name}`, + }, + }; + }); + + afterEach(() => { + if (originalElectron === undefined) { + delete (globalThis as any).window.electron; + } else { + (globalThis as any).window.electron = originalElectron; + } + }); + + it('routes top-level folders to the folders bucket with absolute paths', async () => { + const folderFile = makeFile('my-folder'); + const items = [ + makeItem({ + entry: { isDirectory: true, isFile: false, name: 'my-folder' }, + file: folderFile, + }), + ]; + + const result = await partitionDroppedItems(items); + + expect(result.files).toEqual([]); + expect(result.folders).toEqual([ + { name: 'my-folder', path: '/abs/my-folder' }, + ]); + }); + + it('routes top-level files to the files bucket', async () => { + const fileA = makeFile('a.txt'); + const fileB = makeFile('b.txt'); + const items = [ + makeItem({ entry: { isDirectory: false, isFile: true }, file: fileA }), + makeItem({ entry: { isDirectory: false, isFile: true }, file: fileB }), + ]; + + const result = await partitionDroppedItems(items); + + expect(result.folders).toEqual([]); + expect(result.files).toHaveLength(2); + expect(result.files[0].name).toBe('a.txt'); + expect(result.files[1].name).toBe('b.txt'); + }); + + it('preserves drop order across mixed folders and files', async () => { + const folderFile = makeFile('docs'); + const file = makeFile('readme.md'); + const items = [ + makeItem({ + entry: { isDirectory: true, isFile: false, name: 'docs' }, + file: folderFile, + }), + makeItem({ entry: { isDirectory: false, isFile: true }, file }), + ]; + + const result = await partitionDroppedItems(items); + + expect(result.folders).toEqual([{ name: 'docs', path: '/abs/docs' }]); + expect(result.files.map((f) => f.name)).toEqual(['readme.md']); + }); + + it('falls back to flattening a folder when Electron path resolution fails', async () => { + (globalThis as any).window.electron = undefined; + + const innerFile = makeFile('child.txt'); + const folderEntry: FileSystemDirectoryEntry = { + createReader: () => + ({ + readEntries: (cb: (entries: FileSystemEntry[]) => void) => + cb([ + { + file: (fileCb: (file: File) => void) => fileCb(innerFile), + isDirectory: false, + isFile: true, + } as unknown as FileSystemFileEntry, + ]), + }) as unknown as FileSystemDirectoryReader, + isDirectory: true, + isFile: false, + } as unknown as FileSystemDirectoryEntry; + + const items = [ + makeItem({ + entry: folderEntry as unknown as EntryShape, + file: makeFile('unused'), + }), + ]; + + const result = await partitionDroppedItems(items); + + expect(result.folders).toEqual([]); + expect(result.files.map((f) => f.name)).toEqual(['child.txt']); + }); + + it('skips items whose kind is not "file"', async () => { + const items = [makeItem({ kind: 'string', entry: { isDirectory: true, isFile: false } })]; + + const result = await partitionDroppedItems(items); + + expect(result.folders).toEqual([]); + expect(result.files).toEqual([]); + }); +}); diff --git a/src/components/DragUploadZone/useLocalDragUpload.ts b/src/components/DragUploadZone/useLocalDragUpload.ts index 946459551d..0ec9df4b39 100644 --- a/src/components/DragUploadZone/useLocalDragUpload.ts +++ b/src/components/DragUploadZone/useLocalDragUpload.ts @@ -1,6 +1,52 @@ - +import debug from 'debug'; import { useCallback } from 'react'; +const log = debug('lobe-client:drag-upload:local'); + +export type DragContentKind = 'files' | 'folders' | 'mixed' | 'none'; + +export interface DroppedFolder { + name: string; + path: string; +} + +export interface PartitionedDroppedItems { + files: File[]; + folders: DroppedFolder[]; +} + +/** + * Resolve the absolute filesystem path of a dropped File in Electron. + * Returns null when not running under Electron or the path cannot be resolved. + */ +const resolveElectronFilePath = (file: File): string | null => { + const webUtils = ( + globalThis as unknown as { + window?: { electron?: { webUtils?: { getPathForFile?: (file: File) => string } } }; + } + ).window?.electron?.webUtils; + if (!webUtils?.getPathForFile) { + log('webUtils.getPathForFile unavailable on window.electron — folder path cannot be resolved'); + return null; + } + try { + const result = webUtils.getPathForFile(file); + if (!result) log('webUtils.getPathForFile returned empty for %s', file.name); + return result || null; + } catch (error) { + log('webUtils.getPathForFile threw for %s: %O', file.name, error); + return null; + } +}; + +const safeGetEntry = (item: DataTransferItem): FileSystemEntry | null => { + try { + return item.webkitGetAsEntry(); + } catch { + return null; + } +}; + /** * Process a FileSystemEntry recursively to extract all files */ @@ -52,11 +98,100 @@ export const getFileListFromDataTransferItems = async ( return fileArrays.flat(); }; +/** + * Inspect DataTransferItems synchronously (callable in dragenter / dragover) + * to classify dragged content into 'files', 'folders', 'mixed', or 'none'. + * + * Browsers expose item.webkitGetAsEntry() during drag events with metadata + * (isFile / isDirectory) accessible, even though content reads are gated to drop. + */ +export const detectDragContentKind = (items: DataTransferItemList | null): DragContentKind => { + if (!items || items.length === 0) return 'none'; + + let hasFolder = false; + let hasFile = false; + + for (const item of Array.from(items)) { + if (item.kind !== 'file') continue; + const entry = safeGetEntry(item); + if (entry?.isDirectory) { + hasFolder = true; + } else { + hasFile = true; + } + if (hasFolder && hasFile) break; + } + + if (hasFolder && hasFile) return 'mixed'; + if (hasFolder) return 'folders'; + if (hasFile) return 'files'; + return 'none'; +}; + +/** + * Partition dropped DataTransferItems into top-level folders (with absolute + * filesystem paths via Electron's webUtils) and top-level files. Folders are + * NOT recursed into — the caller is expected to treat them as mention targets. + * + * When a folder is encountered without an Electron path (e.g. running in + * browser), it is skipped from the folders list — callers may still fall back + * to upload by inspecting unhandled items. + */ +export const partitionDroppedItems = async ( + items: DataTransferItem[], +): Promise => { + const folders: DroppedFolder[] = []; + const files: File[] = []; + + for (const item of items) { + if (item.kind !== 'file') continue; + + const entry = safeGetEntry(item); + + if (entry?.isDirectory) { + const directoryFile = item.getAsFile(); + const path = directoryFile ? resolveElectronFilePath(directoryFile) : null; + if (path) { + folders.push({ + name: directoryFile?.name || entry.name || path.split('/').pop() || path, + path, + }); + continue; + } + // Fallback (no Electron / no path): flatten the directory's files for + // upload so the user isn't silently dropped. + const flattened = await processEntry(entry); + files.push(...flattened); + continue; + } + + const file = item.getAsFile(); + if (file) { + files.push(file); + } else if (entry) { + const flattened = await processEntry(entry); + files.push(...flattened); + } + } + + return { files, folders }; +}; + export interface UseLocalDragUploadOptions { /** * Whether the drag upload is disabled */ disabled?: boolean; + /** + * When true, top-level folders are routed to onLocalFolders instead of being + * recursively flattened for upload. Top-level files still flow to onUploadFiles. + * Requires Electron (uses webUtils.getPathForFile) to resolve folder paths. + */ + enableLocalFolderMention?: boolean; + /** + * Callback for top-level dropped folders when enableLocalFolderMention is on. + */ + onLocalFolders?: (folders: DroppedFolder[]) => void | Promise; /** * Callback when files are dropped */ @@ -85,7 +220,7 @@ export interface UseLocalDragUploadResult { export const useLocalDragUpload = ( options: UseLocalDragUploadOptions, ): UseLocalDragUploadResult => { - const { onUploadFiles, disabled = false } = options; + const { onUploadFiles, disabled = false, enableLocalFolderMention, onLocalFolders } = options; // Only preventDefault to allow drop, do NOT stopPropagation const handleDragOver = useCallback( @@ -111,13 +246,25 @@ export const useLocalDragUpload = ( // Do NOT call stopPropagation - let event bubble to Provider const items = Array.from(e.dataTransfer.items); + + if (enableLocalFolderMention && onLocalFolders) { + const { folders, files } = await partitionDroppedItems(items); + log('drop partitioned: %d folder(s), %d file(s)', folders.length, files.length); + if (folders.length > 0) { + await onLocalFolders(folders); + } + if (files.length > 0) { + await onUploadFiles(files); + } + return; + } + + log('drop without folder-mention path, uploading files only'); const files = await getFileListFromDataTransferItems(items); - if (files.length === 0) return; - - onUploadFiles(files); + await onUploadFiles(files); }, - [disabled, onUploadFiles], + [disabled, enableLocalFolderMention, onLocalFolders, onUploadFiles], ); const getContainerProps = useCallback( diff --git a/src/features/ChatInput/InputEditor/insertLocalFolderMentions.ts b/src/features/ChatInput/InputEditor/insertLocalFolderMentions.ts new file mode 100644 index 0000000000..929014e27f --- /dev/null +++ b/src/features/ChatInput/InputEditor/insertLocalFolderMentions.ts @@ -0,0 +1,48 @@ +import type { IEditor } from '@lobehub/editor'; +import { INSERT_MENTION_COMMAND } from '@lobehub/editor'; +import { $getSelection, $isRangeSelection } from 'lexical'; + +import type { DroppedFolder } from '@/components/DragUploadZone'; + +/** + * Insert one localFile mention node per dropped folder at the editor's current + * selection, separating consecutive mentions with a space so they read as + * distinct tokens. + * + * Mirrors the metadata shape used by the `@`-menu local-file mention path so + * the markdownWriter in InputEditor renders ``. + */ +export const insertLocalFolderMentions = (editor: IEditor, folders: DroppedFolder[]) => { + if (folders.length === 0) return; + + const lexicalEditor = editor.getLexicalEditor(); + lexicalEditor?.focus(); + + folders.forEach((folder, index) => { + if (index > 0) { + lexicalEditor?.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertText(' '); + } + }); + } + editor.dispatchCommand(INSERT_MENTION_COMMAND, { + label: folder.name, + metadata: { + isDirectory: true, + name: folder.name, + path: folder.path, + type: 'localFile', + }, + }); + }); + + // Trailing space so the user can keep typing without manually adding one. + lexicalEditor?.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertText(' '); + } + }); +}; diff --git a/src/features/ChatInput/InputEditor/useLocalFileMention.desktop.ts b/src/features/ChatInput/InputEditor/useLocalFileMention.desktop.ts index 99fad7e5fc..ef5045c39f 100644 --- a/src/features/ChatInput/InputEditor/useLocalFileMention.desktop.ts +++ b/src/features/ChatInput/InputEditor/useLocalFileMention.desktop.ts @@ -3,7 +3,7 @@ import debug from 'debug'; import { createElement, useCallback, useEffect } from 'react'; import { useAgentStore } from '@/store/agent'; -import { agentByIdSelectors } from '@/store/agent/selectors'; +import { agentByIdSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { topicSelectors } from '@/store/chat/selectors'; @@ -14,7 +14,6 @@ import { } from './localFileMentionIndex'; import LocalFileIcon from './MentionMenu/LocalFileIcon'; -const LOCAL_SYSTEM_IDENTIFIER = 'lobe-local-system'; const MAX_LOCAL_FILE_MENTION_ITEMS = 20; const log = debug('chat-input:local-file-mention'); @@ -25,18 +24,19 @@ export interface UseLocalFileMentionResult { export const useLocalFileMention = (): UseLocalFileMentionResult => { const agentId = useAgentId(); - const agentPlugins = useAgentStore((s) => agentByIdSelectors.getAgentPluginsById(agentId)(s)); const heterogeneousType = useAgentStore( (s) => agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider?.type, ); + const isLocalSystemEnabled = useAgentStore( + chatConfigByIdSelectors.isLocalSystemEnabledById(agentId), + ); const agentWorkingDirectory = useAgentStore((s) => agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s), ); const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); const workingDirectory = topicWorkingDirectory || agentWorkingDirectory; - const enableLocalFileMention = - !!heterogeneousType || agentPlugins.includes(LOCAL_SYSTEM_IDENTIFIER); + const enableLocalFileMention = !!heterogeneousType || isLocalSystemEnabled; useEffect(() => { if (!enableLocalFileMention) return; diff --git a/src/locales/default/components.ts b/src/locales/default/components.ts index 40666d210e..81a97ab8d6 100644 --- a/src/locales/default/components.ts +++ b/src/locales/default/components.ts @@ -6,6 +6,10 @@ export default { 'DragUpload.dragFileDesc': 'Drag and drop images and files here to upload multiple images and files.', 'DragUpload.dragFileTitle': 'Upload Files', + 'DragUpload.dragFolderDesc': 'Drop the folder to reference it as @mention in the chat input.', + 'DragUpload.dragFolderTitle': 'Reference Folder', + 'DragUpload.dragMixedDesc': 'Folders are inserted as @mentions; files are uploaded.', + 'DragUpload.dragMixedTitle': 'Reference Folder & Upload Files', 'DragUpload.dragTitle': 'Upload Images', 'FileManager.actions.addToLibrary': 'Add to Library', 'FileManager.actions.batchChunking': 'Batch Chunking', diff --git a/src/routes/(main)/agent/features/Conversation/index.tsx b/src/routes/(main)/agent/features/Conversation/index.tsx index 20c3bd1830..6a196c7e7f 100644 --- a/src/routes/(main)/agent/features/Conversation/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/index.tsx @@ -1,10 +1,13 @@ +import { isDesktop } from '@lobechat/const'; import { Flexbox, TooltipGroup } from '@lobehub/ui'; -import React, { memo, Suspense } from 'react'; +import React, { memo, Suspense, useCallback } from 'react'; -import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone'; +import DragUploadZone, { type DroppedFolder, useUploadFiles } from '@/components/DragUploadZone'; import Loading from '@/components/Loading/BrandTextLoading'; +import { insertLocalFolderMentions } from '@/features/ChatInput/InputEditor/insertLocalFolderMentions'; import { useAgentStore } from '@/store/agent'; -import { agentSelectors } from '@/store/agent/selectors'; +import { agentChatConfigSelectors, agentSelectors } from '@/store/agent/selectors'; +import { useChatStore } from '@/store/chat'; import ConversationArea from './ConversationArea'; @@ -18,11 +21,27 @@ const wrapperStyle: React.CSSProperties = { const ChatConversation = memo(() => { const model = useAgentStore(agentSelectors.currentAgentModel); const provider = useAgentStore(agentSelectors.currentAgentModelProvider); + const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous); + const isLocalSystemEnabled = useAgentStore(agentChatConfigSelectors.isLocalSystemEnabled); + const { handleUploadFiles } = useUploadFiles({ model, provider }); + const enableLocalFolderMention = isDesktop && (isHeterogeneous || isLocalSystemEnabled); + + const handleLocalFolders = useCallback((folders: DroppedFolder[]) => { + const editor = useChatStore.getState().mainInputEditor?.instance; + if (!editor) return; + insertLocalFolderMentions(editor, folders); + }, []); + return ( }> - + diff --git a/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts b/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts index 151d7ffbaf..d5bf2a153b 100644 --- a/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +++ b/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts @@ -33,7 +33,11 @@ import { resolveSelectedSkillsWithContent } from '@/services/chat/mecha/skillPre import { resolveSelectedToolsWithContent } from '@/services/chat/mecha/toolPreload'; import { messageService } from '@/services/message'; import { getAgentStoreState, useAgentStore } from '@/store/agent'; -import { agentByIdSelectors, agentSelectors } from '@/store/agent/selectors'; +import { + agentByIdSelectors, + agentSelectors, + chatConfigByIdSelectors, +} from '@/store/agent/selectors'; import { agentGroupByIdSelectors, getChatGroupStoreState } from '@/store/agentGroup'; import { selectRuntimeType } from '@/store/chat/slices/aiChat/actions/agentDispatcher'; import { resolveHeteroResume } from '@/store/chat/slices/aiChat/actions/heteroResume'; @@ -294,11 +298,13 @@ export class ConversationLifecycleActionImpl { }; const fileIdList = files?.map((f) => f.id); + const isLocalSystemEnabled = + chatConfigByIdSelectors.isLocalSystemEnabledById(agentId)(getAgentStoreState()); const canMaterializeLocalFiles = isDesktop && localFileReferences.length > 0 && !metadata?.localSystemToolSnapshots?.length && - (!!heterogeneousProvider || !!agentConfig?.plugins?.includes('lobe-local-system')); + (!!heterogeneousProvider || isLocalSystemEnabled); const localSystemToolSnapshots = canMaterializeLocalFiles ? await materializeLocalSystemToolSnapshots(localFileReferences) : [];