From fea5b382cf5902231440efb6832493ca1f247b20 Mon Sep 17 00:00:00 2001 From: duanfuxiang Date: Wed, 2 Jul 2025 08:09:22 +0800 Subject: [PATCH] update manage files --- src/components/chat-view/ChatView.tsx | 146 +++++++++++++++++- .../Markdown/MarkdownManageFilesBlock.tsx | 124 +++++++++++++++ src/components/chat-view/ReactMarkdown.tsx | 22 +-- src/types/apply.ts | 14 +- src/utils/parse-infio-block.ts | 80 ++++++++++ styles.css | 30 ++++ 6 files changed, 404 insertions(+), 12 deletions(-) create mode 100644 src/components/chat-view/Markdown/MarkdownManageFilesBlock.tsx diff --git a/src/components/chat-view/ChatView.tsx b/src/components/chat-view/ChatView.tsx index c59e85e..15a6ed8 100644 --- a/src/components/chat-view/ChatView.tsx +++ b/src/components/chat-view/ChatView.tsx @@ -3,7 +3,7 @@ import * as path from 'path' import { BaseSerializedNode } from '@lexical/clipboard/clipboard' import { useMutation } from '@tanstack/react-query' import { Box, CircleStop, History, NotebookPen, Plus, Search, Server, SquareSlash, Undo } from 'lucide-react' -import { App, Notice, TFile, WorkspaceLeaf } from 'obsidian' +import { App, Notice, TFile, TFolder, WorkspaceLeaf } from 'obsidian' import { forwardRef, useCallback, @@ -902,6 +902,150 @@ const Chat = forwardRef((props, ref) => { } }; } + } else if (toolArgs.type === 'manage_files') { + try { + const results: string[] = []; + + // 处理每个文件操作 + for (const operation of toolArgs.operations) { + switch (operation.action) { + case 'create_folder': + if (operation.path) { + const folderExists = await app.vault.adapter.exists(operation.path); + if (!folderExists) { + await app.vault.adapter.mkdir(operation.path); + results.push(`✅ 成功创建文件夹: ${operation.path}`); + } else { + results.push(`⚠️ 文件夹已存在: ${operation.path}`); + } + } + break; + + case 'move': + if (operation.source_path && operation.destination_path) { + // 使用 getAbstractFileByPath 而不是 getFileByPath,这样可以获取文件和文件夹 + const sourceFile = app.vault.getAbstractFileByPath(operation.source_path); + if (sourceFile) { + // 确保目标目录存在 + const destDir = path.dirname(operation.destination_path); + if (destDir && destDir !== '.' && destDir !== '/') { + const dirExists = await app.vault.adapter.exists(destDir); + if (!dirExists) { + await app.vault.adapter.mkdir(destDir); + } + } + await app.vault.rename(sourceFile, operation.destination_path); + const itemType = sourceFile instanceof TFile ? '文件' : '文件夹'; + results.push(`✅ 成功移动${itemType}: ${operation.source_path} → ${operation.destination_path}`); + } else { + results.push(`❌ 源文件或文件夹不存在: ${operation.source_path}`); + } + } + break; + + case 'delete': + if (operation.path) { + // 使用 getAbstractFileByPath 而不是 getFileByPath + const fileOrFolder = app.vault.getAbstractFileByPath(operation.path); + if (fileOrFolder) { + try { + const isFolder = fileOrFolder instanceof TFolder; + // 使用 trash 方法将文件/文件夹移到回收站,更安全 + // system: true 尝试使用系统回收站,失败则使用 Obsidian 本地回收站 + await app.vault.trash(fileOrFolder, true); + const itemType = isFolder ? '文件夹' : '文件'; + results.push(`✅ 成功将${itemType}移到回收站: ${operation.path}`); + } catch (error) { + console.error('删除失败:', error); + results.push(`❌ 删除失败: ${operation.path} - ${error.message}`); + } + } else { + results.push(`❌ 文件或文件夹不存在: ${operation.path}`); + } + } + break; + + case 'copy': + if (operation.source_path && operation.destination_path) { + // 文件夹复制比较复杂,需要递归处理 + const sourceFile = app.vault.getAbstractFileByPath(operation.source_path); + if (sourceFile) { + if (sourceFile instanceof TFile) { + // 文件复制 + const destDir = path.dirname(operation.destination_path); + if (destDir && destDir !== '.' && destDir !== '/') { + const dirExists = await app.vault.adapter.exists(destDir); + if (!dirExists) { + await app.vault.adapter.mkdir(destDir); + } + } + const content = await app.vault.read(sourceFile); + await app.vault.create(operation.destination_path, content); + results.push(`✅ 成功复制文件: ${operation.source_path} → ${operation.destination_path}`); + } else if (sourceFile instanceof TFolder) { + // 文件夹复制需要递归处理 + results.push(`❌ 文件夹复制功能暂未实现: ${operation.source_path}`); + } + } else { + results.push(`❌ 源文件或文件夹不存在: ${operation.source_path}`); + } + } + break; + + case 'rename': + if (operation.path && operation.new_name) { + // 使用 getAbstractFileByPath 而不是 getFileByPath + const file = app.vault.getAbstractFileByPath(operation.path); + if (file) { + const newPath = path.join(path.dirname(operation.path), operation.new_name); + await app.vault.rename(file, newPath); + const itemType = file instanceof TFile ? '文件' : '文件夹'; + results.push(`✅ 成功重命名${itemType}: ${operation.path} → ${newPath}`); + } else { + results.push(`❌ 文件或文件夹不存在: ${operation.path}`); + } + } + break; + + default: + results.push(`❌ 不支持的操作类型: ${operation.action}`); + } + } + + const formattedContent = `[manage_files] 文件管理操作结果:\n${results.join('\n')}`; + + return { + type: 'manage_files', + applyMsgId, + applyStatus: ApplyStatus.Applied, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: formattedContent, + id: uuidv4(), + mentionables: [], + } + }; + } catch (error) { + console.error('文件管理操作失败:', error); + return { + type: 'manage_files', + applyMsgId, + applyStatus: ApplyStatus.Failed, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: `[manage_files] 文件管理操作失败: ${error instanceof Error ? error.message : String(error)}`, + id: uuidv4(), + mentionables: [], + } + }; + } + } else { + // 处理未知的工具类型 + throw new Error(`Unsupported tool type: ${toolArgs.type}`); } } catch (error) { console.error('Failed to apply changes', error) diff --git a/src/components/chat-view/Markdown/MarkdownManageFilesBlock.tsx b/src/components/chat-view/Markdown/MarkdownManageFilesBlock.tsx new file mode 100644 index 0000000..2e82b0f --- /dev/null +++ b/src/components/chat-view/Markdown/MarkdownManageFilesBlock.tsx @@ -0,0 +1,124 @@ +import { Check, Copy, FileIcon, FolderPlus, Loader2, Move, Trash2, X } from 'lucide-react' +import React, { useState } from 'react' + +import { ApplyStatus, ManageFilesToolArgs } from "../../../types/apply" + +interface ManageFilesOperation { + action: 'create_folder' | 'move' | 'delete' | 'copy' | 'rename' + path?: string + source_path?: string + destination_path?: string + new_name?: string +} + +export default function MarkdownManageFilesBlock({ + applyStatus, + onApply, + operations, + finish +}: { + applyStatus: ApplyStatus + onApply: (args: ManageFilesToolArgs) => void + operations: ManageFilesOperation[] + finish: boolean +}) { + const [applying, setApplying] = useState(false) + + const getOperationIcon = (action: string) => { + switch (action) { + case 'create_folder': + return + case 'move': + return + case 'delete': + return + case 'copy': + return + case 'rename': + return + default: + return + } + } + + const getOperationDescription = (operation: ManageFilesOperation) => { + switch (operation.action) { + case 'create_folder': + return `创建文件夹:${operation.path}` + case 'move': + return `移动文件:${operation.source_path} → ${operation.destination_path}` + case 'delete': + return `删除:${operation.path}` + case 'copy': + return `复制:${operation.source_path} → ${operation.destination_path}` + case 'rename': + return `重命名:${operation.path} → ${operation.new_name}` + default: + return `未知操作` + } + } + + const handleApply = async () => { + if (applyStatus !== ApplyStatus.Idle) { + return + } + setApplying(true) + onApply({ + type: 'manage_files', + operations: operations, + }) + } + + return ( +
+
+
+ + 文件管理操作 ({operations.length} 个操作) +
+
+ +
+
+
+ {operations.map((operation, index) => ( +
+
+ {getOperationIcon(operation.action)} + + {getOperationDescription(operation)} + +
+
+ ))} +
+
+ ) +} diff --git a/src/components/chat-view/ReactMarkdown.tsx b/src/components/chat-view/ReactMarkdown.tsx index e728467..6175b9c 100644 --- a/src/components/chat-view/ReactMarkdown.tsx +++ b/src/components/chat-view/ReactMarkdown.tsx @@ -11,8 +11,8 @@ import MarkdownDataviewQueryBlock from './Markdown/MarkdownDataviewQueryBlock' import MarkdownEditFileBlock from './Markdown/MarkdownEditFileBlock' import MarkdownFetchUrlsContentBlock from './Markdown/MarkdownFetchUrlsContentBlock' import MarkdownListFilesBlock from './Markdown/MarkdownListFilesBlock' +import MarkdownManageFilesBlock from './Markdown/MarkdownManageFilesBlock' import MarkdownMatchSearchFilesBlock from './Markdown/MarkdownMatchSearchFilesBlock' -import MarkdownPlanBlock from './Markdown/MarkdownPlanBlock' import MarkdownReadFileBlock from './Markdown/MarkdownReadFileBlock' import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock' import MarkdownRegexSearchFilesBlock from './Markdown/MarkdownRegexSearchFilesBlock' @@ -44,21 +44,15 @@ function ReactMarkdown({ return ( <> {blocks.map((block, index) => - block.type === 'communication' ? ( - - ) : block.type === 'think' ? ( + block.type === 'think' ? ( ) : block.type === 'thinking' ? ( - ) : block.type === 'write_to_file' ? ( + ) : block.type === 'manage_files' ? ( + ) : block.type === 'tool_result' ? ( ; + finish?: boolean; +} + +export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | MatchSearchFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs | ApplyDiffToolArgs | UseMcpToolArgs | DataviewQueryToolArgs | CallTransformationsToolArgs | ManageFilesToolArgs; diff --git a/src/utils/parse-infio-block.ts b/src/utils/parse-infio-block.ts index bebb641..b7b20de 100644 --- a/src/utils/parse-infio-block.ts +++ b/src/utils/parse-infio-block.ts @@ -105,6 +105,16 @@ export type ParsedMsgBlock = path: string transformation: string finish: boolean + } | { + type: 'manage_files' + operations: Array<{ + action: 'create_folder' | 'move' | 'delete' | 'copy' | 'rename' + path?: string + source_path?: string + destination_path?: string + new_name?: string + }> + finish: boolean } | { type: 'tool_result' content: string @@ -817,6 +827,76 @@ export function parseMsgBlocks( }) } lastEndOffset = endOffset + } else if (node.nodeName === 'manage_files') { + if (!node.sourceCodeLocation) { + throw new Error('sourceCodeLocation is undefined') + } + const startOffset = node.sourceCodeLocation.startOffset + const endOffset = node.sourceCodeLocation.endOffset + if (startOffset > lastEndOffset) { + parsedResult.push({ + type: 'string', + content: input.slice(lastEndOffset, startOffset), + }) + } + + let operations: Array<{ + action: 'create_folder' | 'move' | 'delete' | 'copy' | 'rename' + path?: string + source_path?: string + destination_path?: string + new_name?: string + }> = [] + + // 检查是否有 operations 子标签 + for (const childNode of node.childNodes) { + if (childNode.nodeName === 'operations' && childNode.childNodes.length > 0) { + try { + // 获取 operations 标签内的内容 + const operationsChildren = childNode.childNodes + if (operationsChildren.length > 0) { + const innerContentStartOffset = operationsChildren[0].sourceCodeLocation?.startOffset + const innerContentEndOffset = operationsChildren[operationsChildren.length - 1].sourceCodeLocation?.endOffset + + if (innerContentStartOffset && innerContentEndOffset) { + const jsonContent = input.slice(innerContentStartOffset, innerContentEndOffset).trim() + operations = JSON5.parse(jsonContent) + } + } + } catch (error) { + console.error('Failed to parse operations JSON', error) + } + break + } + } + + // 如果没有找到 operations 子标签,尝试直接解析标签内容 + if (operations.length === 0) { + const children = node.childNodes + if (children.length > 0) { + try { + const innerContentStartOffset = children[0].sourceCodeLocation?.startOffset + const innerContentEndOffset = children[children.length - 1].sourceCodeLocation?.endOffset + + if (innerContentStartOffset && innerContentEndOffset) { + const jsonContent = input.slice(innerContentStartOffset, innerContentEndOffset).trim() + // 检查内容是否以 [ 开头(纯 JSON 数组) + if (jsonContent.startsWith('[')) { + operations = JSON5.parse(jsonContent) + } + } + } catch (error) { + console.error('Failed to parse manage_files JSON', error) + } + } + } + + parsedResult.push({ + type: 'manage_files', + operations, + finish: node.sourceCodeLocation.endTag !== undefined + }) + lastEndOffset = endOffset } } diff --git a/styles.css b/styles.css index bc7d27d..675106f 100644 --- a/styles.css +++ b/styles.css @@ -2068,6 +2068,36 @@ button.infio-chat-input-model-select { text-decoration: underline; } +/* + * Manage Files Block Styles + */ +.manage-files-operation { + margin-bottom: var(--size-2-1); +} + +.operation-item { + display: flex; + align-items: center; + gap: var(--size-2-1); + padding: var(--size-2-1) var(--size-4-1); + border-radius: var(--radius-s); + font-size: var(--font-ui-small); + color: var(--text-normal); + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + transition: all 0.2s ease; +} + +.operation-item:hover { + background-color: var(--background-modifier-hover); +} + +.operation-description { + flex: 1; + word-break: break-word; + line-height: 1.4; +} + .infio-chat-code-block-status-button { color: var(--color-green); /* 替换原来的 #008000 */ background: none;