Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8973ea6d47 | ||
|
|
cd681b949a | ||
|
|
64af91bde9 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 生产级通用 AI Agent 智能体桌面应用
|
> 生产级通用 AI Agent 智能体桌面应用
|
||||||
|
|
||||||
[](./package.json)
|
[](./package.json)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://www.electronjs.org/)
|
[](https://www.electronjs.org/)
|
||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
|
|||||||
@@ -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,13 +327,22 @@ 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()) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastTimeoutToastAt > 3000) {
|
||||||
|
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', {
|
this.mainWindow.webContents.send('toast:show', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`,
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时)
|
// 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+282
-3
@@ -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 污染
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== 应用工具 =====
|
// ===== 应用工具 =====
|
||||||
|
|||||||
Generated
+2
-65
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.8",
|
"version": "0.3.11",
|
||||||
"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.11",
|
||||||
"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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.8",
|
"version": "0.3.11",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
<ErrorBoundary fallbackTitle="工具确认弹框渲染失败">
|
||||||
<ConfirmationDialog />
|
<ConfirmationDialog />
|
||||||
|
</ErrorBoundary>
|
||||||
<ContextMenuDialogHost />
|
<ContextMenuDialogHost />
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -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 开始)或 TERMINATED(run 结束/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,30 +360,96 @@ 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' }}>
|
{/* 分组列表 */}
|
||||||
|
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
|
||||||
|
{grouped.map((group) => {
|
||||||
|
const groupIds = group.requests.map((r) => r.toolCallId);
|
||||||
|
const groupAllSelected = groupIds.every((id) => selectedIds.has(id));
|
||||||
|
const groupSomeSelected = groupIds.some((id) => selectedIds.has(id));
|
||||||
|
const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default';
|
||||||
|
const isMulti = group.requests.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion
|
||||||
|
key={group.toolName}
|
||||||
|
defaultExpanded
|
||||||
|
sx={{ bgcolor: 'background.default', mb: 0.5 }}
|
||||||
|
>
|
||||||
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
|
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', pr: 1 }}>
|
||||||
参数详情 ({Object.keys(request.args).length} 个参数)
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={groupAllSelected}
|
||||||
|
indeterminate={!groupAllSelected && groupSomeSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleGroupSelect(group);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{group.toolName}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{isMulti && (
|
||||||
|
<Chip
|
||||||
|
label={`×${group.requests.length}`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
label={group.riskLevel}
|
||||||
|
color={groupRiskColor}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 20, fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails>
|
<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
|
<Box
|
||||||
component="pre"
|
component="pre"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -204,12 +459,12 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
maxHeight: 300,
|
maxHeight: 200,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Object.entries(request.args).map(([key, value]) => (
|
{Object.entries(req.args).map(([key, value]) => (
|
||||||
<Box key={key} component="div" sx={{ mb: 1 }}>
|
<Box key={key} component="div" sx={{ mb: 0.5 }}>
|
||||||
<Typography
|
<Typography
|
||||||
component="span"
|
component="span"
|
||||||
variant="caption"
|
variant="caption"
|
||||||
@@ -226,9 +481,24 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
{Object.keys(req.args).length === 0 && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
(无参数)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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() || '');
|
||||||
|
|||||||
@@ -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
@@ -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?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Vendored
+19
@@ -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 }>;
|
||||||
/** 获取已设置为自动执行的工具列表 */
|
/** 获取已设置为自动执行的工具列表 */
|
||||||
|
|||||||
@@ -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. 允许自写的场景
|
||||||
|
|||||||
Reference in New Issue
Block a user