diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 84f7240..69afcac 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -32,6 +32,7 @@ import type { AgentSaveFilesInput, AgentSavedFile, AgentAttachDirectoryInput, + AgentAttachFileInput, GetTaskOutputInput, GetTaskOutputResult, StopTaskInput, @@ -1008,6 +1009,24 @@ export function registerIpcHandlers(): void { } ) + // 打开文件选择对话框(返回文件路径,用于附加) + ipcMain.handle( + AGENT_IPC_CHANNELS.OPEN_FILE_DIALOG, + async (): Promise => { + const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] + if (!win) return null + + const result = await dialog.showOpenDialog(win, { + properties: ['openFile', 'multiSelections'], + title: '选择文件', + }) + + if (result.canceled || result.filePaths.length === 0) return null + + return result.filePaths + } + ) + // 打开文件夹选择对话框 ipcMain.handle( AGENT_IPC_CHANNELS.OPEN_FOLDER_DIALOG, @@ -1062,6 +1081,36 @@ export function registerIpcHandlers(): void { } ) + // 附加外部文件到 Agent 会话 + ipcMain.handle( + AGENT_IPC_CHANNELS.ATTACH_FILE, + async (_, input: AgentAttachFileInput): Promise => { + const meta = getAgentSessionMeta(input.sessionId) + if (!meta) throw new Error(`会话不存在: ${input.sessionId}`) + + const existing = meta.attachedFiles ?? [] + if (existing.includes(input.filePath)) return existing + + const updated = [...existing, input.filePath] + updateAgentSessionMeta(input.sessionId, { attachedFiles: updated }) + return updated + } + ) + + // 移除会话的附加文件 + ipcMain.handle( + AGENT_IPC_CHANNELS.DETACH_FILE, + async (_, input: AgentAttachFileInput): Promise => { + const meta = getAgentSessionMeta(input.sessionId) + if (!meta) throw new Error(`会话不存在: ${input.sessionId}`) + + const existing = meta.attachedFiles ?? [] + const updated = existing.filter((f) => f !== input.filePath) + updateAgentSessionMeta(input.sessionId, { attachedFiles: updated }) + return updated + } + ) + // ===== Agent 文件系统操作 ===== // 获取 session 工作路径 @@ -1279,12 +1328,12 @@ export function registerIpcHandlers(): void { } ) - // 搜索工作区文件(用于 @ 引用,递归扫描,支持附加目录) + // 搜索工作区文件(用于 @ 引用,递归扫描,支持附加目录/文件) ipcMain.handle( AGENT_IPC_CHANNELS.SEARCH_WORKSPACE_FILES, async (_, rootPath: string, query: string, limit = 20, additionalPaths?: string[]): Promise => { - const { readdirSync } = await import('node:fs') - const { resolve, relative } = await import('node:path') + const { readdirSync, statSync } = await import('node:fs') + const { resolve, relative, basename } = await import('node:path') const safeRoot = resolve(rootPath) const ignoreDirs = new Set(['node_modules', '.git', 'dist', '.next', '__pycache__', '.venv', 'build', '.cache']) @@ -1319,11 +1368,28 @@ export function registerIpcHandlers(): void { scan(safeRoot, 0, safeRoot) - // 扫描附加目录(外部路径) + // 扫描附加目录/文件(外部路径) if (additionalPaths && additionalPaths.length > 0) { for (const addPath of additionalPaths) { - const addRoot = resolve(addPath) - scan(addRoot, 0, addRoot) + const resolvedPath = resolve(addPath) + try { + const stat = statSync(resolvedPath) + if (stat.isDirectory()) { + scan(resolvedPath, 0, resolvedPath) + continue + } + + if (stat.isFile()) { + allEntries.push({ + name: basename(resolvedPath), + // 单文件引用需传绝对路径,确保 SDK 可直接定位 + path: resolvedPath, + type: 'file', + }) + } + } catch { + // 忽略不存在或无权限的附加路径 + } } } diff --git a/apps/electron/src/main/lib/agent-orchestrator.ts b/apps/electron/src/main/lib/agent-orchestrator.ts index 1a21239..76c50a9 100644 --- a/apps/electron/src/main/lib/agent-orchestrator.ts +++ b/apps/electron/src/main/lib/agent-orchestrator.ts @@ -589,7 +589,7 @@ export class AgentOrchestrator { * 通过 EventBus 分发 AgentEvent,通过 callbacks 发送控制信号。 */ async sendMessage(input: AgentSendInput, callbacks: SessionCallbacks): Promise { - const { sessionId, userMessage, channelId, modelId, workspaceId, additionalDirectories } = input + const { sessionId, userMessage, channelId, modelId, workspaceId, additionalDirectories, attachedFiles } = input const stderrChunks: string[] = [] // 0. 并发保护 @@ -822,7 +822,13 @@ export class AgentOrchestrator { resumeSessionId: existingSdkSessionId, ...(Object.keys(mcpServers).length > 0 && { mcpServers }), ...(workspaceSlug && { plugins: [{ type: 'local' as const, path: getAgentWorkspacePath(workspaceSlug) }] }), - ...(additionalDirectories && additionalDirectories.length > 0 && { additionalDirectories }), + // 合并附加目录和附加文件到 additionalDirectories(SDK 支持文件路径) + ...((additionalDirectories && additionalDirectories.length > 0) || (attachedFiles && attachedFiles.length > 0)) && { + additionalDirectories: [ + ...(additionalDirectories || []), + ...(attachedFiles || []), + ], + }, // SDK 0.2.52+ 新增选项(从 settings 读取) ...(appSettings.agentThinking && { thinking: appSettings.agentThinking }), ...(appSettings.agentEffort && { effort: appSettings.agentEffort }), diff --git a/apps/electron/src/main/lib/agent-session-manager.ts b/apps/electron/src/main/lib/agent-session-manager.ts index a32d96e..782f903 100644 --- a/apps/electron/src/main/lib/agent-session-manager.ts +++ b/apps/electron/src/main/lib/agent-session-manager.ts @@ -162,7 +162,7 @@ export function appendAgentMessage(id: string, message: AgentMessage): void { */ export function updateAgentSessionMeta( id: string, - updates: Partial>, + updates: Partial>, ): AgentSessionMeta { const index = readIndex() const idx = index.sessions.findIndex((s) => s.id === id) diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 0c29535..5288987 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -40,6 +40,7 @@ import type { AgentSaveFilesInput, AgentSavedFile, AgentAttachDirectoryInput, + AgentAttachFileInput, GetTaskOutputInput, GetTaskOutputResult, StopTaskInput, @@ -402,6 +403,9 @@ export interface ElectronAPI { /** 保存文件到 Agent session 工作目录 */ saveFilesToAgentSession: (input: AgentSaveFilesInput) => Promise + /** 打开文件选择对话框(返回文件路径,用于附加) */ + openAgentFileDialog: () => Promise + /** 打开文件夹选择对话框 */ openFolderDialog: () => Promise<{ path: string; name: string } | null> @@ -411,6 +415,12 @@ export interface ElectronAPI { /** 移除会话的附加目录 */ detachDirectory: (input: AgentAttachDirectoryInput) => Promise + /** 附加外部文件到 Agent 会话 */ + attachFile: (input: AgentAttachFileInput) => Promise + + /** 移除会话的附加文件 */ + detachFile: (input: AgentAttachFileInput) => Promise + // ===== Agent 文件系统操作 ===== /** 获取 session 工作路径 */ @@ -938,6 +948,10 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.SAVE_FILES_TO_SESSION, input) }, + openAgentFileDialog: () => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.OPEN_FILE_DIALOG) + }, + openFolderDialog: () => { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.OPEN_FOLDER_DIALOG) }, @@ -950,6 +964,14 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.DETACH_DIRECTORY, input) }, + attachFile: (input: AgentAttachFileInput) => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.ATTACH_FILE, input) + }, + + detachFile: (input: AgentAttachFileInput) => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.DETACH_FILE, input) + }, + // Agent 文件系统操作 getAgentSessionPath: (workspaceId: string, sessionId: string) => { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.GET_SESSION_PATH, workspaceId, sessionId) diff --git a/apps/electron/src/renderer/atoms/agent-atoms.ts b/apps/electron/src/renderer/atoms/agent-atoms.ts index 3b4095f..f4039ec 100644 --- a/apps/electron/src/renderer/atoms/agent-atoms.ts +++ b/apps/electron/src/renderer/atoms/agent-atoms.ts @@ -1166,6 +1166,13 @@ export const agentSessionDraftsAtom = atom>(new Map()) */ export const agentAttachedDirectoriesMapAtom = atom>(new Map()) +/** + * 会话附加文件 Map — 以 sessionId 为 key + * 存储每个会话通过"选择文件"功能关联的外部文件路径列表。 + * 这些文件通过引用方式附加,不复制到工作区。 + */ +export const agentAttachedFilesMapAtom = atom>(new Map()) + /** 当前 Agent 会话的草稿内容(派生读写原子) */ export const currentAgentSessionDraftAtom = atom( (get) => { diff --git a/apps/electron/src/renderer/components/agent/AgentView.tsx b/apps/electron/src/renderer/components/agent/AgentView.tsx index 52d8601..3f93904 100644 --- a/apps/electron/src/renderer/components/agent/AgentView.tsx +++ b/apps/electron/src/renderer/components/agent/AgentView.tsx @@ -51,6 +51,7 @@ import { buildTeamActivityEntries, rebuildTeamDataFromMessages, agentAttachedDirectoriesMapAtom, + agentAttachedFilesMapAtom, } from '@/atoms/agent-atoms' import type { AgentContextStatus } from '@/atoms/agent-atoms' import { activeViewAtom } from '@/atoms/active-view' @@ -91,6 +92,9 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem const setAttachedDirsMap = useSetAtom(agentAttachedDirectoriesMapAtom) const attachedDirsMap = useAtomValue(agentAttachedDirectoriesMapAtom) const attachedDirs = attachedDirsMap.get(sessionId) ?? [] + const setAttachedFilesMap = useSetAtom(agentAttachedFilesMapAtom) + const attachedFilesMap = useAtomValue(agentAttachedFilesMapAtom) + const attachedFiles = attachedFilesMap.get(sessionId) ?? [] const draftsMap = useAtomValue(agentSessionDraftsAtom) const setDraftsMap = useSetAtom(agentSessionDraftsAtom) @@ -200,11 +204,13 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem .catch(console.error) }, [sessionId, refreshVersion, setStreamingStates, store]) - // 从会话元数据初始化附加目录 + // 从会话元数据初始化附加目录和文件 const sessions = useAtomValue(agentSessionsAtom) React.useEffect(() => { const meta = sessions.find((s) => s.id === sessionId) const dirs = meta?.attachedDirectories ?? [] + const files = meta?.attachedFiles ?? [] + setAttachedDirsMap((prev) => { const existing = prev.get(sessionId) // 避免不必要的更新 @@ -217,7 +223,20 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem } return map }) - }, [sessionId, sessions, setAttachedDirsMap]) + + setAttachedFilesMap((prev) => { + const existing = prev.get(sessionId) + // 避免不必要的更新 + if (JSON.stringify(existing) === JSON.stringify(files)) return prev + const map = new Map(prev) + if (files.length > 0) { + map.set(sessionId, files) + } else { + map.delete(sessionId) + } + return map + }) + }, [sessionId, sessions, setAttachedDirsMap, setAttachedFilesMap]) // 自动发送 pending prompt(从设置页"对话完成配置"触发) React.useEffect(() => { @@ -580,6 +599,7 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem modelId: agentModelId || undefined, workspaceId: currentWorkspaceId || undefined, ...(attachedDirs.length > 0 && { additionalDirectories: attachedDirs }), + ...(attachedFiles.length > 0 && { attachedFiles }), } setInputContent('') @@ -854,6 +874,7 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem collapsible workspacePath={sessionPath} attachedDirs={attachedDirs} + attachedFiles={attachedFiles} /> {/* Footer 工具栏 */} diff --git a/apps/electron/src/renderer/components/agent/SidePanel.tsx b/apps/electron/src/renderer/components/agent/SidePanel.tsx index 6423f42..96c7cb0 100644 --- a/apps/electron/src/renderer/components/agent/SidePanel.tsx +++ b/apps/electron/src/renderer/components/agent/SidePanel.tsx @@ -33,6 +33,7 @@ import { currentAgentWorkspaceIdAtom, agentWorkspacesAtom, agentAttachedDirectoriesMapAtom, + agentAttachedFilesMapAtom, } from '@/atoms/agent-atoms' import type { SidePanelTab } from '@/atoms/agent-atoms' import type { FileEntry } from '@proma/shared' @@ -111,6 +112,11 @@ export function SidePanel({ sessionId, sessionPath }: SidePanelProps): React.Rea const setAttachedDirsMap = useSetAtom(agentAttachedDirectoriesMapAtom) const attachedDirs = attachedDirsMap.get(sessionId) ?? [] + // 附加文件列表 + const attachedFilesMap = useAtomValue(agentAttachedFilesMapAtom) + const setAttachedFilesMap = useSetAtom(agentAttachedFilesMapAtom) + const attachedFiles = attachedFilesMap.get(sessionId) ?? [] + const handleAttachFolder = React.useCallback(async () => { try { const result = await window.electronAPI.openFolderDialog() @@ -150,10 +156,50 @@ export function SidePanel({ sessionId, sessionPath }: SidePanelProps): React.Rea } }, [sessionId, setAttachedDirsMap]) - // 文件上传完成后递增版本号,触发 FileBrowser 刷新 - const handleFilesUploaded = React.useCallback(() => { + const handleDetachFile = React.useCallback(async (filePath: string) => { + try { + const updated = await window.electronAPI.detachFile({ + sessionId, + filePath, + }) + setAttachedFilesMap((prev) => { + const map = new Map(prev) + if (updated.length > 0) { + map.set(sessionId, updated) + } else { + map.delete(sessionId) + } + return map + }) + } catch (error) { + console.error('[SidePanel] 移除附加文件失败:', error) + } + }, [sessionId, setAttachedFilesMap]) + + // 文件上传完成后递增版本号,触发 FileBrowser 刷新,并重新加载附加文件列表 + const handleFilesUploaded = React.useCallback(async () => { setFilesVersion((prev) => prev + 1) - }, [setFilesVersion]) + + // 重新加载会话元数据以获取最新的附加文件列表 + try { + const sessions = await window.electronAPI.listAgentSessions() + const currentSession = sessions.find((s) => s.id === sessionId) + if (currentSession) { + const files = currentSession.attachedFiles ?? [] + setAttachedFilesMap((prev) => { + const map = new Map(prev) + if (files.length > 0) { + map.set(sessionId, files) + } else { + map.delete(sessionId) + } + return map + }) + } + } catch (error) { + console.error('[SidePanel] 重新加载附加文件失败:', error) + } + }, [sessionId, setFilesVersion, setAttachedFilesMap]) // 手动刷新文件列表 const handleRefresh = React.useCallback(() => { @@ -181,7 +227,7 @@ export function SidePanel({ sessionId, sessionPath }: SidePanelProps): React.Rea }, [filesVersion, sessionPath, hasTeamActivity, setIsOpen, setActiveTab]) // 面板是否可显示内容(需要有 sessionPath 或 team 活动) - const hasContent = sessionPath || hasTeamActivity || attachedDirs.length > 0 + const hasContent = sessionPath || hasTeamActivity || attachedDirs.length > 0 || attachedFiles.length > 0 return (
- {/* 可滚动内容区:附加目录 + 文件浏览器 + 拖拽上传 */} + {/* 可滚动内容区:附加目录 + 附加文件 + 文件浏览器 + 拖拽上传 */}
- {/* 附加目录列表(可展开目录树) */} - {attachedDirs.length > 0 && ( - 0 || attachedFiles.length > 0) && ( + )} @@ -342,7 +390,147 @@ export function SidePanel({ sessionId, sessionPath }: SidePanelProps): React.Rea ) } -// ===== 附加目录容器(管理选中状态) ===== +// ===== 附加目录和文件容器(管理选中状态) ===== + +interface AttachedItemsSectionProps { + attachedDirs: string[] + attachedFiles: string[] + onDetachDir: (dirPath: string) => void + onDetachFile: (filePath: string) => void + /** 文件版本号,用于自动刷新已展开的目录 */ + refreshVersion: number +} + +/** 附加目录和文件区域:统一管理所有子项的选中状态 */ +function AttachedItemsSection({ attachedDirs, attachedFiles, onDetachDir, onDetachFile, refreshVersion }: AttachedItemsSectionProps): React.ReactElement { + const [selectedPaths, setSelectedPaths] = React.useState>(new Set()) + + const handleSelect = React.useCallback((path: string, ctrlKey: boolean) => { + setSelectedPaths((prev) => { + if (ctrlKey) { + // Ctrl+点击:切换选中 + const next = new Set(prev) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + return next + } + // 普通点击:单选 + return new Set([path]) + }) + }, []) + + return ( +
+
附加文件(Agent 可以读取并操作)
+ {/* 附加文件夹 */} + {attachedDirs.map((dir) => ( + onDetachDir(dir)} + selectedPaths={selectedPaths} + onSelect={handleSelect} + refreshVersion={refreshVersion} + /> + ))} + {/* 附加文件 */} + {attachedFiles.map((file) => ( + onDetachFile(file)} + selectedPaths={selectedPaths} + onSelect={handleSelect} + /> + ))} +
+ ) +} + +// ===== 附加文件项组件 ===== + +interface AttachedFileItemProps { + filePath: string + onDetach: () => void + selectedPaths: Set + onSelect: (path: string, ctrlKey: boolean) => void +} + +/** 附加文件项:显示文件名,支持选中 + 三点菜单 */ +function AttachedFileItem({ filePath, onDetach, selectedPaths, onSelect }: AttachedFileItemProps): React.ReactElement { + const fileName = filePath.split('/').filter(Boolean).pop() || filePath + const isSelected = selectedPaths.has(filePath) + + const handleClick = (e: React.MouseEvent): void => { + onSelect(filePath, e.ctrlKey || e.metaKey) + } + + const handleDoubleClick = (): void => { + window.electronAPI.openAttachedFile(filePath).catch(console.error) + } + + return ( +
+ + + + {fileName} + + {isSelected && ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + + + + window.electronAPI.showAttachedInFolder(filePath).catch(console.error)} + > + + 在文件夹中显示 + + window.electronAPI.openAttachedFile(filePath).catch(console.error)} + > + + 打开文件 + + + + 移除附加 + + + +
+ )} +
+ ) +} + +// ===== 附加目录容器(旧版,保留兼容) ===== interface AttachedDirsSectionProps { attachedDirs: string[] diff --git a/apps/electron/src/renderer/components/ai-elements/message.tsx b/apps/electron/src/renderer/components/ai-elements/message.tsx index ba13583..222ff3d 100644 --- a/apps/electron/src/renderer/components/ai-elements/message.tsx +++ b/apps/electron/src/renderer/components/ai-elements/message.tsx @@ -278,8 +278,8 @@ export const MessageResponse = React.memo( /** 折叠行数阈值 */ const COLLAPSE_LINE_THRESHOLD = 4 -/** 将文本中的 @file:路径 替换为样式化 chip */ -const FILE_MENTION_RE = /@file:(\S+)/g +/** 将文本中的 @file:路径 替换为样式化 chip(兼容带引号与旧格式) */ +const FILE_MENTION_RE = /@file:(?:"((?:\\.|[^"\\])*)"|(\S+))/g function renderTextWithMentions(text: string): React.ReactNode { const parts: React.ReactNode[] = [] @@ -295,7 +295,7 @@ function renderTextWithMentions(text: string): React.ReactNode { parts.push(text.slice(lastIndex, match.index)) } // 渲染 mention chip - const filePath = match[1] ?? '' + const filePath = (match[1] ?? match[2] ?? '').replace(/\\"/g, '"') const fileName = filePath.split('/').pop() || filePath parts.push( (attachedDirs) attachedDirsRef.current = attachedDirs + // 附加文件路径引用(给 Suggestion 使用) + const attachedFilesRef = useRef(attachedFiles) + attachedFilesRef.current = attachedFiles // Mention Suggestion 配置(稳定引用,不随 workspacePath 变化重建) const mentionSuggestion = useMemo( - () => createFileMentionSuggestion(workspacePathRef, mentionActiveRef, attachedDirsRef), + () => createFileMentionSuggestion(workspacePathRef, mentionActiveRef, attachedDirsRef, attachedFilesRef), [], ) diff --git a/apps/electron/src/renderer/components/file-browser/FileDropZone.tsx b/apps/electron/src/renderer/components/file-browser/FileDropZone.tsx index 07c39b9..94fb561 100644 --- a/apps/electron/src/renderer/components/file-browser/FileDropZone.tsx +++ b/apps/electron/src/renderer/components/file-browser/FileDropZone.tsx @@ -103,30 +103,35 @@ export function FileDropZone({ workspaceSlug, sessionId, onFilesUploaded, onAtta const handleSelectFiles = React.useCallback(async (): Promise => { try { - const result = await window.electronAPI.openFileDialog() - if (result.files.length === 0) return + const filePaths = await window.electronAPI.openAgentFileDialog() + if (!filePaths || filePaths.length === 0) return setIsUploading(true) - const fileEntries = result.files.map((f) => ({ - filename: f.filename, - data: f.data, - })) - await window.electronAPI.saveFilesToAgentSession({ - workspaceSlug, + // 附加文件路径到会话(不复制文件内容) + const updatedFiles = await window.electronAPI.attachFile({ sessionId, - files: fileEntries, + filePath: filePaths[0]!, }) + // 如果有多个文件,依次附加 + for (let i = 1; i < filePaths.length; i++) { + await window.electronAPI.attachFile({ + sessionId, + filePath: filePaths[i]!, + }) + } + + // 触发刷新,让 SidePanel 重新加载附加文件列表 onFilesUploaded() - toast.success(`已添加 ${result.files.length} 个文件`) + toast.success(`已附加 ${filePaths.length} 个文件`) } catch (error) { - console.error('[FileDropZone] 选择文件失败:', error) - toast.error('文件上传失败') + console.error('[FileDropZone] 附加文件失败:', error) + toast.error('文件附加失败') } finally { setIsUploading(false) } - }, [workspaceSlug, sessionId, onFilesUploaded]) + }, [sessionId, onFilesUploaded]) return (
@@ -170,11 +175,11 @@ export function FileDropZone({ workspaceSlug, sessionId, onFilesUploaded, onAtta onClick={handleSelectFiles} > - 选择文件 + 附加文件 -

将文件放入 Agent 工作文件夹

+

附加文件到 Agent 工作区(引用原文件)

{onAttachFolder && ( diff --git a/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx b/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx index 245dfec..4b8677b 100644 --- a/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx +++ b/apps/electron/src/renderer/components/file-browser/file-mention-suggestion.tsx @@ -8,6 +8,7 @@ import type React from 'react' import { ReactRenderer } from '@tiptap/react' import type { SuggestionOptions } from '@tiptap/suggestion' +import type { SuggestionProps } from '@tiptap/suggestion' import { FileMentionList } from './FileMentionList' import type { FileMentionRef } from './FileMentionList' import type { FileIndexEntry } from '@proma/shared' @@ -18,15 +19,17 @@ import type { FileIndexEntry } from '@proma/shared' * @param workspacePathRef 当前工作区根路径引用 * @param mentionActiveRef 是否正在 mention 模式(用于阻止 Enter 发送消息) * @param attachedDirsRef 附加目录路径列表引用(搜索时一并扫描) + * @param attachedFilesRef 附加文件路径列表引用(搜索时一并扫描) */ export function createFileMentionSuggestion( workspacePathRef: React.RefObject, mentionActiveRef: React.MutableRefObject, attachedDirsRef?: React.RefObject, + attachedFilesRef?: React.RefObject, ): Omit, 'editor'> { return { char: '@', - allowSpaces: false, + allowSpaces: true, // 异步搜索文件 items: async ({ query }): Promise => { @@ -34,7 +37,10 @@ export function createFileMentionSuggestion( if (!wsPath) return [] try { - const additionalPaths = attachedDirsRef?.current ?? [] + const additionalPaths = [ + ...(attachedDirsRef?.current ?? []), + ...(attachedFilesRef?.current ?? []), + ] const result = await window.electronAPI.searchWorkspaceFiles( wsPath, query ?? '', @@ -51,17 +57,38 @@ export function createFileMentionSuggestion( render: () => { let renderer: ReactRenderer | null = null let popup: HTMLDivElement | null = null + // 保存当前 props 用于选择时获取 range + let currentProps: SuggestionProps | null = null + + const handleSelect = (item: FileIndexEntry) => { + if (!currentProps) return + const { editor, range } = currentProps + // 使用明确的 range 替换查询文本为 mention 节点 + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: 'mention', + attrs: { id: item.path, label: item.name }, + }, + { + type: 'text', + text: ' ', + }, + ]) + .run() + } return { onStart(props) { mentionActiveRef.current = true + currentProps = props renderer = new ReactRenderer(FileMentionList, { props: { items: props.items, selectedIndex: 0, - onSelect: (item: FileIndexEntry) => { - props.command({ id: item.path, label: item.name }) - }, + onSelect: handleSelect, }, editor: props.editor, }) @@ -86,6 +113,7 @@ export function createFileMentionSuggestion( }, onUpdate(props) { + currentProps = props renderer?.updateProps({ items: props.items }) // 重新定位 diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index a76c3f8..e203911 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -330,6 +330,8 @@ export interface AgentSessionMeta { pinned?: boolean /** 附加的外部目录路径列表(绝对路径,作为 SDK additionalDirectories 传递) */ attachedDirectories?: string[] + /** 附加的外部文件路径列表(绝对路径,通过引用方式附加,不复制) */ + attachedFiles?: string[] /** 创建时间戳 */ createdAt: number /** 更新时间戳 */ @@ -452,6 +454,8 @@ export interface AgentSendInput { workspaceId?: string /** 附加的外部目录(绝对路径,传递给 SDK additionalDirectories) */ additionalDirectories?: string[] + /** 附加的外部文件(绝对路径,传递给 SDK additionalDirectories) */ + attachedFiles?: string[] } // ===== 会话迁移输入 ===== @@ -540,7 +544,7 @@ export interface FileEntry { export interface FileIndexEntry { /** 文件/目录名称 */ name: string - /** 相对于工作区的路径 */ + /** 路径(工作区内为相对路径;附加单文件可能为绝对路径) */ path: string /** 条目类型 */ type: 'file' | 'dir' @@ -585,6 +589,14 @@ export interface AgentAttachDirectoryInput { directoryPath: string } +/** 附加/分离文件的输入参数 */ +export interface AgentAttachFileInput { + /** 会话 ID */ + sessionId: string + /** 文件的绝对路径 */ + filePath: string +} + // ===== AskUserQuestion 交互式问答类型 ===== /** AskUserQuestion 工具的选项定义 */ @@ -825,12 +837,18 @@ export const AGENT_IPC_CHANNELS = { // 附件 /** 保存文件到 Agent session 工作目录 */ SAVE_FILES_TO_SESSION: 'agent:save-files-to-session', + /** 打开文件选择对话框(返回 base64 内容,用于复制) */ + OPEN_FILE_DIALOG: 'agent:open-file-dialog', /** 打开文件夹选择对话框 */ OPEN_FOLDER_DIALOG: 'agent:open-folder-dialog', /** 附加外部目录到 Agent 会话 */ ATTACH_DIRECTORY: 'agent:attach-directory', /** 移除会话的附加目录 */ DETACH_DIRECTORY: 'agent:detach-directory', + /** 附加外部文件到 Agent 会话 */ + ATTACH_FILE: 'agent:attach-file', + /** 移除会话的附加文件 */ + DETACH_FILE: 'agent:detach-file', // 文件系统操作 /** 获取 session 工作路径 */