update manage files

This commit is contained in:
duanfuxiang 2025-07-02 08:09:22 +08:00
parent 89bc10d16d
commit fea5b382cf
6 changed files with 404 additions and 12 deletions

View File

@ -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<ChatRef, ChatProps>((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)

View File

@ -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 <FolderPlus size={14} className="infio-chat-code-block-header-icon" />
case 'move':
return <Move size={14} className="infio-chat-code-block-header-icon" />
case 'delete':
return <Trash2 size={14} className="infio-chat-code-block-header-icon" />
case 'copy':
return <Copy size={14} className="infio-chat-code-block-header-icon" />
case 'rename':
return <FileIcon size={14} className="infio-chat-code-block-header-icon" />
default:
return <FileIcon size={14} className="infio-chat-code-block-header-icon" />
}
}
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 (
<div className={`infio-chat-code-block has-filename`}>
<div className={'infio-chat-code-block-header'}>
<div className={'infio-chat-code-block-header-filename'}>
<FolderPlus size={14} className="infio-chat-code-block-header-icon" />
({operations.length} )
</div>
<div className={'infio-chat-code-block-header-button'}>
<button
onClick={handleApply}
className="infio-apply-button"
disabled={applyStatus !== ApplyStatus.Idle || applying || !finish}
>
{
!finish ? (
<>
<Loader2 className="spinner" size={14} />
</>
) : applyStatus === ApplyStatus.Idle ? (
applying ? (
<>
<Loader2 className="spinner" size={14} />
</>
) : (
'执行操作'
)
) : applyStatus === ApplyStatus.Applied ? (
<>
<Check size={14} />
</>
) : (
<>
<X size={14} />
</>
)}
</button>
</div>
</div>
<div className="infio-chat-code-block-content">
{operations.map((operation, index) => (
<div key={index} className="manage-files-operation">
<div className="operation-item">
{getOperationIcon(operation.action)}
<span className="operation-description">
{getOperationDescription(operation)}
</span>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -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' ? (
<RawMarkdownBlock
key={"markdown-" + index}
content={block.content}
className="infio-markdown"
/>
) : block.type === 'think' ? (
block.type === 'think' ? (
<MarkdownReasoningBlock
key={"reasoning-" + index}
reasoningContent={block.content}
/>
) : block.type === 'thinking' ? (
<MarkdownPlanBlock
<RawMarkdownBlock
key={"plan-" + index}
planContent={block.content}
content={block.content}
/>
) : block.type === 'write_to_file' ? (
<MarkdownEditFileBlock
@ -229,6 +223,14 @@ function ReactMarkdown({
transformation={block.transformation}
finish={block.finish}
/>
) : block.type === 'manage_files' ? (
<MarkdownManageFilesBlock
key={"manage-files-" + index}
applyStatus={applyStatus}
onApply={onApply}
operations={block.operations}
finish={block.finish}
/>
) : block.type === 'tool_result' ? (
<MarkdownToolResult
key={"tool-result-" + index}

View File

@ -119,4 +119,16 @@ export type CallTransformationsToolArgs = {
finish?: boolean;
}
export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | MatchSearchFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs | ApplyDiffToolArgs | UseMcpToolArgs | DataviewQueryToolArgs | CallTransformationsToolArgs;
export type ManageFilesToolArgs = {
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;
}
export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | MatchSearchFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs | ApplyDiffToolArgs | UseMcpToolArgs | DataviewQueryToolArgs | CallTransformationsToolArgs | ManageFilesToolArgs;

View File

@ -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
}
}

View File

@ -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;