4 Commits
Author SHA1 Message Date
thzxx 058ee2de36 feat: 升级至 v0.3.12 — 文件与代码类工具全面优化(16 项)
【阶段一 · 紧急修复 F1】
- F1-1 file_editor regex 计数 bug:强制 g 标志 + 同一 RegExp 实例做 match+replace
- F1-2 code_search 统一 safeResolvePath(含路径遍历 + MEMORY.md 拦截)
- F1-3 git_commit amend 模式:message 可选 + --no-edit 防止编辑器 hang
- F1-4 write_file append 模式原子化:显式 open(O_APPEND)+write+fsync+close

【阶段二 · 增强现有工具 F2】
- F2-1 read_file 智能编码检测:BOM(UTF-8/UTF-16 LE/BE) + GBK 降级
- F2-2 read_file 支持 tail 模式(读取末尾 N 行,适用日志)
- F2-3 search_files 二进制过滤 + 智能编码检测
- F2-4 search_files 多 glob 匹配(逗号分隔,如 *.ts,*.js)
- F2-5 file_editor 新增 find_replace 操作(字面量替换,规避正则歧义)
- F2-6 file_editor regex 支持跨行匹配(multiline 参数)
- F2-7 file_editor 新增 backup 参数(编辑前 .bak 备份)

【阶段三 · 新增工具 F3】
- F3-1 file_move:双路径校验 + overwrite + 自动建父目录 + rename 原子
- F3-2 file_info:大小/时间/类型/编码/二进制/权限位

【阶段四 · 优化 F4】
- F4-1 diff_viewer Uint32Array→Uint16Array(省一半内存)+ safeResolvePath + 智能编码
- F4-2 错误处理统一:diff-viewer/file-editor/code-search catch 块改用 extractErrorMessage

【阶段五 · 验证 F5】
- tsc --noEmit 类型检查通过
- 人工审查通过:路径校验/错误处理/原子性/资源释放/权限策略/导出注册

工具数量:13 → 15(新增 file_move / file_info)
2026-07-21 16:08:39 +08:00
thzxx 8973ea6d47 feat: 升级至 v0.3.11 — 流式输出性能优化 12 项,解决长内容卡死
针对 AI 流式输出长内容时应用卡死问题,实施 12 项性能优化:

渲染层优化(F1/F3/F7):
- F1: React.memo 包裹 MessageItem/AssistantMessage,跳过历史消息重渲染
- F3: 流式时用纯文本渲染,避免 ReactMarkdown 全量重解析 O(n²)
- F7: useMemo 缓存 ReactMarkdown 元素,非流式时复用实例

滚动与布局(F2/F4/F10/F12):
- F2: 滚动节流 rAF + 100ms throttle + trailing 兜底,避免高频布局
- F4: CSS content-visibility 准虚拟滚动,跳过不可见区域布局
- F10: CSS contain 隔离 markdown 布局计算
- F12: 滚动容器 transform: translateZ(0) GPU 加速

状态与 IPC 批处理(F5/F8/F9):
- F5: 渲染进程 text_delta rAF 批处理,合并多次 store 写入
- F8: 主进程 text_delta 32ms 节流合并,降低 IPC 频率
- F9: reasoning_delta traceSteps thought 走 rAF 批处理

代码清理(F11):
- F11: 移除 rehypeRaw,节省 raw HTML 解析开销 + 防 AI 输出注入

验证:tsc --noEmit 通过,flush 逻辑在 done/error/cleanup 三处正确执行。
2026-07-21 14:38:27 +08:00
thzxx cd681b949a feat: 升级至 v0.3.10 — LLM 配置批量保存 + workspace.path 失败感知 + provider 切换防御性修复
- 新增 config:setBatch IPC:批量写入 8 个 LLM 字段后统一 reloadAdapter,
  解决设置页串行 config:set 在中间态触发"LLM 配置不完整"错误的问题
- SettingsModal.handleSave 和 OnboardingWizard.handleNext 改用 setBatch
- workspace.path 写入独立文件失败时返回 success:false(config:set 和 setBatch 一致),
  避免用户误以为保存成功但下次启动仍使用旧路径
- setBatch 将 provider 切换清空 apiKey 的逻辑移到循环前执行,
  消除对 entries 中 llm.provider 必须出现在 llm.apiKey 之前的隐含顺序依赖
2026-07-21 13:14:42 +08:00
thzxx 64af91bde9 feat: 升级至 v0.3.9 — 工具批量审批 + run_command 频率限制优化 + Toast 规范
1. 工具批量审批:解决并行工具审批弹框覆盖问题,新增批量 IPC 通道和列表 UI,支持同工具多次调用分组展示;2. 审批弹框健壮性:超时 toast 防风暴、agent 状态同步清空、ErrorBoundary 防白屏;3. run_command maxFrequency 从 3 调整为 10;4. 开发规范新增 Toast 通知铁律(必须使用 MeToast)
2026-07-21 11:43:41 +08:00
26 changed files with 1742 additions and 365 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
> 生产级通用 AI Agent 智能体桌面应用 > 生产级通用 AI Agent 智能体桌面应用
[![Version](https://img.shields.io/badge/version-0.3.8-blue)](./package.json) [![Version](https://img.shields.io/badge/version-0.3.12-blue)](./package.json)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/) [![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/)
[![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/)
+112 -7
View File
@@ -22,6 +22,11 @@ export interface ConfirmationRequest {
args: Record<string, unknown>; args: Record<string, unknown>;
riskLevel: string; riskLevel: string;
reason: string; reason: string;
/**
* 过期时间戳(ms),由 waitForConfirmation 注入,用于前端倒计时 UI。
* 注意:beforeExecute 构造 request 时不带此字段,仅在 waitForConfirmation 中追加。
*/
expiresAt?: number;
} }
export class ConfirmationHook implements PreToolHook { export class ConfirmationHook implements PreToolHook {
@@ -41,8 +46,17 @@ export class ConfirmationHook implements PreToolHook {
/** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */ /** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */
private autoExecuteTools = new Set<string>(); private autoExecuteTools = new Set<string>();
/** 等待确认的 Promise 解析器 */ /** 等待确认的 Promise 解析器(含完整请求信息,供 getPendingConfirmations 返回) */
private pendingConfirmations = new Map<string, { resolve: (v: boolean) => void; timer: NodeJS.Timeout; toolName: string; expiresAt: number }>(); private pendingConfirmations = new Map<string, {
resolve: (v: boolean) => void;
timer: NodeJS.Timeout;
toolName: string;
expiresAt: number;
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
args?: Record<string, unknown>;
riskLevel?: string;
reason?: string;
}>();
/** 确认超时时间(可从配置读取,默认 120 秒) */ /** 确认超时时间(可从配置读取,默认 120 秒) */
private confirmationTimeoutMs = 120_000; private confirmationTimeoutMs = 120_000;
@@ -171,6 +185,78 @@ export class ConfirmationHook implements PreToolHook {
} }
} }
/**
* 批量处理用户确认响应(由 IPC tool:confirmationResponseBatch 调用)
*
* 设计要点:
* 1. 单次批量 resolve 即可处理 N 个 pending(避免逐个 IPC 往返)
* 2. remember/autoExecute 按 toolName 去重写入,避免 Map 重复赋值
* 3. 批量拒绝时若 remember=true 也会写入"拒绝"记忆(与单条语义一致)
*
* @param toolCallIds 待处理的 toolCallId 列表
* @param approved 批准/拒绝
* @param remember 会话内记住决定(按 toolName 去重)
* @param autoExecute 永久自动执行(仅 approved=true 时生效,按 toolName 去重)
* @returns 实际处理成功的 toolCallId 数组(未找到的会被跳过)
*/
resolveConfirmationsBatch(
toolCallIds: string[],
approved: boolean,
remember: boolean,
autoExecute: boolean = false,
): string[] {
const resolved: string[] = [];
// 同一 toolName 在批量中可能多次出现,仅写入一次决策记忆
const processedToolNames = new Set<string>();
for (const id of toolCallIds) {
const pending = this.pendingConfirmations.get(id);
if (!pending) continue; // 已超时或不存在,跳过
clearTimeout(pending.timer);
pending.resolve(approved);
this.pendingConfirmations.delete(id);
resolved.push(id);
// 按工具名去重写入决策(同一工具的多次调用共享一次决策)
if (!processedToolNames.has(pending.toolName)) {
processedToolNames.add(pending.toolName);
if (autoExecute && approved) {
this.setAutoExecute(pending.toolName, true);
}
if (remember) {
this.rememberedDecisions.set(pending.toolName, approved);
}
}
}
return resolved;
}
/**
* 获取当前所有等待中的确认请求(供前端批量审批 UI 拉取已积压的请求)
*
* 场景:并行工具触发的多个 IPC 事件可能在前端 state 中互相覆盖,
* 前端可在弹框打开时主动调用此接口,确保拿到完整的 pending 列表。
*
* @returns pending 确认请求的快照(含 toolCallId/toolName/args/riskLevel/reason/expiresAt
*/
getPendingConfirmations(): ConfirmationRequest[] {
const result: ConfirmationRequest[] = [];
for (const [id, pending] of this.pendingConfirmations) {
// 重建 ConfirmationRequest(前端需要 args 用于展示参数详情)
// pending 已在 waitForConfirmation 中缓存完整请求信息
result.push({
toolCallId: id,
toolName: pending.toolName,
args: pending.args ?? {},
riskLevel: pending.riskLevel ?? 'medium',
reason: pending.reason ?? `Tool "${pending.toolName}" requires confirmation`,
expiresAt: pending.expiresAt,
});
}
return result;
}
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> { async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
const def = this.toolDefs.get(toolCall.name); const def = this.toolDefs.get(toolCall.name);
if (!def) { if (!def) {
@@ -227,7 +313,13 @@ export class ConfirmationHook implements PreToolHook {
/** /**
* 等待用户确认(带超时) * 等待用户确认(带超时)
* 发送 expiresAt 到前端,供倒计时 UI 使用;超时时发送 toast 通知 * 发送 expiresAt 到前端,供倒计时 UI 使用;超时时发送 toast 通知
*
* v0.3.2 修复:超时 toast 防风暴
* 并行工具全部超时时会触发 N 个 toast,用 lastToastAt 节流(3 秒内只发 1 条),
* 且消息改为汇总形式"工具确认超时,N 个工具未执行"。
*/ */
private lastTimeoutToastAt = 0;
private waitForConfirmation(request: ConfirmationRequest): Promise<boolean> { private waitForConfirmation(request: ConfirmationRequest): Promise<boolean> {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
const expiresAt = Date.now() + this.confirmationTimeoutMs; const expiresAt = Date.now() + this.confirmationTimeoutMs;
@@ -235,12 +327,21 @@ export class ConfirmationHook implements PreToolHook {
// 设置超时 // 设置超时
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.pendingConfirmations.delete(request.toolCallId); this.pendingConfirmations.delete(request.toolCallId);
// 超时发送 toast 通知用户 // 超时发送 toast 通知用户3 秒节流,防止并行工具风暴)
if (this.mainWindow && !this.mainWindow.isDestroyed()) { if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('toast:show', { const now = Date.now();
type: 'warning', if (now - this.lastTimeoutToastAt > 3000) {
message: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`, this.lastTimeoutToastAt = now;
}); // 统计当前还有多少 pending(含本次刚超时的)
const pendingCount = this.pendingConfirmations.size + 1;
const message = pendingCount > 1
? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行`
: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`;
this.mainWindow.webContents.send('toast:show', {
type: 'warning',
message,
});
}
} }
resolve(false); // 超时视为拒绝 resolve(false); // 超时视为拒绝
}, this.confirmationTimeoutMs); }, this.confirmationTimeoutMs);
@@ -250,6 +351,10 @@ export class ConfirmationHook implements PreToolHook {
timer, timer,
toolName: request.toolName, toolName: request.toolName,
expiresAt, expiresAt,
// v0.3.2 批量审批:同步缓存完整请求信息,供 getPendingConfirmations() 返回
args: request.args,
riskLevel: request.riskLevel,
reason: request.reason,
}); });
// 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时) // 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时)
+7 -1
View File
@@ -39,7 +39,7 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'memory_search', requiredLevel: PermissionLevel.READ }, { toolName: 'memory_search', requiredLevel: PermissionLevel.READ },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 }, { toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 },
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE }, { toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 }, { toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 10 },
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ }, { toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
// web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具) // web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具)
// 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION // 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION
@@ -86,6 +86,12 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
// v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认 // v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认
{ toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 }, { toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
// v0.3.3: 文件移动/重命名工具(1 个)— 可能覆盖目标,需确认
{ toolName: 'file_move', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
// v0.3.3: 文件信息查询工具(1 个)— 只读
{ toolName: 'file_info', requiredLevel: PermissionLevel.READ },
]; ];
export class PolicyEngine { export class PolicyEngine {
+30 -31
View File
@@ -12,12 +12,13 @@
import { execFile } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { resolve } from 'path';
import log from 'electron-log'; import log from 'electron-log';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types'; import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { isPathWithinWorkspace } from './file-guard'; // F1-2: 统一用 safeResolvePath(含 isPathWithinWorkspace + MEMORY.md 拦截)
// F4-2: 引入 extractErrorMessage 统一错误处理
import { safeResolvePath, extractErrorMessage } from './file-guard';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -60,31 +61,37 @@ export class CodeSearchTool implements IMetonaTool {
}; };
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> { async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const pattern = args.pattern as string; // F4-2: 外层 try-catch 防止 safeResolvePath 抛出异常向上传播
if (typeof pattern !== 'string' || !pattern) { // read_file/write_file/list_directory 均有外层 try-catchcode_search 此前缺失)
return { results: [], count: 0, error: 'Pattern is required and must be a string' }; try {
} const pattern = args.pattern as string;
if (pattern.length > 500) { if (typeof pattern !== 'string' || !pattern) {
return { results: [], count: 0, error: 'Pattern too long (max 500 chars)' }; return { results: [], count: 0, error: 'Pattern is required and must be a string' };
} }
if (pattern.length > 500) {
return { results: [], count: 0, error: 'Pattern too long (max 500 chars)' };
}
const searchPath = args.path const searchPath = args.path
? this.resolveSearchPath(args.path as string, context.workspacePath) ? safeResolvePath(args.path as string, context.workspacePath)
: context.workspacePath; : context.workspacePath;
const fileGlob = args.file_glob as string | undefined; const fileGlob = args.file_glob as string | undefined;
const caseSensitive = (args.case_sensitive as boolean) ?? false; const caseSensitive = (args.case_sensitive as boolean) ?? false;
const contextBefore = Math.min(5, Math.max(0, (args.context_before as number) ?? 0)); const contextBefore = Math.min(5, Math.max(0, (args.context_before as number) ?? 0));
const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? 0)); const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? 0));
const maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50)); const maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50));
const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults }; const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults };
// 优先使用 ripgrep,回退到 JS 实现 // 优先使用 ripgrep,回退到 JS 实现
const hasRg = await this.checkRipgrep(); const hasRg = await this.checkRipgrep();
if (hasRg) { if (hasRg) {
return this.searchWithRipgrep(pattern, searchPath, opts, context); return this.searchWithRipgrep(pattern, searchPath, opts, context);
}
return this.searchWithJs(pattern, searchPath, opts, context);
} catch (error) {
return { results: [], count: 0, error: extractErrorMessage(error), success: false };
} }
return this.searchWithJs(pattern, searchPath, opts, context);
} }
/** 使用 ripgrep 子进程搜索 */ /** 使用 ripgrep 子进程搜索 */
@@ -217,12 +224,4 @@ export class CodeSearchTool implements IMetonaTool {
limit: opts.maxResults, limit: opts.maxResults,
}, context); }, context);
} }
private resolveSearchPath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
if (!isPathWithinWorkspace(filePath, workspacePath)) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
}
} }
+24 -16
View File
@@ -8,11 +8,14 @@
*/ */
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import { resolve } from 'path';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types'; import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { isProtectedWorkspaceFile, isPathWithinWorkspace } from './file-guard'; import {
safeResolvePath,
extractErrorMessage,
decodeBufferWithDetection,
} from './file-guard';
interface DiffLine { interface DiffLine {
type: 'context' | 'added' | 'removed'; type: 'context' | 'added' | 'removed';
@@ -30,8 +33,9 @@ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
const sm = oldSliced.length; const sm = oldSliced.length;
const sn = newSliced.length; const sn = newSliced.length;
// D4.1: 使用 Uint32Array 一维数组替代 number[][],节省内存 // F4-1: 使用 Uint16Array 一维数组替代 Uint32Array,节省一半内存
const lcs = new Uint32Array((sm + 1) * (sn + 1)); // LCS 长度最大值为 min(sm, sn) <= 5000,远小于 Uint16Array 上限 65535,安全
const lcs = new Uint16Array((sm + 1) * (sn + 1));
const idx = (i: number, j: number): number => i * (sn + 1) + j; const idx = (i: number, j: number): number => i * (sn + 1) + j;
for (let i = 1; i <= sm; i++) { for (let i = 1; i <= sm; i++) {
@@ -183,21 +187,24 @@ export class DiffViewerTool implements IMetonaTool {
return { success: false, error: 'file_a and file_b are required for files mode' }; return { success: false, error: 'file_a and file_b are required for files mode' };
} }
// 安全校验 // F4-1: 用 safeResolvePath 统一路径校验(含 isPathWithinWorkspace + MEMORY.md 拦截)
const pathA = resolve(context.workspacePath, fileA); let pathA: string;
const pathB = resolve(context.workspacePath, fileB); let pathB: string;
if (!isPathWithinWorkspace(fileA, context.workspacePath) || !isPathWithinWorkspace(fileB, context.workspacePath)) { try {
return { success: false, error: 'Path traversal detected' }; pathA = safeResolvePath(fileA, context.workspacePath);
} pathB = safeResolvePath(fileB, context.workspacePath);
if (isProtectedWorkspaceFile(fileA, context.workspacePath) || isProtectedWorkspaceFile(fileB, context.workspacePath)) { } catch (error) {
return { success: false, error: 'Access denied: MEMORY.md is protected' }; return { success: false, error: extractErrorMessage(error) };
} }
try { try {
contentA = await readFile(pathA, 'utf-8'); // F4-1: 用智能编码检测读取文件(支持 GBK/UTF-16 等非 UTF-8 编码)
contentB = await readFile(pathB, 'utf-8'); const bufferA = await readFile(pathA);
const bufferB = await readFile(pathB);
contentA = decodeBufferWithDetection(bufferA).content;
contentB = decodeBufferWithDetection(bufferB).content;
} catch (err) { } catch (err) {
return { success: false, error: `Failed to read files: ${(err as Error).message}` }; return { success: false, error: `Failed to read files: ${extractErrorMessage(err)}` };
} }
labelA = fileA; labelA = fileA;
labelB = fileB; labelB = fileB;
@@ -252,7 +259,8 @@ export class DiffViewerTool implements IMetonaTool {
diff_lines: diff.slice(0, 500), diff_lines: diff.slice(0, 500),
}; };
} catch (err) { } catch (err) {
return { success: false, error: (err as Error).message }; // F4-2: 统一错误处理,避免 non-Error 抛出值被 String 强转丢失信息
return { success: false, error: extractErrorMessage(err) };
} }
} }
} }
+122 -21
View File
@@ -33,7 +33,7 @@ export class FileEditorTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'file_editor', name: 'file_editor',
description: description:
'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range). More efficient than write_file for targeted edits. Supports dry_run mode for preview. File size limit: 10MB.', 'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range), find_replace (literal string replace, avoids regex ambiguity). More efficient than write_file for targeted edits. Supports dry_run mode for preview, multiline regex matching, and automatic .bak backup. File size limit: 10MB.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -41,14 +41,19 @@ export class FileEditorTool implements IMetonaTool {
operation: { operation: {
type: 'string', type: 'string',
description: 'Edit operation', description: 'Edit operation',
enum: ['replace', 'insert', 'delete', 'regex'], enum: ['replace', 'insert', 'delete', 'regex', 'find_replace'],
}, },
start_line: { type: 'number', description: 'Start line number (1-indexed). Used by replace/insert/delete/regex' }, start_line: { type: 'number', description: 'Start line number (1-indexed). Used by replace/insert/delete/regex' },
end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete' }, end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete/regex' },
content: { type: 'string', description: 'New content for replace/insert operations' }, content: { type: 'string', description: 'New content for replace/insert operations' },
pattern: { type: 'string', description: 'Regex pattern for regex operation' }, pattern: { type: 'string', description: 'Regex pattern for regex operation' },
replacement: { type: 'string', description: 'Replacement string for regex operation' }, replacement: { type: 'string', description: 'Replacement string for regex operation' },
flags: { type: 'string', description: 'Regex flags (default "g")' }, flags: { type: 'string', description: 'Regex flags (default "g"). Use "gm" for multiline, "gms" for multiline+dotall' },
multiline: { type: 'boolean', description: 'F2-6: Enable cross-line regex matching. When true, regex is applied to the joined content of the range (not line-by-line). Useful for replacing multi-line code blocks. Default false.' },
find: { type: 'string', description: 'F2-5: Literal string to find for find_replace operation (no regex interpretation)' },
replace: { type: 'string', description: 'F2-5: Replacement string for find_replace operation' },
replace_all: { type: 'boolean', description: 'F2-5: Replace all occurrences (true, default) or only the first (false). For find_replace operation.' },
backup: { type: 'boolean', description: 'F2-7: Save a .bak copy of the original file before editing (default false)' },
dry_run: { type: 'boolean', description: 'Preview mode: return the diff without writing to file (default false)' }, dry_run: { type: 'boolean', description: 'Preview mode: return the diff without writing to file (default false)' },
}, },
required: ['file_path', 'operation'], required: ['file_path', 'operation'],
@@ -66,8 +71,10 @@ export class FileEditorTool implements IMetonaTool {
return { success: false, error: 'file_path is required' }; return { success: false, error: 'file_path is required' };
} }
const filePath = safeResolvePath(args.file_path as string, context.workspacePath); const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex'; const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex' | 'find_replace';
const dryRun = (args.dry_run as boolean) ?? false; const dryRun = (args.dry_run as boolean) ?? false;
// F2-7: backup 参数 — 编辑前保存 .bak 文件
const backup = (args.backup as boolean) ?? false;
// 文件必须存在(不支持创建新文件,请用 write_file // 文件必须存在(不支持创建新文件,请用 write_file
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
@@ -143,6 +150,8 @@ export class FileEditorTool implements IMetonaTool {
const pattern = args.pattern as string; const pattern = args.pattern as string;
const replacement = (args.replacement as string) ?? ''; const replacement = (args.replacement as string) ?? '';
const flags = (args.flags as string) ?? 'g'; const flags = (args.flags as string) ?? 'g';
// F2-6: multiline 参数 — 支持跨行匹配
const multiline = (args.multiline as boolean) ?? false;
if (!pattern) { if (!pattern) {
return { success: false, error: 'Regex operation requires "pattern" parameter' }; return { success: false, error: 'Regex operation requires "pattern" parameter' };
} }
@@ -156,27 +165,42 @@ export class FileEditorTool implements IMetonaTool {
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` }; return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
} }
// F1-1 修复: 强制 regex 含 g 标志,保证 match 计数与 replace 结果一致
const effectiveFlags = flags.includes('g') ? flags : flags + 'g';
let regex: RegExp; let regex: RegExp;
try { try {
regex = new RegExp(pattern, flags); regex = new RegExp(pattern, effectiveFlags);
} catch (err) { } catch (err) {
return { success: false, error: `Invalid regex: ${(err as Error).message}` }; // F4-2: 统一用 extractErrorMessage 提取错误信息
return { success: false, error: `Invalid regex: ${extractErrorMessage(err)}` };
} }
// F1.4: 用带 g 标志的正则统计匹配数 if (multiline) {
const targetLines = lines.slice(startLine - 1, endLine); // F2-6: 跨行匹配 — 对整个目标内容做正则替换(支持多行代码块替换)
const countRegex = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g'); const targetContent = lines.slice(startLine - 1, endLine).join('\n');
const replacedLines = targetLines.map((line) => { const matches = targetContent.match(regex);
const matches = line.match(countRegex); if (matches) replaceCount = matches.length;
if (matches) replaceCount += matches.length; const replacedContent = targetContent.replace(regex, replacement);
return line.replace(regex, replacement); const replacedLines = replacedContent.split('\n');
}); newLines = [
...lines.slice(0, startLine - 1),
newLines = [ ...replacedLines,
...lines.slice(0, startLine - 1), ...lines.slice(endLine),
...replacedLines, ];
...lines.slice(endLine), } else {
]; // F1-1 修复: 用同一个 regex 实例做 match 计数 + replace 替换(逐行)
const targetLines = lines.slice(startLine - 1, endLine);
const replacedLines = targetLines.map((line) => {
const matches = line.match(regex);
if (matches) replaceCount += matches.length;
return line.replace(regex, replacement);
});
newLines = [
...lines.slice(0, startLine - 1),
...replacedLines,
...lines.slice(endLine),
];
}
affectedRange = { start: startLine, end: endLine }; affectedRange = { start: startLine, end: endLine };
// 如果没有替换,返回提示 // 如果没有替换,返回提示
@@ -195,6 +219,56 @@ export class FileEditorTool implements IMetonaTool {
break; break;
} }
case 'find_replace': {
// F2-5: 字面量字符串替换 — 不解析正则元字符,避免歧义
// 适用于精准替换代码片段(如函数名、配置值)
const find = args.find as string;
const replaceStr = (args.replace as string) ?? '';
const replaceAll = (args.replace_all as boolean) ?? true;
if (find === undefined || find === null) {
return { success: false, error: 'find_replace operation requires "find" parameter' };
}
if (find.length === 0) {
return { success: false, error: '"find" must not be empty' };
}
if (replaceAll) {
// 全局替换:用 split + join 统计匹配次数并替换
const parts = originalContent.split(find);
replaceCount = parts.length - 1;
const newContent = parts.join(replaceStr);
newLines = newContent.split('\n');
} else {
// 只替换第一个匹配
const idx = originalContent.indexOf(find);
if (idx >= 0) {
replaceCount = 1;
const newContent = originalContent.slice(0, idx) + replaceStr + originalContent.slice(idx + find.length);
newLines = newContent.split('\n');
} else {
replaceCount = 0;
newLines = lines;
}
}
// find_replace 作用于整个文件,affectedRange 覆盖全部行
affectedRange = { start: 1, end: lines.length };
if (replaceCount === 0) {
return {
success: true,
operation,
file_path: args.file_path,
message: 'No matches found for find pattern',
replacements: 0,
affected_range: affectedRange,
dry_run: dryRun,
};
}
break;
}
default: default:
return { success: false, error: `Unknown operation: ${operation}` }; return { success: false, error: `Unknown operation: ${operation}` };
} }
@@ -214,6 +288,24 @@ export class FileEditorTool implements IMetonaTool {
affectedRange.start - 1, affectedRange.start - 1,
Math.min(affectedRange.end, newLines.length), Math.min(affectedRange.end, newLines.length),
).join('\n'); ).join('\n');
} else if (operation === 'find_replace') {
// F2-5: find_replace 的 dry_run — 只预览第一个匹配附近的内容
// 避免大文件预览整个文件(affectedRange 覆盖全部行)
const firstMatchIdx = originalContent.indexOf(args.find as string);
if (firstMatchIdx >= 0) {
const beforeMatch = originalContent.slice(0, firstMatchIdx);
const matchLine = beforeMatch.split('\n').length;
const contextRadius = 3; // 前后各 3 行
const previewStart = Math.max(1, matchLine - contextRadius);
const previewEnd = Math.min(lines.length, matchLine + contextRadius);
originalPreview = lines.slice(previewStart - 1, previewEnd).join('\n');
const lineDelta = newLines.length - lines.length;
const modifiedEnd = Math.min(newLines.length, previewEnd + lineDelta);
modifiedPreview = newLines.slice(previewStart - 1, Math.max(previewStart - 1, modifiedEnd)).join('\n');
} else {
originalPreview = '';
modifiedPreview = '';
}
} else { } else {
// replace/delete/regex: 原文件取 [start-1, end) 区间 // replace/delete/regex: 原文件取 [start-1, end) 区间
originalPreview = lines.slice( originalPreview = lines.slice(
@@ -254,6 +346,14 @@ export class FileEditorTool implements IMetonaTool {
await mkdir(parentDir, { recursive: true }); await mkdir(parentDir, { recursive: true });
} }
// F2-7: backup 模式 — 编辑前保存 .bak 文件
// 注意:备份在写入前进行,确保即使写入失败也有原始备份
let backupPath: string | null = null;
if (backup) {
backupPath = `${filePath}.bak`;
await writeFile(backupPath, originalContent, 'utf-8');
}
const newContent = newLines.join('\n'); const newContent = newLines.join('\n');
// F1.7: 原子写入 - 临时文件 + rename // F1.7: 原子写入 - 临时文件 + rename
const tmpPath = `${filePath}.tmp_${Date.now()}`; const tmpPath = `${filePath}.tmp_${Date.now()}`;
@@ -277,6 +377,7 @@ export class FileEditorTool implements IMetonaTool {
lines_before: lines.length, lines_before: lines.length,
lines_after: newLines.length, lines_after: newLines.length,
bytes_written: Buffer.byteLength(newContent, 'utf-8'), bytes_written: Buffer.byteLength(newContent, 'utf-8'),
backup_path: backupPath, // F2-7: 备份文件路径(null 表示未备份)
}; };
} catch (err) { } catch (err) {
return { success: false, error: extractErrorMessage(err) }; return { success: false, error: extractErrorMessage(err) };
+80 -3
View File
@@ -149,16 +149,93 @@ export function matchGlob(name: string, glob: string): boolean {
return new RegExp(`^${pattern}$`, 'i').test(name); return new RegExp(`^${pattern}$`, 'i').test(name);
} }
/**
* F2-4: 多 glob 匹配(逗号分隔)
*
* 支持 "*.ts,*.js,*.tsx" 形式的多 glob 匹配,任一匹配即通过。
* 单个 glob 时等价于 matchGlob。空字符串或空白字符串视为匹配所有。
*
* @param name 待匹配的文件名
* @param globStr 通配符模式字符串(支持逗号分隔多 glob)
* @returns 是否匹配任一 glob
*/
export function matchAnyGlob(name: string, globStr: string): boolean {
// 按逗号分割,去除空白,过滤空字符串
const globs = globStr.split(',').map((g) => g.trim()).filter((g) => g.length > 0);
if (globs.length === 0) return true; // 空字符串视为匹配所有
for (const g of globs) {
if (matchGlob(name, g)) return true;
}
return false;
}
/** /**
* 共享错误提取 * 共享错误提取
* *
* 统一从 unknown 错误对象中提取 message 字符串 * 统一从 unknown 错误对象中提取 message 字符串,可选附加 stderr 信息
*
* F4-2: 支持 optional stderr 参数,用于 child_process 错误(git/command
* *
* @param error catch 块中的 unknown 错误 * @param error catch 块中的 unknown 错误
* @param includeStderr 是否尝试从 error.stderr 提取 stderr 信息(默认 false
* @returns 错误消息字符串 * @returns 错误消息字符串
*/ */
export function extractErrorMessage(error: unknown): string { export function extractErrorMessage(error: unknown, includeStderr = false): string {
return error instanceof Error ? error.message : String(error); if (error instanceof Error) {
if (includeStderr) {
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
return stderr ? `${error.message}\n${stderr}` : error.message;
}
return error.message;
}
return String(error);
}
/**
* F2-1: 智能文件编码检测与解码
*
* 支持 BOM 检测(UTF-8 / UTF-16 LE / UTF-16 BE)和无 BOM 时的编码推断
* UTF-8 strict → GBK → UTF-8 loose 三级降级)。
*
* 解决 Windows 中文环境 GBK 文件读取乱码问题,以及 UTF-16 文件读取问题。
*
* @param buffer 文件/命令输出的原始字节
* @returns 解码后的文本和检测到的编码名(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose
*/
export function decodeBufferWithDetection(buffer: Buffer): { content: string; encoding: string } {
if (buffer.length === 0) {
return { content: '', encoding: 'utf-8' };
}
// BOM 检测
// UTF-8 BOM: EF BB BF
if (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
return { content: buffer.slice(3).toString('utf-8'), encoding: 'utf-8-bom' };
}
// UTF-16 LE BOM: FF FE
if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) {
return { content: buffer.slice(2).toString('utf16le'), encoding: 'utf-16le' };
}
// UTF-16 BE BOM: FE FF
if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) {
const body = buffer.slice(2);
// 偶数长度保护(UTF-16 每字符 2 字节)
const safe = body.length % 2 === 0 ? body : body.slice(0, body.length - 1);
const swapped = Buffer.from(safe); // 复制避免修改原 buffer
swapped.swap16(); // BE → LE 字节交换
return { content: swapped.toString('utf16le'), encoding: 'utf-16be' };
}
// 无 BOMUTF-8 strict → GBK → UTF-8 loose 三级降级
try {
return { content: new TextDecoder('utf-8', { fatal: true }).decode(buffer), encoding: 'utf-8' };
} catch {
try {
return { content: new TextDecoder('gbk').decode(buffer), encoding: 'gbk' };
} catch {
return { content: buffer.toString('utf-8'), encoding: 'utf-8-loose' };
}
}
} }
/** /**
+285 -15
View File
@@ -22,7 +22,7 @@
* @see standard/开发规范.md — 使用 fs/path 内置模块 * @see standard/开发规范.md — 使用 fs/path 内置模块
*/ */
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises'; import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises';
import { join, relative, resolve, dirname } from 'path'; import { join, relative, resolve, dirname } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
@@ -33,7 +33,9 @@ import {
isPathWithinWorkspace, isPathWithinWorkspace,
safeResolvePath, safeResolvePath,
matchGlob, matchGlob,
matchAnyGlob,
extractErrorMessage, extractErrorMessage,
decodeBufferWithDetection,
MAX_FILE_SIZE_BYTES, MAX_FILE_SIZE_BYTES,
FILE_TOOL_TIMEOUT_MS, FILE_TOOL_TIMEOUT_MS,
MAX_LINE_LENGTH, MAX_LINE_LENGTH,
@@ -83,13 +85,14 @@ export class ReadFileTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'read_file', name: 'read_file',
description: description:
'Read the contents of a text file. Returns lines with offset/limit for large files. Auto-detects and rejects binary files (suggest view_image for images). File size limit: 10MB. Lines longer than 10000 chars are truncated.', 'Read the contents of a text file. Returns lines with offset/limit for large files. Auto-detects and rejects binary files (suggest view_image for images). File size limit: 10MB. Lines longer than 10000 chars are truncated. Smart encoding detection: supports UTF-8/UTF-8-BOM/UTF-16LE/UTF-16BE (BOM) and GBK/CP936 (fallback for Windows Chinese files). Returns the detected encoding in the response. Supports tail mode to read the last N lines (useful for logs).',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
file_path: { type: 'string', description: 'Absolute or relative path to the file to read' }, file_path: { type: 'string', description: 'Absolute or relative path to the file to read' },
offset: { type: 'number', description: 'Start line number (1-indexed, default 1)' }, offset: { type: 'number', description: 'Start line number (1-indexed, default 1). Ignored if tail is specified.' },
limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000)' }, limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000). Ignored if tail is specified.' },
tail: { type: 'number', description: 'Read the last N lines from the file. Takes precedence over offset/limit. Useful for reading log tails. Max 2000.' },
}, },
required: ['file_path'], required: ['file_path'],
}, },
@@ -104,6 +107,10 @@ export class ReadFileTool implements IMetonaTool {
const filePath = safeResolvePath(args.file_path as string, context.workspacePath); const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
const offset = Math.max(1, (args.offset as number) ?? 1); const offset = Math.max(1, (args.offset as number) ?? 1);
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500)); const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
// F2-2: tail 模式 — 从文件末尾读取 N 行(优先于 offset/limit
const tail = args.tail !== undefined
? Math.min(2000, Math.max(1, Math.floor(args.tail as number)))
: undefined;
// v0.3.2: 文件存在性 + 大小预检(先 stat 再决定是否读取,避免大文件 OOM) // v0.3.2: 文件存在性 + 大小预检(先 stat 再决定是否读取,避免大文件 OOM)
let stats; let stats;
@@ -135,9 +142,27 @@ export class ReadFileTool implements IMetonaTool {
}; };
} }
const content = await readFile(filePath, 'utf-8'); // F2-1: 智能编码检测 — 读取原始 Buffer 后用 BOM 检测 + UTF-8/GBK 降级
// 解决 Windows 中文环境 GBK 文件读取乱码,以及 UTF-16 文件读取问题
const buffer = await readFile(filePath);
const { content, encoding } = decodeBufferWithDetection(buffer);
const lines = content.split('\n'); const lines = content.split('\n');
const slicedLines = lines.slice(offset - 1, offset - 1 + limit);
// F2-2: 根据 tail 参数选择切片模式
let slicedLines: string[];
let truncated: boolean;
let startLine: number;
if (tail !== undefined) {
// tail 模式:从末尾取 tail 行
slicedLines = lines.slice(-tail);
truncated = lines.length > tail;
startLine = Math.max(1, lines.length - tail + 1);
} else {
// offset/limit 模式
slicedLines = lines.slice(offset - 1, offset - 1 + limit);
truncated = lines.length > offset - 1 + limit;
startLine = offset;
}
// v0.3.2: 超长行截断 // v0.3.2: 超长行截断
const processedLines = slicedLines.map((line) => truncateLine(line)); const processedLines = slicedLines.map((line) => truncateLine(line));
@@ -147,9 +172,12 @@ export class ReadFileTool implements IMetonaTool {
content: processedLines.map((l) => l.text).join('\n'), content: processedLines.map((l) => l.text).join('\n'),
total_lines: lines.length, total_lines: lines.length,
returned_lines: slicedLines.length, returned_lines: slicedLines.length,
truncated: lines.length > offset - 1 + limit, truncated,
start_line: startLine, // F2-2: 返回实际起始行号
lines_truncated: truncatedLines, lines_truncated: truncatedLines,
file_size: stats.size, file_size: stats.size,
encoding, // F2-1: 检测到的编码(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose
mode: tail !== undefined ? 'tail' : 'offset', // F2-2: 读取模式
success: true, success: true,
}; };
} catch (error) { } catch (error) {
@@ -207,13 +235,41 @@ export class WriteFileTool implements IMetonaTool {
} }
if (mode === 'append') { if (mode === 'append') {
// append 模式:直接 appendFileappend 本身是原子的) // F1-4: append 模式增强 — 显式 open(O_APPEND) + write + fsync + close
await appendFile(filePath, content, 'utf-8'); // 修复 v0.3.2 注释错误:"append 本身是原子的"不准确
// 实际:fs.appendFile 内部是 open(O_APPEND) + write + close,存在:
// - 无 fsyncwrite 后数据在 page cache,崩溃可能丢失
// - 大内容(> 4KB)非完全原子:可能被分成多个 write
// 改进:显式 open + 循环 write + fsync + close,保证数据持久化到磁盘
// 保持 append 语义(O_APPEND 内核级追加),不改变并发行为
const fileExisted = existsSync(filePath);
const oldSize = fileExisted ? (await stat(filePath)).size : 0;
let fd: FileHandle | null = null;
try {
fd = await open(filePath, 'a');
// 'a' 模式下 write 追加到末尾(O_APPEND 内核级保证)
// 循环写入确保大内容完整写入(单次 write 可能不完整)
const buffer = Buffer.from(content, 'utf-8');
let totalWritten = 0;
while (totalWritten < buffer.length) {
const { bytesWritten } = await fd.write(buffer, totalWritten, buffer.length - totalWritten);
totalWritten += bytesWritten;
}
await fd.sync(); // fsync 保证数据持久化到磁盘
} finally {
if (fd) {
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
}
}
const newStats = await stat(filePath); const newStats = await stat(filePath);
return { return {
bytes_written: contentBytes, bytes_written: contentBytes,
path: filePath, path: filePath,
mode: 'append', mode: 'append',
created: !fileExisted,
old_size: oldSize,
new_file_size: newStats.size, new_file_size: newStats.size,
success: true, success: true,
}; };
@@ -256,7 +312,7 @@ export class ListDirectoryTool implements IMetonaTool {
properties: { properties: {
dir_path: { type: 'string', description: 'Directory path (default: workspace root)' }, dir_path: { type: 'string', description: 'Directory path (default: workspace root)' },
depth: { type: 'number', description: 'Recursive depth (default 1, max 5)' }, depth: { type: 'number', description: 'Recursive depth (default 1, max 5)' },
glob: { type: 'string', description: 'Filename filter pattern (e.g., "*.ts")' }, glob: { type: 'string', description: 'Filename filter pattern. Supports comma-separated multi-glob (e.g., "*.ts" or "*.ts,*.js,*.tsx")' },
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' }, include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
}, },
}, },
@@ -324,8 +380,8 @@ export class ListDirectoryTool implements IMetonaTool {
await this.listDir(rootPath, fullPath, maxDepth, glob, includeHidden, currentDepth + 1, results, maxEntries); await this.listDir(rootPath, fullPath, maxDepth, glob, includeHidden, currentDepth + 1, results, maxEntries);
} }
} else { } else {
// glob 过滤仅适用于文件 // F2-4: glob 过滤仅适用于文件,支持多 glob(逗号分隔,如 "*.ts,*.js"
if (glob && !matchGlob(entry.name, glob)) continue; if (glob && !matchAnyGlob(entry.name, glob)) continue;
const stats = await stat(fullPath); const stats = await stat(fullPath);
// v0.3.2: 添加 modified timeISO 字符串) // v0.3.2: 添加 modified timeISO 字符串)
results.push({ results.push({
@@ -356,7 +412,7 @@ export class SearchFilesTool implements IMetonaTool {
pattern: { type: 'string', description: 'Search pattern (regex for content, glob for filenames)' }, pattern: { type: 'string', description: 'Search pattern (regex for content, glob for filenames)' },
target: { type: 'string', description: '"content" (default) to search file contents, "files" to search filenames', enum: ['content', 'files'] }, target: { type: 'string', description: '"content" (default) to search file contents, "files" to search filenames', enum: ['content', 'files'] },
path: { type: 'string', description: 'Search directory (default: workspace root)' }, path: { type: 'string', description: 'Search directory (default: workspace root)' },
file_glob: { type: 'string', description: 'Limit to specific file types (e.g., "*.py")' }, file_glob: { type: 'string', description: 'Limit to specific file types. Supports comma-separated multi-glob (e.g., "*.py" or "*.ts,*.js,*.tsx")' },
limit: { type: 'number', description: 'Maximum results (default 50, max 200)' }, limit: { type: 'number', description: 'Maximum results (default 50, max 200)' },
context_lines: { type: 'number', description: 'Lines of context to show around content matches (default 0, max 5). Only for target="content".' }, context_lines: { type: 'number', description: 'Lines of context to show around content matches (default 0, max 5). Only for target="content".' },
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' }, include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
@@ -443,7 +499,13 @@ export class SearchFilesTool implements IMetonaTool {
const fileStats = await stat(filePath); const fileStats = await stat(filePath);
if (fileStats.size > MAX_FILE_SIZE_BYTES) return; if (fileStats.size > MAX_FILE_SIZE_BYTES) return;
const content = await readFile(filePath, 'utf-8'); // F2-3: 跳过二进制文件(避免读取乱码 + 提升性能)
// 空文件不算二进制,直接放行(size=0 时 isBinaryFile 内部 bytesRead=0 返回 false
if (fileStats.size > 0 && await isBinaryFile(filePath)) return;
// F2-3: 用智能编码检测读取文件(支持 GBK 等非 UTF-8 编码)
const buffer = await readFile(filePath);
const { content } = decodeBufferWithDetection(buffer);
const lines = content.split('\n'); const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
if (results.length >= limit) break; if (results.length >= limit) break;
@@ -492,7 +554,8 @@ export class SearchFilesTool implements IMetonaTool {
if (entry.isDirectory()) { if (entry.isDirectory()) {
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath); await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath);
} else { } else {
if (fileGlob && !matchGlob(entry.name, fileGlob)) continue; // F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx"
if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue;
await callback(fullPath, entry.name); await callback(fullPath, entry.name);
} }
} }
@@ -613,3 +676,210 @@ export class DeleteFileTool implements IMetonaTool {
} }
} }
} }
// ===== 6. file_move =====
/**
* F3-1: 文件移动/重命名工具
*
* 安全策略:
* 1. 源路径和目标路径都必须在工作空间内(双重 safeResolvePath 校验)
* 2. 受保护文件(MEMORY.md)禁止移动(safeResolvePath 内置拦截)
* 3. 自动创建目标父目录
* 4. 支持覆盖已存在文件(overwrite 参数)
* 5. 禁止移动工作空间根目录
* 6. rename 在同文件系统上是原子操作
*/
export class FileMoveTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'file_move',
description:
'Move or rename a file or directory. Source and destination must be within workspace. Auto-creates destination parent directory. Use overwrite: true to replace existing destination. Atomic on same filesystem (uses rename).',
parameters: {
type: 'object',
properties: {
source_path: { type: 'string', description: 'Path to the file/directory to move' },
destination_path: { type: 'string', description: 'Destination path' },
overwrite: { type: 'boolean', description: 'Overwrite if destination exists (default false)' },
},
required: ['source_path', 'destination_path'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: FILE_TOOL_TIMEOUT_MS,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
try {
const sourcePath = args.source_path as string;
const destinationPath = args.destination_path as string;
const overwrite = (args.overwrite as boolean) ?? false;
if (!sourcePath || !destinationPath) {
return { error: 'source_path and destination_path are required', success: false };
}
// F3-1: 双路径校验(源和目标都必须在工作空间内 + 非 MEMORY.md
let resolvedSource: string;
let resolvedDest: string;
try {
resolvedSource = safeResolvePath(sourcePath, context.workspacePath);
resolvedDest = safeResolvePath(destinationPath, context.workspacePath);
} catch (error) {
return { error: extractErrorMessage(error), success: false };
}
// 禁止移动工作空间根目录
const workspaceRoot = resolve(context.workspacePath);
if (resolvedSource === workspaceRoot) {
return {
error: 'Cannot move workspace root directory',
source_path: sourcePath,
success: false,
};
}
// 源必须存在
if (!existsSync(resolvedSource)) {
return { error: 'Source not found', path: sourcePath, success: false };
}
// 目标已存在处理
let overwritten = false;
if (existsSync(resolvedDest)) {
if (!overwrite) {
return {
error: 'Destination already exists. Use overwrite: true to replace.',
destination_path: destinationPath,
success: false,
};
}
// overwrite: true — 删除目标
const destStats = await stat(resolvedDest);
if (destStats.isDirectory()) {
await rm(resolvedDest, { recursive: true, force: false });
} else {
await unlink(resolvedDest);
}
overwritten = true;
}
// 自动创建目标父目录
const destParentDir = dirname(resolvedDest);
if (!existsSync(destParentDir)) {
await mkdir(destParentDir, { recursive: true });
}
// 执行移动(rename 在同文件系统上是原子操作)
await rename(resolvedSource, resolvedDest);
const stats = await stat(resolvedDest);
return {
source: sourcePath,
destination: destinationPath,
isDirectory: stats.isDirectory(),
overwritten,
success: true,
};
} catch (error) {
return { error: extractErrorMessage(error), success: false };
}
}
}
// ===== 7. file_info =====
/**
* F3-2: 文件信息查询工具
*
* 返回文件的元信息:大小、时间戳、类型、编码检测、权限位。
* 只读操作,风险等级 SAFE。
*
* 编码检测只读取前 8KB,避免大文件 OOM。
*/
export class FileInfoTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'file_info',
description:
'Get detailed file information: size, timestamps (created/modified/accessed), type (file/directory/symlink), encoding detection (UTF-8/UTF-16/GBK), binary check, and permission bits.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file or directory' },
},
required: ['file_path'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: FILE_TOOL_TIMEOUT_MS,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
try {
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
if (!existsSync(filePath)) {
return { error: 'File not found', path: args.file_path, success: false };
}
const stats = await stat(filePath);
const isDir = stats.isDirectory();
const isFile = stats.isFile();
const isSymlink = stats.isSymbolicLink();
const info: Record<string, unknown> = {
path: args.file_path,
absolute_path: filePath,
type: isDir ? 'directory' : isFile ? 'file' : isSymlink ? 'symlink' : 'unknown',
size: stats.size,
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
accessed: stats.atime.toISOString(),
mode: stats.mode.toString(8), // 八进制权限位
success: true,
};
// F3-2: 文件类型额外信息(编码 + 二进制检测)
// 只读前 8KB,避免大文件 OOM
if (isFile) {
let fd: FileHandle | null = null;
try {
fd = await open(filePath, 'r');
const buffer = Buffer.alloc(8192);
const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
const actualBuffer = buffer.slice(0, bytesRead);
// 二进制检测(NULL 字节检查)
let isBinary = false;
for (let i = 0; i < bytesRead; i++) {
if (actualBuffer[i] === 0) {
isBinary = true;
break;
}
}
info.is_binary = isBinary;
// 编码检测(仅非二进制文件)
if (!isBinary && bytesRead > 0) {
const { encoding } = decodeBufferWithDetection(actualBuffer);
info.encoding = encoding;
} else if (bytesRead === 0) {
info.encoding = 'utf-8'; // 空文件默认 UTF-8
}
} catch {
// 读取失败,不报告编码信息
} finally {
if (fd) {
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
}
}
}
return info;
} catch (error) {
return { error: extractErrorMessage(error), success: false };
}
}
}
+23 -8
View File
@@ -340,11 +340,11 @@ export class GitLogTool implements IMetonaTool {
export class GitCommitTool implements IMetonaTool { export class GitCommitTool implements IMetonaTool {
readonly definition: MetonaToolDef = { readonly definition: MetonaToolDef = {
name: 'git_commit', name: 'git_commit',
description: 'Stage files and create a git commit. Supports amending the previous commit. Requires user confirmation.', description: 'Stage files and create a git commit. Supports amending the previous commit. In amend mode, message is optional (omitting it keeps the original commit message). Requires user confirmation.',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
message: { type: 'string', description: 'Commit message' }, message: { type: 'string', description: 'Commit message. Required for new commits. Optional in amend mode (omitting keeps original message).' },
files: { files: {
type: 'array', type: 'array',
items: { type: 'string', description: 'File path to stage' }, items: { type: 'string', description: 'File path to stage' },
@@ -352,7 +352,8 @@ export class GitCommitTool implements IMetonaTool {
}, },
amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' }, amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' },
}, },
required: ['message'], // F1-3: message 不再硬性 required — amend 模式下可省略(保留原 message)
// 校验在 execute 内根据 amend 标志动态进行
}, },
category: MetonaToolCategory.FILESYSTEM, category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.MEDIUM, riskLevel: MetonaRiskLevel.MEDIUM,
@@ -361,12 +362,17 @@ export class GitCommitTool implements IMetonaTool {
}; };
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> { async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const message = args.message as string; const message = args.message as string | undefined;
const files = (args.files as string[] | undefined) ?? []; const files = (args.files as string[] | undefined) ?? [];
const amend = (args.amend as boolean) ?? false; const amend = (args.amend as boolean) ?? false;
if (!message || message.trim().length === 0) { // F1-3: 非 amend 模式必须有 messageamend 模式可省略(保留原 message)
return { success: false, error: 'Commit message is required' }; if (!amend && (!message || message.trim().length === 0)) {
return { success: false, error: 'Commit message is required (use amend: true to reuse the previous message)' };
}
// amend 模式下若提供 message,需校验非空字符串
if (amend && message !== undefined && message.trim().length === 0) {
return { success: false, error: 'Commit message must not be empty (or omit message field to keep original)' };
} }
try { try {
@@ -393,9 +399,16 @@ export class GitCommitTool implements IMetonaTool {
const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length; const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length;
// 3. 提交(commit 可能触发 hooks,给予更长超时) // 3. 提交(commit 可能触发 hooks,给予更长超时)
// F1-3: amend 模式下若未提供 message,不加 -m 参数(保留原 message
const commitArgs = ['commit']; const commitArgs = ['commit'];
if (amend) commitArgs.push('--amend'); if (amend) commitArgs.push('--amend');
commitArgs.push('-m', message); if (message && message.trim().length > 0) {
commitArgs.push('-m', message);
} else if (amend) {
// amend + 无 message + 无 --no-edit → git 会打开编辑器要求输入
// 加 --no-edit 明确表示保留原 message(防止 hang 等待编辑器)
commitArgs.push('--no-edit');
}
await runGit(commitArgs, context.workspacePath, 20_000); await runGit(commitArgs, context.workspacePath, 20_000);
// 4. 获取提交 hash 和当前分支 // 4. 获取提交 hash 和当前分支
@@ -408,8 +421,10 @@ export class GitCommitTool implements IMetonaTool {
success: true, success: true,
commit, commit,
branch, branch,
message, // F1-3: amend 模式下若未提供 message,返回值为空字符串(实际 message 保留原值)
message: message ?? (amend ? '(amended - original message preserved)' : ''),
filesChanged, filesChanged,
amended: amend,
}; };
} catch (error) { } catch (error) {
if (isNotGitRepoError(error)) { if (isNotGitRepoError(error)) {
+5 -2
View File
@@ -1,6 +1,9 @@
/** /**
* 内置工具导出 * 内置工具导出
* *
* v0.3.3: 从 27 个工具扩展到 29 个工具
* 新增:file_move, file_info
*
* v0.3.2: 从 26 个工具扩展到 27 个工具 * v0.3.2: 从 26 个工具扩展到 27 个工具
* 新增:delete_file * 新增:delete_file
* *
@@ -14,8 +17,8 @@
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
*/ */
// 文件系统工具(5 个) // 文件系统工具(7 个)
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, DeleteFileTool } from './filesystem'; export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, DeleteFileTool, FileMoveTool, FileInfoTool } from './filesystem';
// v0.2.0: 精准文件编辑工具 // v0.2.0: 精准文件编辑工具
export { FileEditorTool } from './file-editor'; export { FileEditorTool } from './file-editor';
// v0.2.0: ripgrep 代码搜索工具 // v0.2.0: ripgrep 代码搜索工具
+282 -3
View File
@@ -163,10 +163,73 @@ export function registerAllIPCHandlers(
} }
// 监听 Agent Loop 事件 // 监听 Agent Loop 事件
const onStreamEvent = (event: MetonaStreamEvent) => { // F8: 主进程 text_delta 节流合并
if (!mainWindow.isDestroyed()) { // 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,
mainWindow.webContents.send('agent:streamEvent', event); // 每次 IPC 调用都有跨进程开销,叠加渲染进程处理成本。
// 方案:在主进程聚合 text_delta,32ms 间隔合并转发(IPC 频率降至 ~30 次/秒)。
// 非 text_delta 事件立即转发(先 flush 缓冲区保证顺序)。
// done/error 时立即 flush,避免最后一段 delta 丢失。
// 与 F5 渲染进程 rAF 批处理叠加,总延迟约 48ms(人眼不敏感)。
let textDeltaBuffer = '';
let lastTextEventMeta: Pick<MetonaStreamEvent, 'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'> | null = null;
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const flushTextDeltaBuffer = () => {
flushTimer = null;
if (!textDeltaBuffer || !lastTextEventMeta || mainWindow.isDestroyed()) {
textDeltaBuffer = '';
lastTextEventMeta = null;
return;
} }
// 构建合并的 text_delta 事件,保留最后一个 delta 的元数据
const mergedEvent: MetonaStreamEvent = {
...lastTextEventMeta,
type: MetonaStreamEventType.TEXT_DELTA,
delta: textDeltaBuffer,
seq: lastTextEventMeta.seq, // 使用最后一个 delta 的 seq
timestamp: Date.now(),
};
mainWindow.webContents.send('agent:streamEvent', mergedEvent);
textDeltaBuffer = '';
lastTextEventMeta = null;
};
const onStreamEvent = (event: MetonaStreamEvent) => {
if (mainWindow.isDestroyed()) return;
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) {
if (textDeltaBuffer === '') {
lastTextEventMeta = {
requestId: event.requestId,
sessionId: event.sessionId,
iteration: event.iteration,
seq: event.seq,
timestamp: event.timestamp,
runId: event.runId,
};
} else {
// 更新 seq 为最新 delta 的 seq(保证前端 seq 连续性判断)
lastTextEventMeta = {
...lastTextEventMeta!,
seq: event.seq,
timestamp: event.timestamp,
runId: event.runId,
};
}
textDeltaBuffer += event.delta;
if (flushTimer === null) {
flushTimer = setTimeout(flushTextDeltaBuffer, 32);
}
return;
}
// 非 text_delta 事件:先 flush 缓冲区,再立即转发(保证事件顺序)
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTextDeltaBuffer();
}
mainWindow.webContents.send('agent:streamEvent', event);
}; };
const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string; runId?: string }) => { const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string; runId?: string }) => {
@@ -413,6 +476,11 @@ export function registerAllIPCHandlers(
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
} finally { } finally {
// F8: 注销前 flush 残留的 text_delta 缓冲区,避免丢失最后一段内容
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTextDeltaBuffer();
}
agentLoop.off('streamEvent', onStreamEvent); agentLoop.off('streamEvent', onStreamEvent);
agentLoop.off('stateChange', onStateChange); agentLoop.off('stateChange', onStateChange);
agentLoop.off('compressed', onCompressed); agentLoop.off('compressed', onCompressed);
@@ -756,6 +824,169 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Workspace path saved (restart required): ${value}`); log.info(`[CONFIG] Workspace path saved (restart required): ${value}`);
} catch (err) { } catch (err) {
log.error('[CONFIG] Failed to save workspace path:', err); log.error('[CONFIG] Failed to save workspace path:', err);
// v0.3.10: 工作空间路径写入失败必须告知用户,否则下次启动仍使用旧路径
// 用户看到"配置已保存"却实际未生效,会误以为配置系统故障
return { success: false, error: `工作空间路径保存失败:${(err as Error).message}` };
}
}
return { success: true };
});
// ===== v0.3.9: 批量保存配置(解决串行保存中间态触发 reloadAdapter 失败问题)=====
// 设计原因:前端设置页一次保存 8 个字段,串行 config:set 会在中间态(如 provider
// 已改但 apiKey 还没保存)触发 reloadAdapter,导致返回"LLM 配置不完整"错误。
// 批量保存:先写入所有字段,最后统一触发一次 reloadAdapter 和 Engine/Orchestrator 同步。
ipcMain.handle('config:setBatch', async (_event, entries: unknown) => {
// 参数校验:必须是 {key, value}[] 非空数组
if (!Array.isArray(entries) || entries.length === 0) {
return { success: false, error: 'Invalid entries: must be non-empty array of {key, value}' };
}
// 逐条校验每个元素的结构和类型
for (const entry of entries) {
if (!entry || typeof entry !== 'object') {
return { success: false, error: 'Invalid entry: must be {key, value} object' };
}
const e = entry as { key?: unknown; value?: unknown };
if (typeof e.key !== 'string' || !e.key.trim()) {
return { success: false, error: 'Invalid config key in batch' };
}
const v = e.value;
if (v !== null && typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') {
return { success: false, error: `Invalid config value for key "${e.key}": must be string, number, boolean, or null` };
}
}
// 敏感字段脱敏工具(与 config:set 保持一致)
const SENSITIVE_KEY_PATTERNS = ['apikey', 'api_key', 'apitoken', 'token', 'secret', 'password'];
const maskSensitive = (key: string, value: unknown): unknown => {
const isSensitive = SENSITIVE_KEY_PATTERNS.some(p => key.toLowerCase().includes(p));
if (isSensitive && typeof value === 'string' && value.length > 0) {
return value.length > 4 ? '***' + value.slice(-4) : '***';
}
return value;
};
// LLM 相关字段集合(用于判断是否需要 reloadAdapter
const LLM_KEYS = ['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx',
'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow'];
// 第一步:逐条写入 configService + 审计日志
// 不在此处触发 reloadAdapter,避免中间态失败
let needsReloadAdapter = false;
const engineUpdates: { key: string; value: unknown }[] = [];
let workspacePathValue: string | null = null;
let logLevelValue: 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null = null;
// v0.3.10 防御性修复: Provider 切换时清空 API key 的逻辑必须在循环前执行,
// 不能依赖 entries 中 llm.provider 出现在 llm.apiKey 之前。
// 旧实现:若前端误把 llm.apiKey 放在 llm.provider 之前,会先 set apiKey
// 随后处理 llm.provider 时清空 apiKey,导致用户填写的 apiKey 丢失。
// 新实现:先扫描 entries 找出 llm.provider 的值,与当前值比较,若变化则清空 apiKey,
// 然后循环按 entries 顺序 set(包括 llm.apiKey),最终值正确。
const providerEntry = (entries as Array<{ key: string; value: unknown }>)
.find((e) => e.key === 'llm.provider');
if (providerEntry) {
const oldProvider = configService.get<string>('llm.provider') ?? '';
const newProvider = (providerEntry.value as string) ?? '';
if (oldProvider && newProvider && oldProvider !== newProvider) {
configService.set('llm.apiKey', '');
log.info(`[CONFIG] Provider changed (${oldProvider}${newProvider}), API key cleared (batch mode)`);
}
}
for (const entry of entries) {
const { key, value } = entry as { key: string; value: unknown };
configService.set(key, value);
needsReloadAdapter = needsReloadAdapter || LLM_KEYS.includes(key);
// 收集 Engine/Orchestrator 相关更新(稍后统一应用)
if (key === 'agent.maxIterations' || key === 'agent.totalTimeoutMs' ||
key === 'agent.enableThinking' || key === 'agent.thinkingEffort' ||
key === 'ollama.numCtx' || key === 'deepseek.contextWindow' ||
key === 'agnes.contextWindow' || key === 'mimo.contextWindow' ||
key === 'agent.toolExecutionTimeoutMs' || key === 'agent.confirmationTimeoutMs') {
engineUpdates.push({ key, value });
}
if (key === 'workspace.path' && typeof value === 'string') {
workspacePathValue = value;
}
if (key === 'logging.level' && typeof value === 'string') {
logLevelValue = value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
}
// 审计日志(脱敏)
auditService.log({
sessionId: '',
eventType: 'config_change',
actor: 'user',
target: key,
details: { value: maskSensitive(key, value) },
outcome: 'success',
});
}
// 第二步:统一触发一次 reloadAdapter(仅在 LLM 配置变更时)
if (needsReloadAdapter) {
const reloadSuccess = reloadAdapter();
if (!reloadSuccess) {
log.warn('[CONFIG] Adapter reload failed after batch config save');
return { success: false, error: 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写' };
} else {
log.info('[CONFIG] LLM config batch changed, adapter reloaded');
}
}
// 第三步:统一应用 Engine/Orchestrator 更新(取每个 key 的最后值)
// 注意:reloadAdapter 内部已重建 adapter 并同步 contextWindow,此处仅处理非 LLM 字段
// 的 Engine 配置(maxIterations/totalTimeoutMs/enableThinking 等)
for (const { key, value } of engineUpdates) {
if (key === 'agent.maxIterations') {
agentLoop.updateConfig({ maxIterations: value as number });
} else if (key === 'agent.totalTimeoutMs') {
agentLoop.updateConfig({ totalTimeoutMs: value as number });
} else if (key === 'agent.enableThinking') {
agentLoop.updateConfig({ thinkingEnabled: value as boolean });
orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean });
} else if (key === 'agent.thinkingEffort') {
agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
} else if (key === 'ollama.numCtx') {
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
} else if (key === 'deepseek.contextWindow' || key === 'agnes.contextWindow' || key === 'mimo.contextWindow') {
const ctxWindow = (value as number) || undefined;
agentLoop.updateConfig({ contextWindow: ctxWindow });
orchestrator.updateDefaultConfig({ contextWindow: ctxWindow });
} else if (key === 'agent.confirmationTimeoutMs') {
confirmationHook.setConfirmationTimeout(value as number);
} else if (key === 'agent.toolExecutionTimeoutMs') {
agentLoop.updateConfig({ toolExecutionTimeoutMs: value as number });
}
}
if (engineUpdates.length > 0) {
log.info(`[CONFIG] Engine/Orchestrator updated with ${engineUpdates.length} config changes (batch mode)`);
}
// 第四步:日志级别即时应用
if (logLevelValue) {
log.transports.file.level = logLevelValue;
log.transports.console.level = logLevelValue;
log.info(`[CONFIG] Log level updated to ${logLevelValue}`);
}
// 第五步:工作空间路径写入独立文件(下次启动生效)
if (workspacePathValue) {
try {
const { writeWorkspacePathToFile } = await import('../main');
writeWorkspacePathToFile(workspacePathValue);
log.info(`[CONFIG] Workspace path saved (restart required): ${workspacePathValue}`);
} catch (err) {
log.error('[CONFIG] Failed to save workspace path:', err);
// v0.3.10: 工作空间路径写入失败必须告知用户,否则下次启动仍使用旧路径
return { success: false, error: `工作空间路径保存失败:${(err as Error).message}` };
} }
} }
@@ -1175,6 +1406,54 @@ export function registerAllIPCHandlers(
log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`); log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
}); });
// ===== v0.3.2: 批量工具确认响应(并行工具调用一次性审批)=====
// 设计原因:多个工具并行触发时,逐条 IPC 响应会产生 N 次往返,
// 且前端 state 覆盖会导致部分请求丢失。批量响应一次解决。
ipcMain.on('tool:confirmationResponseBatch', (_event, data: unknown) => {
if (!data || typeof data !== 'object') {
log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid data');
return;
}
const req = data as {
toolCallIds?: unknown;
approved?: unknown;
remember?: unknown;
autoExecute?: unknown;
};
// 严格校验 toolCallIds 数组
if (!Array.isArray(req.toolCallIds) || req.toolCallIds.length === 0) {
log.warn('[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array');
return;
}
// 每个元素必须是字符串
for (const id of req.toolCallIds) {
if (typeof id !== 'string' || !id) {
log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid toolCallId in array');
return;
}
}
if (typeof req.approved !== 'boolean') {
log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid approved');
return;
}
const remember = typeof req.remember === 'boolean' ? req.remember : false;
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
const resolved = confirmationHook.resolveConfirmationsBatch(
req.toolCallIds as string[],
req.approved,
remember,
autoExecute,
);
log.info(`[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
});
// ===== v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用)=====
// 用途:解决并行工具触发的多个 IPC 事件在前端 state 中互相覆盖导致丢失的问题。
// 前端在弹框打开时主动拉取,确保拿到完整的 pending 列表。
ipcMain.handle('tool:getPendingConfirmations', async () => {
return { success: true, data: confirmationHook.getPendingConfirmations() };
});
// ===== v0.2.0: 持久化自动执行设置 ===== // ===== v0.2.0: 持久化自动执行设置 =====
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => { ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
// M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染 // M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染
+7
View File
@@ -60,6 +60,9 @@ import {
ViewImageTool, ViewImageTool,
// v0.3.2 新增工具(1 个) // v0.3.2 新增工具(1 个)
DeleteFileTool, DeleteFileTool,
// v0.3.3 新增工具(2 个)
FileMoveTool,
FileInfoTool,
} from './harness/tools/built-in'; } from './harness/tools/built-in';
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks'; import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
import { ConfirmationHook } from './harness/hooks/confirmation-hook'; import { ConfirmationHook } from './harness/hooks/confirmation-hook';
@@ -206,6 +209,10 @@ async function initialize(): Promise<void> {
// v0.3.2: 文件删除工具(破坏性操作,HIGH + requireConfirmation // v0.3.2: 文件删除工具(破坏性操作,HIGH + requireConfirmation
toolRegistry.registerBuiltin(new DeleteFileTool()); toolRegistry.registerBuiltin(new DeleteFileTool());
// v0.3.3: 文件移动/重命名工具 + 文件信息查询工具
toolRegistry.registerBuiltin(new FileMoveTool());
toolRegistry.registerBuiltin(new FileInfoTool());
// v0.2.0: 新增文件工具 // v0.2.0: 新增文件工具
toolRegistry.registerBuiltin(new FileEditorTool()); toolRegistry.registerBuiltin(new FileEditorTool());
toolRegistry.registerBuiltin(new CodeSearchTool()); toolRegistry.registerBuiltin(new CodeSearchTool());
+19
View File
@@ -95,6 +95,22 @@ const metonaAPI = {
}, },
sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) =>
ipcRenderer.send('tool:confirmationResponse', response), ipcRenderer.send('tool:confirmationResponse', response),
// v0.3.2: 批量确认响应(并行工具一次性审批)
sendConfirmationResponseBatch: (response: { toolCallIds: string[]; approved: boolean; remember: boolean; autoExecute?: boolean }) =>
ipcRenderer.send('tool:confirmationResponseBatch', response),
// v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用,防止 state 覆盖丢失)
getPendingConfirmations: () =>
ipcRenderer.invoke('tool:getPendingConfirmations') as Promise<{
success: boolean;
data: Array<{
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
riskLevel: string;
reason: string;
expiresAt?: number;
}>;
}>,
// v0.2.0: 持久化自动执行设置 // v0.2.0: 持久化自动执行设置
setAutoExecute: (toolName: string, enabled: boolean) => setAutoExecute: (toolName: string, enabled: boolean) =>
ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled), ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled),
@@ -105,6 +121,9 @@ const metonaAPI = {
config: { config: {
get: (key: string) => ipcRenderer.invoke('config:get', key), get: (key: string) => ipcRenderer.invoke('config:get', key),
set: (key: string, value: unknown) => ipcRenderer.invoke('config:set', key, value), set: (key: string, value: unknown) => ipcRenderer.invoke('config:set', key, value),
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) =>
ipcRenderer.invoke('config:setBatch', entries),
}, },
// ===== 应用工具 ===== // ===== 应用工具 =====
+2 -65
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.8", "version": "0.3.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.8", "version": "0.3.12",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@@ -2333,9 +2333,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2350,9 +2347,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2367,9 +2361,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2384,9 +2375,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2401,9 +2389,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2418,9 +2403,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2435,9 +2417,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2452,9 +2431,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2469,9 +2445,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2486,9 +2459,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2503,9 +2473,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2520,9 +2487,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2537,9 +2501,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2789,9 +2750,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2809,9 +2767,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2829,9 +2784,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2849,9 +2801,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7420,9 +7369,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7444,9 +7390,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7468,9 +7411,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7492,9 +7432,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.8", "version": "0.3.12",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js", "main": "dist-electron/main/main.js",
"author": "Metona Team", "author": "Metona Team",
+4 -1
View File
@@ -20,6 +20,7 @@ import { CommandPalette } from '@renderer/components/CommandPalette';
import { ToastContainer } from '@renderer/components/ToastContainer'; import { ToastContainer } from '@renderer/components/ToastContainer';
import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog'; import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog';
import { ContextMenuDialogHost } from '@renderer/components/ContextMenu'; import { ContextMenuDialogHost } from '@renderer/components/ContextMenu';
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
import { useAgentStream } from '@renderer/hooks/useAgentStream'; import { useAgentStream } from '@renderer/hooks/useAgentStream';
@@ -81,7 +82,9 @@ export default function App(): React.JSX.Element {
<OnboardingWizard /> <OnboardingWizard />
<CommandPalette /> <CommandPalette />
<ToastContainer /> <ToastContainer />
<ConfirmationDialog /> <ErrorBoundary fallbackTitle="工具确认弹框渲染失败">
<ConfirmationDialog />
</ErrorBoundary>
<ContextMenuDialogHost /> <ContextMenuDialogHost />
</div> </div>
</ThemeProvider> </ThemeProvider>
+388 -101
View File
@@ -1,21 +1,30 @@
/** /**
* ConfirmationDialog — 工具执行确认对话框(v0.2.0 新增 * ConfirmationDialog — 工具执行确认对话框(v0.3.2 批量审批版本
* *
* 当 Agent 调用 requiresPermission=true 或 riskLevel>=HIGH 的工具时, * 当 Agent 并行调用多个 requiresPermission=true 或 riskLevel>=HIGH 的工具时,
* 通过此对话框请求用户确认。 * 通过此对话框批量请求用户确认。
*
* v0.3.2 关键改进:
* 1. state 从单值改为数组,支持同时显示多个 pending 请求
* 2. 弹框打开时主动调用 getPendingConfirmations 拉取已积压请求,
* 解决并行 IPC 事件在前端 state 中互相覆盖丢失的问题
* 3. 同工具多次调用按 toolName 分组展示(如 read_file (×5)
* 4. 支持批量勾选 / 批量批准 / 批量拒绝
* 5. remember/autoExecute 按 toolName 去重应用到所有选中项
* *
* 监听 IPC 事件 `tool:confirmationRequest`,显示工具详情, * 监听 IPC 事件 `tool:confirmationRequest`,显示工具详情,
* 用户确认/拒绝后通过 `tool:confirmationResponse` IPC 通道发送结果。 * 用户确认/拒绝后通过 `tool:confirmationResponseBatch` IPC 通道批量发送结果。
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
Dialog, DialogTitle, DialogContent, DialogActions, Dialog, DialogTitle, DialogContent, DialogActions,
Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox, Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox,
Accordion, AccordionSummary, AccordionDetails, Accordion, AccordionSummary, AccordionDetails,
LinearProgress, LinearProgress, List, ListItem, ListItemIcon, ListItemText,
IconButton, Tooltip, Divider,
} from '@mui/material'; } from '@mui/material';
import { ShieldAlert, ChevronDown, Timer } from 'lucide-react'; import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
interface ConfirmationRequest { interface ConfirmationRequest {
toolCallId: string; toolCallId: string;
@@ -35,39 +44,105 @@ const RISK_COLORS: Record<string, 'default' | 'success' | 'warning' | 'error' |
critical: 'error', critical: 'error',
}; };
/**
* 按工具名分组后的请求组
*/
interface GroupedRequests {
toolName: string;
riskLevel: string;
/** 共享同一 riskLevel 的多个请求(riskLevel 来自 toolDef,同工具必然相同) */
requests: ConfirmationRequest[];
}
export function ConfirmationDialog(): React.JSX.Element | null { export function ConfirmationDialog(): React.JSX.Element | null {
const [request, setRequest] = useState<ConfirmationRequest | null>(null); const [requests, setRequests] = useState<ConfirmationRequest[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [remember, setRemember] = useState(false); const [remember, setRemember] = useState(false);
const [autoExecute, setAutoExecute] = useState(false); const [autoExecute, setAutoExecute] = useState(false);
const [remainingMs, setRemainingMs] = useState<number>(0); const [remainingMs, setRemainingMs] = useState<number>(0);
// 记录首次接收时的剩余时间,作为进度条总量(固定不变) // 记录首次接收时的剩余时间,作为进度条总量(固定不变)
const [initialMs, setInitialMs] = useState<number>(0); const [initialMs, setInitialMs] = useState<number>(0);
// ===== 弹框打开时主动拉取已积压的 pending 请求 =====
// 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达,
// 或在 React state 更新批次中被覆盖。主动拉取确保不丢请求。
const refreshPending = useCallback(async (mergeNew?: ConfirmationRequest) => {
try {
const result = await window.metona?.tool?.getPendingConfirmations();
const pendingList: ConfirmationRequest[] = result?.success ? result.data : [];
// 合并新到的 IPC 请求(若 pending 快照已包含则去重)
const merged = [...pendingList];
if (mergeNew) {
const exists = merged.some((r) => r.toolCallId === mergeNew.toolCallId);
if (!exists) merged.push(mergeNew);
}
// 按 toolCallId 去重(防止 refresh 与 IPC 事件重复添加)
const dedupedMap = new Map<string, ConfirmationRequest>();
for (const r of merged) dedupedMap.set(r.toolCallId, r);
const deduped = Array.from(dedupedMap.values());
setRequests(deduped);
// 默认全选
setSelectedIds(new Set(deduped.map((r) => r.toolCallId)));
} catch {
// 拉取失败时回退到只显示新到的请求
if (mergeNew) {
setRequests([mergeNew]);
setSelectedIds(new Set([mergeNew.toolCallId]));
}
}
}, []);
useEffect(() => { useEffect(() => {
// 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API // 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API
const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => { const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => {
const req = data as ConfirmationRequest; const req = data as ConfirmationRequest;
setRequest(req); // 每次新请求到达时主动拉取完整 pending 列表,防止 state 覆盖丢失
refreshPending(req);
setRemember(false); setRemember(false);
setAutoExecute(false); setAutoExecute(false);
// 初始化倒计时 // 初始化倒计时(使用本次请求的过期时间作为初始值)
if (req.expiresAt) { if (req.expiresAt) {
const remain = Math.max(0, req.expiresAt - Date.now()); const remain = Math.max(0, req.expiresAt - Date.now());
setRemainingMs(remain); setRemainingMs(remain);
setInitialMs(remain); setInitialMs(remain);
} else {
setRemainingMs(0);
setInitialMs(0);
} }
}); });
return cleanup; return cleanup;
}, [refreshPending]);
// ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATEDrun 结束/abort)时清空前端 state =====
// 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步,
// 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。
// 新会话 INIT 时也清空,防止上一会话的残留请求污染新会话 UI。
useEffect(() => {
if (!window.metona?.agent?.onStateChange) return;
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => {
const data = state as { state?: string; current?: string };
const stateValue = data.state ?? data.current ?? '';
// INIT: 新 run 开始(新会话或新消息);TERMINATED: run 结束(正常完成/abort/超时/死循环)
if (stateValue === 'INIT' || stateValue === 'TERMINATED') {
setRequests([]);
setSelectedIds(new Set());
}
});
return unsubscribe;
}, []); }, []);
// 倒计时:每 200ms 更新剩余时间 // 倒计时:每 200ms 更新剩余时间(取所有请求中最早过期的)
useEffect(() => { useEffect(() => {
if (!request?.expiresAt) return; if (requests.length === 0) return;
const interval = setInterval(() => { const interval = setInterval(() => {
const remaining = request.expiresAt! - Date.now(); // 取所有请求中最早过期的剩余时间
const earliestExpires = requests
.map((r) => r.expiresAt)
.filter((v): v is number => typeof v === 'number')
.sort((a, b) => a - b)[0];
if (!earliestExpires) {
setRemainingMs(0);
return;
}
const remaining = earliestExpires - Date.now();
if (remaining <= 0) { if (remaining <= 0) {
setRemainingMs(0); setRemainingMs(0);
clearInterval(interval); clearInterval(interval);
@@ -76,28 +151,121 @@ export function ConfirmationDialog(): React.JSX.Element | null {
} }
}, 200); }, 200);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [request]); }, [requests]);
const handleRespond = useCallback((approved: boolean) => { // 按工具名分组(同工具多次调用折叠为一组)
if (!request) return; const grouped: GroupedRequests[] = useMemo(() => {
// 通过 preload 暴露的 metona.tool API 发送响应 const map = new Map<string, GroupedRequests>();
for (const r of requests) {
const existing = map.get(r.toolName);
if (existing) {
existing.requests.push(r);
} else {
map.set(r.toolName, {
toolName: r.toolName,
riskLevel: r.riskLevel,
requests: [r],
});
}
}
return Array.from(map.values());
}, [requests]);
const handleToggleSelect = useCallback((toolCallId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(toolCallId)) {
next.delete(toolCallId);
} else {
next.add(toolCallId);
}
return next;
});
}, []);
const handleToggleGroupSelect = useCallback((group: GroupedRequests) => {
const groupIds = group.requests.map((r) => r.toolCallId);
setSelectedIds((prev) => {
const next = new Set(prev);
const allSelected = groupIds.every((id) => next.has(id));
if (allSelected) {
groupIds.forEach((id) => next.delete(id));
} else {
groupIds.forEach((id) => next.add(id));
}
return next;
});
}, []);
const handleSelectAll = useCallback(() => {
setSelectedIds(new Set(requests.map((r) => r.toolCallId)));
}, [requests]);
const handleDeselectAll = useCallback(() => {
setSelectedIds(new Set());
}, []);
const handleRespond = useCallback((approved: boolean, onlySelected: boolean = true) => {
// 决定要处理的 toolCallId 列表
// onlySelected=true: 仅处理勾选项(用于"批准选中")
// onlySelected=false: 处理全部请求(用于"拒绝全部" / "批准全部"
const targetIds = onlySelected
? Array.from(selectedIds)
: requests.map((r) => r.toolCallId);
if (targetIds.length === 0) return;
// 通过批量 IPC 通道发送响应
// autoExecute 仅在批准时生效(拒绝时无需持久化) // autoExecute 仅在批准时生效(拒绝时无需持久化)
window.metona?.tool?.sendConfirmationResponse({ window.metona?.tool?.sendConfirmationResponseBatch({
toolCallId: request.toolCallId, toolCallIds: targetIds,
approved, approved,
remember, remember,
autoExecute: approved && autoExecute, autoExecute: approved && autoExecute,
}); });
setRequest(null);
}, [request, remember, autoExecute]);
if (!request) return null; if (onlySelected) {
// "批准选中":只移除已处理的,保留未选中的 pending
// 防止未选中的 pending 被清空后丢失(用户看不到,会超时失败)
const targetSet = new Set(targetIds);
const remaining = requests.filter((r) => !targetSet.has(r.toolCallId));
setRequests(remaining);
// 更新选中项:清空已处理的,保留未选中的(但实际上未选中的本来就不在 selectedIds 中)
setSelectedIds(new Set());
// 主动刷新后端 pending 列表,拉取可能新到达的请求
// 用 setTimeout 避免与 setRequests 同批次,确保后端已处理完批量响应
if (remaining.length === 0) {
setTimeout(() => refreshPending(), 50);
}
} else {
// "拒绝全部 / 批准全部":清空所有
setRequests([]);
setSelectedIds(new Set());
}
}, [requests, selectedIds, remember, autoExecute, refreshPending]);
const riskColor = RISK_COLORS[request.riskLevel] ?? 'default'; if (requests.length === 0) return null;
// 取所有请求中最早过期的,用于倒计时显示
const earliestExpires = requests
.map((r) => r.expiresAt)
.filter((v): v is number => typeof v === 'number')
.sort((a, b) => a - b)[0];
const selectedCount = selectedIds.size;
const totalCount = requests.length;
const allSelected = selectedCount === totalCount;
// 综合风险等级:取所有请求中最高的
const highestRisk = requests.reduce<string>((highest, r) => {
const order = ['safe', 'low', 'medium', 'high', 'critical'];
return order.indexOf(r.riskLevel) > order.indexOf(highest) ? r.riskLevel : highest;
}, 'safe');
const riskColor = RISK_COLORS[highestRisk] ?? 'default';
const remainingSec = Math.ceil(remainingMs / 1000); const remainingSec = Math.ceil(remainingMs / 1000);
const isUrgent = remainingSec <= 10 && remainingSec > 0; const isUrgent = remainingSec <= 10 && remainingSec > 0;
const isExpired = remainingMs <= 0 && request.expiresAt != null; const isExpired = remainingMs <= 0 && earliestExpires != null;
// 总时长使用首次接收时的剩余时间(固定值),保证进度条单调递减
const totalMs = initialMs || remainingMs; const totalMs = initialMs || remainingMs;
const progressPercent = totalMs > 0 const progressPercent = totalMs > 0
? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100)) ? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100))
@@ -106,7 +274,6 @@ export function ConfirmationDialog(): React.JSX.Element | null {
// 格式化参数显示 // 格式化参数显示
const formatArg = (key: string, value: unknown): string => { const formatArg = (key: string, value: unknown): string => {
if (typeof value === 'string') { if (typeof value === 'string') {
// 长字符串截断
return value.length > 200 ? value.slice(0, 200) + '...' : value; return value.length > 200 ? value.slice(0, 200) + '...' : value;
} }
if (value === null) return 'null'; if (value === null) return 'null';
@@ -118,11 +285,25 @@ export function ConfirmationDialog(): React.JSX.Element | null {
} }
}; };
// 综合风险文案
const batchReason = totalCount > 1
? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}`
: requests[0]?.reason ?? 'Agent 正在请求执行工具';
return ( return (
<Dialog <Dialog
open={!!request} open={requests.length > 0}
onClose={() => handleRespond(false)} onClose={(_, reason) => {
maxWidth="sm" // 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
if (isExpired) return;
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
handleRespond(false, false);
}
}}
maxWidth="md"
fullWidth fullWidth
slotProps={{ slotProps={{
paper: { paper: {
@@ -132,15 +313,23 @@ export function ConfirmationDialog(): React.JSX.Element | null {
> >
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" /> <ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
<Typography variant="h6" component="span"></Typography> <Typography variant="h6" component="span">
{totalCount > 1 && `${totalCount} 个并行请求)`}
</Typography>
<Box sx={{ flex: 1 }} />
<Tooltip title="刷新 pending 列表">
<IconButton size="small" onClick={() => refreshPending()}>
<RefreshCw size={16} />
</IconButton>
</Tooltip>
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}> <Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
{request.reason} {batchReason}
</Alert> </Alert>
{request.expiresAt && !isExpired && ( {earliestExpires && !isExpired && (
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Timer <Timer
size={16} size={16}
@@ -152,7 +341,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
sx={{ sx={{
color: isUrgent ? 'error.main' : 'text.secondary', color: isUrgent ? 'error.main' : 'text.secondary',
fontWeight: isUrgent ? 700 : 500, fontWeight: isUrgent ? 700 : 500,
minWidth: 48, minWidth: 80,
animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none', animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none',
'@keyframes metona-pulse': { '@keyframes metona-pulse': {
'0%, 100%': { opacity: 1 }, '0%, 100%': { opacity: 1 },
@@ -160,7 +349,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
}, },
}} }}
> >
{remainingSec}s {totalCount > 1 ? '最早过期 ' : '剩余 '}{remainingSec}s
</Typography> </Typography>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
@@ -171,64 +360,145 @@ export function ConfirmationDialog(): React.JSX.Element | null {
</Box> </Box>
)} )}
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}> {/* 选择控制条 */}
<Chip <Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
label={`工具: ${request.toolName}`} <Button size="small" onClick={handleSelectAll} disabled={allSelected}>
color="primary"
size="small" </Button>
/> <Button size="small" onClick={handleDeselectAll} disabled={selectedCount === 0}>
<Chip
label={`风险: ${request.riskLevel}`} </Button>
color={riskColor} <Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
size="small" {selectedCount} / {totalCount}
/> </Typography>
</Box> </Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Divider sx={{ mb: 1 }} />
Agent
</Typography>
<Accordion defaultExpanded sx={{ bgcolor: 'background.default' }}> {/* 分组列表 */}
<AccordionSummary expandIcon={<ChevronDown size={16} />}> <List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}> {grouped.map((group) => {
({Object.keys(request.args).length} ) const groupIds = group.requests.map((r) => r.toolCallId);
</Typography> const groupAllSelected = groupIds.every((id) => selectedIds.has(id));
</AccordionSummary> const groupSomeSelected = groupIds.some((id) => selectedIds.has(id));
<AccordionDetails> const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default';
<Box const isMulti = group.requests.length > 1;
component="pre"
sx={{ return (
fontFamily: 'monospace', <Accordion
fontSize: 11, key={group.toolName}
color: 'text.primary', defaultExpanded
whiteSpace: 'pre-wrap', sx={{ bgcolor: 'background.default', mb: 0.5 }}
wordBreak: 'break-word', >
margin: 0, <AccordionSummary expandIcon={<ChevronDown size={16} />}>
maxHeight: 300, <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', pr: 1 }}>
overflow: 'auto', <Checkbox
}} size="small"
> checked={groupAllSelected}
{Object.entries(request.args).map(([key, value]) => ( indeterminate={!groupAllSelected && groupSomeSelected}
<Box key={key} component="div" sx={{ mb: 1 }}> onChange={(e) => {
<Typography e.stopPropagation();
component="span" handleToggleGroupSelect(group);
variant="caption" }}
sx={{ color: 'info.main', fontWeight: 700 }} onClick={(e) => e.stopPropagation()}
> />
{key}: <Typography variant="body2" sx={{ fontWeight: 600 }}>
</Typography> {group.toolName}
<Typography </Typography>
component="span" {isMulti && (
variant="caption" <Chip
sx={{ color: 'text.primary', ml: 1 }} label={`×${group.requests.length}`}
> size="small"
{formatArg(key, value)} color="primary"
</Typography> variant="outlined"
</Box> sx={{ height: 20, fontSize: 11 }}
))} />
</Box> )}
</AccordionDetails> <Chip
</Accordion> label={group.riskLevel}
color={groupRiskColor}
size="small"
sx={{ height: 20, fontSize: 11 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
{group.requests.map((req, idx) => {
const isSelected = selectedIds.has(req.toolCallId);
return (
<ListItem
key={req.toolCallId}
sx={{
py: 0.5,
bgcolor: isSelected ? 'action.selected' : 'transparent',
borderRadius: 1,
}}
secondaryAction={
isMulti ? (
<Typography variant="caption" color="text.secondary">
#{idx + 1}
</Typography>
) : undefined
}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Checkbox
size="small"
checked={isSelected}
onChange={() => handleToggleSelect(req.toolCallId)}
/>
</ListItemIcon>
<ListItemText
primary={
<Box
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: 11,
color: 'text.primary',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
maxHeight: 200,
overflow: 'auto',
}}
>
{Object.entries(req.args).map(([key, value]) => (
<Box key={key} component="div" sx={{ mb: 0.5 }}>
<Typography
component="span"
variant="caption"
sx={{ color: 'info.main', fontWeight: 700 }}
>
{key}:
</Typography>
<Typography
component="span"
variant="caption"
sx={{ color: 'text.primary', ml: 1 }}
>
{formatArg(key, value)}
</Typography>
</Box>
))}
{Object.keys(req.args).length === 0 && (
<Typography variant="caption" color="text.secondary">
</Typography>
)}
</Box>
}
/>
</ListItem>
);
})}
</AccordionDetails>
</Accordion>
);
})}
</List>
<Divider sx={{ my: 1 }} />
<FormControlLabel <FormControlLabel
control={ control={
@@ -240,10 +510,9 @@ export function ConfirmationDialog(): React.JSX.Element | null {
} }
label={ label={
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
</Typography> </Typography>
} }
sx={{ mt: 1 }}
/> />
<FormControlLabel <FormControlLabel
@@ -260,32 +529,50 @@ export function ConfirmationDialog(): React.JSX.Element | null {
} }
label={ label={
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{selectedCount > 0 && autoExecute && (
<Box component="span" sx={{ color: 'warning.main', ml: 1 }}>
· {new Set(Array.from(selectedIds).map((id) => requests.find((r) => r.toolCallId === id)?.toolName).filter(Boolean) as string[]).size}
</Box>
)}
</Typography> </Typography>
} }
sx={{ mt: 0.5 }}
/> />
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<Button <Button
onClick={() => handleRespond(false)} onClick={() => handleRespond(false, false)}
color="error" color="error"
variant="outlined" variant="outlined"
size="small" size="small"
disabled={isExpired} disabled={isExpired}
> >
({totalCount})
</Button> </Button>
<Button <Button
onClick={() => handleRespond(true)} onClick={() => handleRespond(true, false)}
color="success"
variant="outlined"
size="small"
disabled={isExpired || allSelected}
title={allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
>
</Button>
<Button
onClick={() => handleRespond(true, true)}
color="success" color="success"
variant="contained" variant="contained"
size="small" size="small"
autoFocus autoFocus
disabled={isExpired} disabled={isExpired || selectedCount === 0}
> >
{isExpired ? '已超时' : '确认执行'} {isExpired
? '已超时'
: selectedCount === totalCount
? `确认执行 (${selectedCount})`
: `批准选中 (${selectedCount})`}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
+65 -18
View File
@@ -5,15 +5,20 @@
* 1. 💭 思考阶段 — ThoughtBlock 自动展开,流式追加 reasoning content * 1. 💭 思考阶段 — ThoughtBlock 自动展开,流式追加 reasoning content
* 2. 🔧 工具调用 — ToolCallCard 展示调用和结果 * 2. 🔧 工具调用 — ToolCallCard 展示调用和结果
* 3. ✏️ 回复阶段 — Markdown 渲染,打字光标指示流式输出 * 3. ✏️ 回复阶段 — Markdown 渲染,打字光标指示流式输出
*
* F1 性能优化: 使用 React.memo 包裹,避免历史消息在流式 delta 时重渲染。
* 配合 agentStatus 选择器优化:历史消息(isStreaming=false)不订阅真实 agentStatus
* 避免 agentStatus 频繁变化(thinking/executing/idle)触发所有 AssistantMessage 重渲染。
*/ */
import { useState, useCallback } from 'react'; import { useState, useCallback, memo, useMemo } from 'react';
import { Box, Typography, Avatar, Stack, IconButton, Tooltip } from '@mui/material'; import { Box, Typography, Avatar, Stack, IconButton, Tooltip } from '@mui/material';
import { Bot, Copy, Check } from 'lucide-react'; import { Bot, Copy, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight'; import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw'; // F11: 移除 rehypeRaw — 避免解析 raw HTML 的开销 + 安全考虑(防止 AI 输出 HTML 注入)
// 如需恢复内联 HTML 渲染,可重新引入 rehype-raw
import type { ChatMessage } from '@renderer/stores/agent-store'; import type { ChatMessage } from '@renderer/stores/agent-store';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { ThoughtBlock } from './ThoughtBlock'; import { ThoughtBlock } from './ThoughtBlock';
@@ -27,11 +32,14 @@ interface AssistantMessageProps {
isStreaming?: boolean; isStreaming?: boolean;
} }
export function AssistantMessage({ message, isStreaming }: AssistantMessageProps): React.JSX.Element { function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps): React.JSX.Element {
// 直接使用 message.content — updateLastAssistantMessage 已实时更新此字段 // 直接使用 message.content — updateLastAssistantMessage 已实时更新此字段
const content = message.content; const content = message.content;
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const agentStatus = useAgentStore((s) => s.agentStatus); // F1: 历史消息(isStreaming=false)固定返回 'idle',避免 agentStatus 变化触发重渲染
// 逻辑等价:原 isThinking = agentStatus === 'thinking' && isStreaming
// 新 isThinking = agentStatus === 'thinking'agentStatus 在非流式时恒为 'idle'
const agentStatus = useAgentStore((s) => (isStreaming ? s.agentStatus : 'idle'));
const contextMenuItems = createContextMenuItems('message', { content }); const contextMenuItems = createContextMenuItems('message', { content });
@@ -40,6 +48,29 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps
const hasContent = !!content; const hasContent = !!content;
const isThinking = agentStatus === 'thinking' && isStreaming; const isThinking = agentStatus === 'thinking' && isStreaming;
// F7: useMemo 缓存 ReactMarkdown 元素,避免非流式时因 isStreaming/isLast 变化重新创建元素
// 流式时此元素不被渲染(用纯文本),useMemo 仍会更新但仅创建轻量 React 元素对象
// 非流式时 content 不变,useMemo 复用缓存,避免重新创建 ReactMarkdown 组件实例
const markdownElement = useMemo(() => (
<Box className="prose-metona">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className ?? '');
// rehype-highlight 会把代码替换为 <span> 高亮元素,需提取纯文本
const codeStr = extractTextContent(children).replace(/\n$/, '');
if (!match) return <code className={className} {...props}>{children}</code>;
return <CodeBlock language={match[1]} code={codeStr} />;
},
}}
>
{content}
</ReactMarkdown>
</Box>
), [content]);
return ( return (
<Stack <Stack
direction="row" direction="row"
@@ -109,23 +140,32 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps
)} )}
{hasContent ? ( {hasContent ? (
<Box className="prose-metona"> isStreaming ? (
<ReactMarkdown // F3: 流式时用纯文本渲染,避免 ReactMarkdown 对长内容重新解析导致卡顿。
remarkPlugins={[remarkGfm]} // 根因:每个 delta 触发 ReactMarkdown 全量重新解析 + rehypeHighlight 高亮,
rehypePlugins={[rehypeHighlight, rehypeRaw]} // 内容长度增加时单次解析耗时 O(n) 退化,30+ delta/秒下呈 O(n²) 卡死。
components={{ // 流结束后自动切换到 ReactMarkdown 一次性渲染(一次性 O(n),可接受)。
code({ className, children, ...props }) { <Box
const match = /language-(\w+)/.exec(className ?? ''); component="pre"
// rehype-highlight 会把代码替换为 <span> 高亮元素,需提取纯文本 className="prose-metona prose-streaming"
const codeStr = extractTextContent(children).replace(/\n$/, ''); sx={{
if (!match) return <code className={className} {...props}>{children}</code>; margin: 0,
return <CodeBlock language={match[1]} code={codeStr} />; padding: 0,
}, fontFamily: 'inherit',
fontSize: 14,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
color: 'text.primary',
}} }}
> >
{content} {content}
</ReactMarkdown> </Box>
</Box> ) : (
// F7: 使用 useMemo 缓存的 ReactMarkdown 元素
markdownElement
)
) : isStreaming ? ( ) : isStreaming ? (
<Typography variant="body2" sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}> <Typography variant="body2" sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}>
{isThinking ? '正在思考...' : '正在生成回复...'} {isThinking ? '正在思考...' : '正在生成回复...'}
@@ -160,6 +200,13 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps
); );
} }
/**
* F1: memo 包裹 AssistantMessage。
* - 流式 delta 时仅最后一条 message 引用变化,历史消息跳过 re-render
* - agentStatus 订阅已优化(非流式时返回 'idle'),历史消息不受 agentStatus 变化影响
*/
export const AssistantMessage = memo(AssistantMessageImpl);
function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element { function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
+14 -1
View File
@@ -2,8 +2,13 @@
* MessageItem — 单条消息路由组件 * MessageItem — 单条消息路由组件
* *
* 根据消息 role 路由到对应的子组件渲染。 * 根据消息 role 路由到对应的子组件渲染。
*
* F1 性能优化: 使用 React.memo 包裹,避免流式 delta 触发历史消息重渲染。
* 浅比较策略:message 引用变化(store 仅替换最后一条)或 isStreaming 变化时才重渲染。
* 历史消息的 message 引用在 delta 流中保持不变,因此可跳过 re-render。
*/ */
import { memo } from 'react';
import { Box, Typography, Stack } from '@mui/material'; import { Box, Typography, Stack } from '@mui/material';
import { Terminal } from 'lucide-react'; import { Terminal } from 'lucide-react';
import type { ChatMessage } from '@renderer/stores/agent-store'; import type { ChatMessage } from '@renderer/stores/agent-store';
@@ -18,7 +23,7 @@ interface MessageItemProps {
isStreaming?: boolean; isStreaming?: boolean;
} }
export function MessageItem({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element { function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
switch (message.role) { switch (message.role) {
case 'user': case 'user':
return <UserMessage message={message} />; return <UserMessage message={message} />;
@@ -41,6 +46,14 @@ export function MessageItem({ message, isLast, isStreaming }: MessageItemProps):
} }
} }
/**
* F1: memo 默认浅比较 props。
* - message 是对象引用,store 更新最后一条时只替换最后一条,历史消息引用不变 → 跳过 re-render
* - isStreaming 是 boolean,流式过程中值不变(持续 true),流结束才变化一次 → 历史消息仅重渲染一次
* - isLast 是 boolean,仅在最后一条切换时变化
*/
export const MessageItem = memo(MessageItemImpl);
/** ToolMessage — 独立的工具结果消息(role=tool) */ /** ToolMessage — 独立的工具结果消息(role=tool) */
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element { function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return ( return (
+84 -8
View File
@@ -13,13 +13,72 @@ export function MessageList(): React.JSX.Element {
const isStreaming = useAgentStore((s) => s.isStreaming); const isStreaming = useAgentStore((s) => s.isStreaming);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
// 流式输出时高频触发,使用 throttle 避免滚动卡顿 // F2: 滚动节流优化
// 问题:原实现用 setTimeout(80) debounce + 'smooth',高频 delta 时 trailing 永不触发,
// 且 'smooth' 在长列表上触发主线程布局动画,加剧卡顿。
// 方案:
// - 流式时:100ms 节流 + trailing 兜底 + 'auto' 行为(同步布局,无动画占主线程)
// - 非流式时:立即 'smooth' 滚动(新消息发送/接收完成)
// - 用 rAF 同步到下一帧,与 React 渲染合并,避免一帧内多次布局
const lastScrollRef = useRef(0);
const trailingTimerRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const now = performance.now();
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); const elapsed = now - lastScrollRef.current;
}, 80);
return () => clearTimeout(timer); // 调度一次 rAF 滚动(自动取消上一次挂起的 rAF)
}, [messages]); const doScroll = (behavior: ScrollBehavior) => {
lastScrollRef.current = performance.now();
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
messagesEndRef.current?.scrollIntoView({ behavior });
});
};
// 非流式或首次进入:立即平滑滚动(新消息出现)
if (!isStreaming || lastScrollRef.current === 0) {
if (trailingTimerRef.current !== null) {
clearTimeout(trailingTimerRef.current);
trailingTimerRef.current = null;
}
doScroll('smooth');
return;
}
// 流式中:100ms 节流 + trailing 兜底
if (elapsed >= 100) {
// 已过节流窗口,立即执行
if (trailingTimerRef.current !== null) {
clearTimeout(trailingTimerRef.current);
trailingTimerRef.current = null;
}
doScroll('auto');
} else if (trailingTimerRef.current === null) {
// 在节流窗口内,安排 trailing 滚动(保证最后一次 delta 后到底)
const remaining = 100 - elapsed;
trailingTimerRef.current = window.setTimeout(() => {
trailingTimerRef.current = null;
doScroll('auto');
}, remaining);
}
}, [messages, isStreaming]);
// 组件卸载时清理所有挂起的 timer 和 rAF
useEffect(() => {
return () => {
if (trailingTimerRef.current !== null) {
clearTimeout(trailingTimerRef.current);
trailingTimerRef.current = null;
}
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, []);
if (messages.length === 0 && !isStreaming) { if (messages.length === 0 && !isStreaming) {
return ( return (
@@ -35,10 +94,27 @@ export function MessageList(): React.JSX.Element {
} }
return ( return (
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3 }}> // F12: GPU 加速 — transform: translateZ(0) 将滚动容器提升为合成层
// 滚动时由合成器线程处理,避免主线程重绘叠加卡顿
// 风险评估:已确认 chat 目录内无 position: fixed 元素;
// ContextMenu 用 MUI Portal 渲染到 document.body,不受 transform 影响
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3, transform: 'translateZ(0)' }}>
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
{messages.map((msg, i) => ( {messages.map((msg, i) => (
<MessageItem key={msg.id} message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} /> // F4: content-visibility 准虚拟滚动
// 浏览器原生支持,不可见区域跳过布局和绘制(DOM 节点保留但渲染开销 O(1))。
// 配合 F1 memo(跳过 re-render)+ F3 流式纯文本,历史消息开销降至最低。
// contain-intrinsic-size 提供估算高度,避免滚动时高度抖动。
// 注:如后续需真正虚拟滚动(移除 DOM 节点),可升级到 react-virtuoso。
<Box
key={msg.id}
sx={{
'content-visibility': 'auto',
'contain-intrinsic-size': 'auto 300px',
}}
>
<MessageItem message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
</Box>
))} ))}
<StreamingIndicator /> <StreamingIndicator />
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
+19 -17
View File
@@ -26,23 +26,25 @@ export function OnboardingWizard(): React.JSX.Element | null {
const handleNext = async () => { const handleNext = async () => {
if (step < STEPS.length - 1) { setStep(step + 1); return; } if (step < STEPS.length - 1) { setStep(step + 1); return; }
try { try {
if (window.metona?.config) { if (window.metona?.config?.setBatch) {
const configSets: Promise<unknown>[] = []; // v0.3.9: 改用批量保存,避免并行 config.set 中间态触发 reloadAdapter 失败
if (provider.trim()) configSets.push(window.metona.config.set('llm.provider', provider.trim())); // 旧实现问题:Promise.allSettled 并行 6 个 config.set
if (baseURL.trim()) configSets.push(window.metona.config.set('llm.baseURL', baseURL.trim())); // - llm.provider 写入会清空 llm.apiKey(与 llm.apiKey 写入竞态)
if (model.trim()) configSets.push(window.metona.config.set('llm.model', model.trim())); // - reloadAdapter 被调用 4 次,中间态必然失败
if (apiKey.trim()) configSets.push(window.metona.config.set('llm.apiKey', apiKey.trim())); // - 代码只检查 status === 'rejected',完全忽略 { success: false } 的情况
if (workspacePath.trim()) configSets.push(window.metona.config.set('workspace.path', workspacePath.trim())); // - 实际配置失败但前端显示成功并关闭向导
configSets.push(window.metona.config.set('onboarding.completed', true)); const entries: Array<{ key: string; value: unknown }> = [];
// M-26 修复: 改用 Promise.allSettled,单个配置写入失败不阻止 onboarding 完成 if (provider.trim()) entries.push({ key: 'llm.provider', value: provider.trim() });
// 但 onboarding.completed 必须成功,否则用户重启后仍会看到引导 if (baseURL.trim()) entries.push({ key: 'llm.baseURL', value: baseURL.trim() });
const results = await Promise.allSettled(configSets); if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() });
const failedCount = results.filter((r) => r.status === 'rejected').length; if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() });
if (failedCount > 0) { if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() });
console.error('[OnboardingWizard]', `${failedCount} config(s) failed to save`); entries.push({ key: 'onboarding.completed', value: true });
// 用户主动操作失败必须有反馈,否则按钮看起来无响应
import('metona-toast').then((mod) => mod.default.error(`部分配置保存失败(${failedCount} 项),请重试`)).catch(() => {}); const r = await window.metona.config.setBatch(entries);
// 不调用 setOnboardingCompleted(true),让用户重试 if (r && !r.success) {
console.error('[OnboardingWizard]', 'Batch config save failed:', r.error);
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试')).catch(() => {});
return; return;
} }
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || ''); useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
+19 -25
View File
@@ -440,35 +440,29 @@ function LLMSettings() {
} }
setSaving(true); setSaving(true);
try { try {
const setConfig = window.metona?.config?.set; const setBatch = window.metona?.config?.setBatch;
if (!setConfig) { if (!setBatch) {
import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {}); import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {});
return; return;
} }
// 串行保存:避免并发 IPC 调用(reloadAdapter 内部 lastConfigSig 比较会跳过中间态的重载) // v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败
const fields: Array<[string, unknown]> = [ // 旧实现:串行 config:set 8 次,provider 切换后第 1 步会清空 apiKey,
['llm.provider', provider], // 此时 reloadAdapter 读到空 apiKey 返回 false,前端 toast 报"配置不全"
['llm.model', model], // 但所有字段实际已写入,第二次点保存才显示"已保存"。
['llm.apiKey', apiKey], // 新实现:一次性传所有字段,后端先写入全部,最后统一 reloadAdapter 一次。
['llm.baseURL', baseURL], const entries: Array<{ key: string; value: unknown }> = [
['ollama.numCtx', numCtx], { key: 'llm.provider', value: provider },
['deepseek.contextWindow', dsCtxWindow], { key: 'llm.model', value: model },
['agnes.contextWindow', agnesCtxWindow], { key: 'llm.apiKey', value: apiKey },
['mimo.contextWindow', mimoCtxWindow], { key: 'llm.baseURL', value: baseURL },
{ key: 'ollama.numCtx', value: numCtx },
{ key: 'deepseek.contextWindow', value: dsCtxWindow },
{ key: 'agnes.contextWindow', value: agnesCtxWindow },
{ key: 'mimo.contextWindow', value: mimoCtxWindow },
]; ];
let firstError: string | null = null; const r = await setBatch(entries);
for (const [key, value] of fields) { if (r && !r.success) {
try { import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {});
const r = await setConfig(key, value);
if (r && !r.success && !firstError) {
firstError = r.error ?? '配置保存失败';
}
} catch (err) {
if (!firstError) firstError = (err as Error).message;
}
}
if (firstError) {
import('metona-toast').then((mod) => mod.default.error(firstError!)).catch(() => {});
} else { } else {
import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {}); import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {});
} }
+108 -20
View File
@@ -25,6 +25,66 @@ export function useAgentStream(): void {
useEffect(() => { useEffect(() => {
if (!window.metona?.agent?.onStreamEvent) return; if (!window.metona?.agent?.onStreamEvent) return;
// F5: text_delta rAF 批处理
// 问题:每个 text_delta 直接调用 updateLastAssistantMessage + updateLastTraceStep
// 频率 30-50 次/秒,每次触发 store 更新 + React re-render。
// 方案:累积 delta 到缓冲区,用 rAF 每帧 commit 一次,合并多次 store 写入。
// done/error 时立即 flush,避免最后一段 delta 丢失。
// 新迭代卡片创建逻辑(needsNewCard)立即处理,不缓冲。
let textDeltaBuffer = '';
let textBufferSessionId: string | undefined;
let textRafId: number | null = null;
const flushTextDelta = () => {
textRafId = null;
if (!textDeltaBuffer) return;
const delta = textDeltaBuffer;
const bufferedSessionId = textBufferSessionId;
textDeltaBuffer = '';
textBufferSessionId = undefined;
const store = useAgentStore.getState();
// 会话切换保护:如果缓冲时的会话与当前会话不一致,丢弃(避免跨会话污染)
if (bufferedSessionId && store.currentSessionId !== bufferedSessionId) return;
store.updateLastAssistantMessage(delta);
// 无 reasoning 模式下,将文本内容也记入 Trace thought
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
const steps = store.traceSteps;
const step = steps[steps.length - 1];
if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) {
store.updateLastTraceStep({
thought: (step.thought ?? '') + delta,
});
}
};
// F9: reasoning_delta 的 traceSteps thought 更新走 rAF 批处理
// 问题:reasoning_delta 每个 delta 调用 updateLastTraceStep,频率 10-30 次/秒,
// 触发 TraceViewer 等订阅者 re-render(即使不可见也有 selector 调用开销)。
// 方案:累积 reasoning delta 到缓冲区,rAF 每帧 commit 一次。
// message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示思考内容)。
// 新迭代卡片创建时的 thought 更新保持即时(新卡片需立即显示)。
let traceThoughtBuffer = '';
let traceRafId: number | null = null;
const flushTraceThought = () => {
traceRafId = null;
if (!traceThoughtBuffer) return;
const delta = traceThoughtBuffer;
traceThoughtBuffer = '';
const store = useAgentStore.getState();
const steps = store.traceSteps;
const step = steps[steps.length - 1];
if (step) {
store.updateLastTraceStep({
thought: (step.thought ?? '') + delta,
});
}
};
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => { const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => {
const data = event as { const data = event as {
type?: string; type?: string;
@@ -121,13 +181,11 @@ export function useAgentStream(): void {
}); });
} }
// 同步更新当前 Trace 步骤的 thought 字段 // F9: traceSteps thought 更新走 rAF 批处理(减少 TraceViewer re-render 频率)
const traceSteps = getStore().traceSteps; // message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示)
const curStep = traceSteps[traceSteps.length - 1]; traceThoughtBuffer += data.delta;
if (curStep) { if (traceRafId === null) {
getStore().updateLastTraceStep({ traceRafId = requestAnimationFrame(flushTraceThought);
thought: (curStep.thought ?? '') + data.delta,
});
} }
} }
break; break;
@@ -142,6 +200,11 @@ export function useAgentStream(): void {
const needsNewCard = !last || last.role !== 'assistant' || const needsNewCard = !last || last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration); (last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) { if (needsNewCard) {
// F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta)
if (textRafId !== null) {
cancelAnimationFrame(textRafId);
flushTextDelta();
}
getStore().addMessage({ getStore().addMessage({
id: genMsgId('assistant'), id: genMsgId('assistant'),
role: 'assistant', role: 'assistant',
@@ -155,19 +218,14 @@ export function useAgentStream(): void {
break; break;
} }
} }
// isStreaming 已在 sendMessage 时设置为 true,无需每个 delta 重复设置 // F5: 累积 delta 到缓冲区,用 rAF 每帧 commit 一次
getStore().updateLastAssistantMessage(data.delta); // 首次缓冲时记录 sessionId(用于会话切换保护)
if (textDeltaBuffer === '') {
// 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪) textBufferSessionId = data.sessionId;
// 当 assistant 消息无 reasoningContent 时,持续累积 text delta 到 thought }
const messages = getStore().messages; textDeltaBuffer += data.delta;
const lastMsg = messages[messages.length - 1]; if (textRafId === null) {
const steps = getStore().traceSteps; textRafId = requestAnimationFrame(flushTextDelta);
const step = steps[steps.length - 1];
if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) {
getStore().updateLastTraceStep({
thought: (step.thought ?? '') + data.delta,
});
} }
} }
break; break;
@@ -305,6 +363,16 @@ export function useAgentStream(): void {
// 流结束 // 流结束
case 'done': case 'done':
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
if (textRafId !== null) {
cancelAnimationFrame(textRafId);
flushTextDelta();
}
// F9: flush traceThought 缓冲区,避免最后一段 reasoning delta 丢失
if (traceRafId !== null) {
cancelAnimationFrame(traceRafId);
flushTraceThought();
}
getStore().setStreaming(false); getStore().setStreaming(false);
getStore().setCurrentRunId(null); getStore().setCurrentRunId(null);
// 不覆盖 error 状态 — error handler 已设置 agentStatus='error' // 不覆盖 error 状态 — error handler 已设置 agentStatus='error'
@@ -318,6 +386,16 @@ export function useAgentStream(): void {
// 错误 // 错误
case 'error': case 'error':
// F5: 错误前立即 flush 缓冲区,保留已接收的内容
if (textRafId !== null) {
cancelAnimationFrame(textRafId);
flushTextDelta();
}
// F9: flush traceThought 缓冲区,保留已接收的 reasoning 内容
if (traceRafId !== null) {
cancelAnimationFrame(traceRafId);
flushTraceThought();
}
getStore().setStreaming(false); getStore().setStreaming(false);
getStore().setCurrentRunId(null); getStore().setCurrentRunId(null);
getStore().setAgentStatus('error'); getStore().setAgentStatus('error');
@@ -335,6 +413,16 @@ export function useAgentStream(): void {
cleanupRef.current = unsubscribe; cleanupRef.current = unsubscribe;
return () => { return () => {
// F5: 组件卸载时清理挂起的 rAF,并 flush 残留 delta(保留已接收内容)
if (textRafId !== null) {
cancelAnimationFrame(textRafId);
flushTextDelta();
}
// F9: 清理 traceThought 的 rAF
if (traceRafId !== null) {
cancelAnimationFrame(traceRafId);
flushTraceThought();
}
cleanupRef.current?.(); cleanupRef.current?.();
}; };
}, []); }, []);
+4
View File
@@ -187,6 +187,10 @@ code, pre, .font-mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', mo
color: inherit; color: inherit;
line-height: 1.7; line-height: 1.7;
font-size: 14px; font-size: 14px;
/* F10: CSS containment 隔离布局计算
contain: layout style 隔离 markdown 渲染区域的布局和样式计算,
避免 reflow 扩散到外部容器。不含 paint 以避免裁剪代码块横向滚动溢出。 */
contain: layout style;
} }
.prose-metona h1, .prose-metona h1,
+19
View File
@@ -162,6 +162,8 @@ interface MetonaMemoryAPI {
interface MetonaConfigAPI { interface MetonaConfigAPI {
get: (key: string) => Promise<unknown>; get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<{ success: boolean }>; set: (key: string, value: unknown) => Promise<{ success: boolean }>;
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean }>;
} }
// ===== App API ===== // ===== App API =====
@@ -335,6 +337,8 @@ interface MetonaConfirmationRequest {
args: Record<string, unknown>; args: Record<string, unknown>;
riskLevel: string; riskLevel: string;
reason: string; reason: string;
/** v0.3.2: 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */
expiresAt?: number;
} }
interface MetonaToolAPI { interface MetonaToolAPI {
@@ -345,6 +349,21 @@ interface MetonaToolAPI {
remember: boolean; remember: boolean;
autoExecute?: boolean; autoExecute?: boolean;
}) => void; }) => void;
/** v0.3.2: 批量确认响应(并行工具一次性审批,支持同工具多次调用) */
sendConfirmationResponseBatch: (response: {
toolCallIds: string[];
approved: boolean;
remember: boolean;
autoExecute?: boolean;
}) => void;
/**
* v0.3.2: 拉取当前所有 pending 确认
* 前端弹框打开时主动调用,防止并行 IPC 事件在前端 state 中互相覆盖导致丢失
*/
getPendingConfirmations: () => Promise<{
success: boolean;
data: MetonaConfirmationRequest[];
}>;
/** 设置/取消工具的持久化自动执行(跨会话不再询问) */ /** 设置/取消工具的持久化自动执行(跨会话不再询问) */
setAutoExecute: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>; setAutoExecute: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
/** 获取已设置为自动执行的工具列表 */ /** 获取已设置为自动执行的工具列表 */
+18
View File
@@ -86,6 +86,24 @@
| 头像 | Avatar | 自写头像圆形容器 | | 头像 | Avatar | 自写头像圆形容器 |
| 列表 | List、ListItemButton | 自写列表项 | | 列表 | List、ListItemButton | 自写列表项 |
#### 3.2 Toast 通知铁律
**所有 Toast 通知必须使用项目封装的 `MeToast` 组件**,禁止自写 Toast 组件、禁止直接使用第三方 Toast 库(如 notistack、react-hot-toast、react-toastify 等)。
| 场景 | 规范 |
|------|------|
| 前端主动显示 Toast | 使用 `MeToast` 组件(通过 `showToast()` 或对应 hook 调用) |
| 后端通过 IPC 触发 Toast | 主进程发送 `toast:show` IPC 事件,参数 `{ type: 'success' \| 'info' \| 'warning' \| 'error', message: string }`,前端 MeToast 监听并统一渲染 |
| 类型枚举 | `success` / `info` / `warning` / `error` 四种,不得自定义 |
| 并行事件防风暴 | 同一来源的并行事件(如多工具超时)需在后端节流,不得依赖前端去重 |
**原因**
1. 统一视觉风格与动画效果
2. 集中管理 Toast 队列、堆叠顺序、自动消失时间
3. 避免 MUI Snackbar 与第三方库混用导致重复弹出或层级冲突
**例外**:无。任何 Toast 需求都必须走 MeToast 通道。
**例外**:仅当 MUI 确认不存在对应组件时,才可使用原生 HTML 元素 + Tailwind CSS 实现,且必须注释说明原因。 **例外**:仅当 MUI 确认不存在对应组件时,才可使用原生 HTML 元素 + Tailwind CSS 实现,且必须注释说明原因。
#### 4. 允许自写的场景 #### 4. 允许自写的场景