feat: drag folders into chat input as @localFile mentions on desktop (#15071)

When the agent's runtime mode is `local` (or it's a heterogeneous agent),
dragging a folder into the conversation now inserts a `<localFile path="..."
isDirectory />` 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.
This commit is contained in:
Innei
2026-05-21 21:09:19 +08:00
committed by GitHub
parent b358b0b2d1
commit b22ac0f266
11 changed files with 501 additions and 31 deletions
+4
View File
@@ -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",
+4
View File
@@ -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": "批量分块",
@@ -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<DragUploadContextValue>({
dragContentKind: 'none',
isDraggingGlobally: false,
});
@@ -30,6 +39,7 @@ interface DragUploadProviderProps {
*/
export const DragUploadProvider = memo<DragUploadProviderProps>(({ children }) => {
const [isDraggingGlobally, setIsDraggingGlobally] = useState(false);
const [dragContentKind, setDragContentKind] = useState<DragContentKind>('none');
const dragCounter = useRef(0);
const handleDragEnter = useCallback((e: DragEvent) => {
@@ -40,6 +50,7 @@ export const DragUploadProvider = memo<DragUploadProviderProps>(({ children }) =
if (dragCounter.current === 1) {
setIsDraggingGlobally(true);
setDragContentKind(detectDragContentKind(e.dataTransfer.items));
}
}, []);
@@ -56,6 +67,7 @@ export const DragUploadProvider = memo<DragUploadProviderProps>(({ children }) =
if (dragCounter.current === 0) {
setIsDraggingGlobally(false);
setDragContentKind('none');
}
}, []);
@@ -64,6 +76,7 @@ export const DragUploadProvider = memo<DragUploadProviderProps>(({ children }) =
e.preventDefault();
dragCounter.current = 0;
setIsDraggingGlobally(false);
setDragContentKind('none');
}, []);
useEffect(() => {
@@ -80,7 +93,11 @@ export const DragUploadProvider = memo<DragUploadProviderProps>(({ children }) =
};
}, [handleDragEnter, handleDragOver, handleDragLeave, handleDrop]);
return <DragUploadContext value={{ isDraggingGlobally }}>{children}</DragUploadContext>;
return (
<DragUploadContext value={{ dragContentKind, isDraggingGlobally }}>
{children}
</DragUploadContext>
);
});
DragUploadProvider.displayName = 'DragUploadProvider';
+57 -13
View File
@@ -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<void>;
/**
* Callback when files are dropped
*/
@@ -102,6 +112,8 @@ const DragUploadZone = memo<DragUploadZoneProps>(
className,
disabled = false,
enabledFiles = true,
enableLocalFolderMention = false,
onLocalFolders,
overlayMinHeight = 160,
onUploadFiles,
style,
@@ -109,17 +121,43 @@ const DragUploadZone = memo<DragUploadZoneProps>(
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 (
<div className={cx(styles.container, className)} style={style} {...getContainerProps()}>
{children}
@@ -137,7 +175,10 @@ const DragUploadZone = memo<DragUploadZoneProps>(
transform: 'rotateZ(-20deg) translateX(8px)',
}}
>
<Icon icon={FileImage} size={ICON_SIZE} />
<Icon
icon={overlayCopy.showFolderIcon ? FolderIcon : FileImage}
size={ICON_SIZE}
/>
</Center>
<Center
className={styles.icon}
@@ -148,7 +189,10 @@ const DragUploadZone = memo<DragUploadZoneProps>(
zIndex: 1,
}}
>
<Icon icon={FileUpIcon} size={ICON_SIZE} />
<Icon
icon={overlayCopy.showFolderIcon ? FolderIcon : FileUpIcon}
size={ICON_SIZE}
/>
</Center>
<Center
className={styles.icon}
@@ -159,16 +203,15 @@ const DragUploadZone = memo<DragUploadZoneProps>(
transform: 'rotateZ(20deg) translateX(-8px)',
}}
>
<Icon icon={FileText} size={ICON_SIZE} />
<Icon
icon={overlayCopy.showFolderIcon ? FolderIcon : FileText}
size={ICON_SIZE}
/>
</Center>
</Flexbox>
<Flexbox align={'center'} gap={4} style={{ textAlign: 'center' }}>
<Flexbox className={styles.title}>
{t(enabledFiles ? 'DragUpload.dragFileTitle' : 'DragUpload.dragTitle')}
</Flexbox>
<Flexbox className={styles.desc}>
{t(enabledFiles ? 'DragUpload.dragFileDesc' : 'DragUpload.dragDesc')}
</Flexbox>
<Flexbox className={styles.title}>{overlayCopy.title}</Flexbox>
<Flexbox className={styles.desc}>{overlayCopy.desc}</Flexbox>
</Flexbox>
</Center>
</div>
@@ -181,6 +224,7 @@ const DragUploadZone = memo<DragUploadZoneProps>(
DragUploadZone.displayName = 'DragUploadZone';
export type { DroppedFolder } from './useLocalDragUpload';
export { usePasteFile } from './usePasteFile';
export { useUploadFiles } from './useUploadFiles';
export default DragUploadZone;
@@ -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<DroppedFolder[]>([
{ 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<DroppedFolder[]>([{ 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([]);
});
});
@@ -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<PartitionedDroppedItems> => {
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<void>;
/**
* 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(
@@ -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 `<localFile name="..." path="..." isDirectory />`.
*/
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(' ');
}
});
};
@@ -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;
+4
View File
@@ -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',
@@ -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 (
<Suspense fallback={<Loading debugId="Agent > ChatConversation" />}>
<DragUploadZone style={wrapperStyle} onUploadFiles={handleUploadFiles}>
<DragUploadZone
enableLocalFolderMention={enableLocalFolderMention}
style={wrapperStyle}
onLocalFolders={enableLocalFolderMention ? handleLocalFolders : undefined}
onUploadFiles={handleUploadFiles}
>
<Flexbox flex={1} height={'100%'} style={{ minWidth: 0 }}>
<TooltipGroup>
<ConversationArea />
@@ -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)
: [];