feat: 升级至 v0.3.7 — 前后端状态同步与错误处理全量修复
核心引擎修复: - CE-1: 上下文压缩摘要 role 从 system 改为 user,避免被 adapter 过滤 - CE-2: 工具失败时优先使用 error 字段(engine/openai-format/ollama 三处) - P0-1: DeadLoopError 终止时正确传 error 参数,前端可见 ERROR 事件 - MT-1: 新增 waitForAbort 方法,abortSession 等待 run 结束再返回 - MT-2: TERMINATED 状态到达时标记步骤完成,避免 Trace Viewer 转圈 - MT-3: 压缩边界检测孤立 tool 消息,避免 API 400 错误 - isRetryableError 与 catch 分支统一 toLowerCase IPC 与主进程修复: - P0-2: createAdapter 配置缺失返回 null,FALLBACK_ADAPTER 兜底 - P0-3: reloadAdapter 失败返回 success:false 通知前端 - P1-5: 校验失败发 ERROR+DONE 流事件,防止 isStreaming 卡死 - P1-6: configLoaded 标志,配置加载前禁用发送按钮 - P1-7: MCP initialize 移到 agentLoop 后,完成后同步工具 - P2-11: beforeLoad 在 loadURL 前注册 IPC handler - P2-12: provider 切换竞态保护 前端状态同步修复: - clearSessions 后同步清空前端会话与消息状态 - clearMemories 通过 memoryVersion 触发 MemoryViewer 重新加载 - ContextMenu 4 个 session 操作补全 IPC 调用与 try/catch - useConfig 配置保存失败回滚 UI 并提示 - handleToggle 工具切换失败回滚单个工具状态 错误处理全量补全: - 所有 await window.metona 调用补全 try/catch 与 toast 反馈 - MCP addServer/toggleServer/removeServer 检查返回值 - showItemInFolder 检查返回值(handleOpen/handleOpenInFolder) - sse-stream/ollama NDJSON 解析失败改为 log.warn - adapter throwHttpError 带 status 属性供 isRetryableError 判断
This commit is contained in:
@@ -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/)
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ export class AgnesAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.text().catch(() => '');
|
await this.throwHttpError(response, 'Agnes AI API error');
|
||||||
throw new Error(`Agnes AI API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as Record<string, unknown>;
|
const data = await response.json() as Record<string, unknown>;
|
||||||
@@ -102,11 +101,12 @@ export class AgnesAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error(`Agnes AI stream error: ${response.status}`);
|
await this.throwHttpError(response, 'Agnes AI stream error');
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* parseSSEStream(
|
yield* parseSSEStream(
|
||||||
response.body,
|
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||||
|
response.body!,
|
||||||
request.meta.requestId,
|
request.meta.requestId,
|
||||||
request.meta.sessionId,
|
request.meta.sessionId,
|
||||||
request.meta.iteration,
|
request.meta.iteration,
|
||||||
|
|||||||
@@ -111,6 +111,18 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
|||||||
return this.supportedModels.map((id) => ({ id }));
|
return this.supportedModels.map((id) => ({ id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出
|
||||||
|
* engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx)
|
||||||
|
*/
|
||||||
|
protected async throwHttpError(response: Response, context: string): Promise<never> {
|
||||||
|
let errorBody = '';
|
||||||
|
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
|
||||||
|
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
|
||||||
|
(error as Error & { status: number }).status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将原生错误映射为 MetonaError
|
* 将原生错误映射为 MetonaError
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ export class DeepSeekAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.text().catch(() => '');
|
await this.throwHttpError(response, 'DeepSeek API error');
|
||||||
throw new Error(`DeepSeek API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as Record<string, unknown>;
|
const data = await response.json() as Record<string, unknown>;
|
||||||
@@ -107,11 +106,13 @@ export class DeepSeekAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error(`DeepSeek stream error: ${response.status}`);
|
await this.throwHttpError(response, 'DeepSeek stream error');
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* parseSSEStream(
|
yield* parseSSEStream(
|
||||||
response.body,
|
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||||
|
// TypeScript 无法通过 await Promise<never> 正确收窄,需显式断言
|
||||||
|
response.body!,
|
||||||
request.meta.requestId,
|
request.meta.requestId,
|
||||||
request.meta.sessionId,
|
request.meta.sessionId,
|
||||||
request.meta.iteration,
|
request.meta.iteration,
|
||||||
|
|||||||
@@ -71,8 +71,7 @@ export class MimoAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.text().catch(() => '');
|
await this.throwHttpError(response, 'MiMo API error');
|
||||||
throw new Error(`MiMo API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as Record<string, unknown>;
|
const data = await response.json() as Record<string, unknown>;
|
||||||
@@ -111,11 +110,12 @@ export class MimoAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error(`MiMo stream error: ${response.status}`);
|
await this.throwHttpError(response, 'MiMo stream error');
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* parseSSEStream(
|
yield* parseSSEStream(
|
||||||
response.body,
|
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||||
|
response.body!,
|
||||||
request.meta.requestId,
|
request.meta.requestId,
|
||||||
request.meta.sessionId,
|
request.meta.sessionId,
|
||||||
request.meta.iteration,
|
request.meta.iteration,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Ollama API error: ${response.status}`);
|
await this.throwHttpError(response, 'Ollama API error');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as Record<string, unknown>;
|
const data = await response.json() as Record<string, unknown>;
|
||||||
@@ -81,10 +81,11 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error(`Ollama stream error: ${response.status}`);
|
await this.throwHttpError(response, 'Ollama stream error');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
// 非空断言:上方 if 已确保 response.body 不为 null
|
||||||
|
const reader = response.body!.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
@@ -183,8 +184,9 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (parseErr) {
|
||||||
// 跳过解析失败的行
|
// P2-8 修复: 与 sse-stream.ts 一致,记录解析失败行便于诊断
|
||||||
|
log.warn(`[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`, trimmed.slice(0, 200));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,9 +438,12 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
// 工具结果
|
// 工具结果
|
||||||
if (m.role === 'tool' && m.toolResult) {
|
if (m.role === 'tool' && m.toolResult) {
|
||||||
msg.tool_call_id = m.toolResult.toolCallId;
|
msg.tool_call_id = m.toolResult.toolCallId;
|
||||||
msg.content = typeof m.toolResult.result === 'string'
|
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
|
||||||
|
msg.content = m.toolResult.error
|
||||||
|
? m.toolResult.error
|
||||||
|
: (typeof m.toolResult.result === 'string'
|
||||||
? m.toolResult.result
|
? m.toolResult.result
|
||||||
: JSON.stringify(m.toolResult.result);
|
: JSON.stringify(m.toolResult.result));
|
||||||
}
|
}
|
||||||
// assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串)
|
// assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串)
|
||||||
if (m.role === 'assistant' && m.toolCalls?.length) {
|
if (m.role === 'assistant' && m.toolCalls?.length) {
|
||||||
|
|||||||
@@ -67,10 +67,13 @@ export function buildOpenAICompatibleMessages(
|
|||||||
// === 工具执行结果 ===
|
// === 工具执行结果 ===
|
||||||
if (m.role === 'tool' && m.toolResult) {
|
if (m.role === 'tool' && m.toolResult) {
|
||||||
msg.tool_call_id = m.toolResult.toolCallId;
|
msg.tool_call_id = m.toolResult.toolCallId;
|
||||||
msg.content =
|
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
|
||||||
typeof m.toolResult.result === 'string'
|
// 否则 LLM 看到 "null" 不知道失败原因,可能重复调用导致死循环
|
||||||
|
msg.content = m.toolResult.error
|
||||||
|
? m.toolResult.error
|
||||||
|
: (typeof m.toolResult.result === 'string'
|
||||||
? m.toolResult.result
|
? m.toolResult.result
|
||||||
: JSON.stringify(m.toolResult.result);
|
: JSON.stringify(m.toolResult.result));
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import log from 'electron-log';
|
||||||
import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types';
|
import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types';
|
||||||
import { MetonaStreamEventType } from '../../types';
|
import { MetonaStreamEventType } from '../../types';
|
||||||
|
|
||||||
@@ -198,8 +199,9 @@ export async function* parseSSEStream(
|
|||||||
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
||||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (parseErr) {
|
||||||
// 跳过解析失败的行
|
// P2-8 修复: 不再静默跳过,记录 warning 便于排查 SSE 数据损坏
|
||||||
|
log.warn(`[SSE] Failed to parse stream line: ${(parseErr as Error).message}`, line.slice(0, 200));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,11 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
for (const result of step.toolResults) {
|
for (const result of step.toolResults) {
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
content: typeof result.result === 'string' ? result.result : JSON.stringify(result.result),
|
// CE-2 修复: 工具失败时 result.result 为 null,LLM 会看到 "null" 而非错误信息
|
||||||
|
// 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环
|
||||||
|
content: result.error
|
||||||
|
? result.error
|
||||||
|
: (typeof result.result === 'string' ? result.result : JSON.stringify(result.result)),
|
||||||
toolResult: result,
|
toolResult: result,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
iteration: this.currentIteration,
|
iteration: this.currentIteration,
|
||||||
@@ -274,11 +278,16 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
return this.finish(TerminationReason.TIMEOUT);
|
return this.finish(TerminationReason.TIMEOUT);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// v0.3.0: 捕获 DeadLoopError — 以 DEAD_LOOP 原因终止
|
// v0.3.0: 捕获 DeadLoopError — 以 DEAD_LOOP 原因终止
|
||||||
|
// P0-1 审查修复: 必须传 error 参数(第三参数),否则 finish() 不会发射 ERROR 事件
|
||||||
|
// 前端只收到 DONE 会导致 agentStatus 被设为 'idle' 而非 'error',且无错误消息卡片
|
||||||
if (error instanceof DeadLoopError) {
|
if (error instanceof DeadLoopError) {
|
||||||
return this.finish(TerminationReason.DEAD_LOOP, error.message);
|
return this.finish(TerminationReason.DEAD_LOOP, undefined, error as Error);
|
||||||
}
|
}
|
||||||
const errMsg = (error as Error).message;
|
const errMsg = (error as Error).message ?? '';
|
||||||
if (this.aborted || errMsg.includes('Aborted')) {
|
// P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR
|
||||||
|
// Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖
|
||||||
|
const errMsgLower = errMsg.toLowerCase();
|
||||||
|
if (this.aborted || errMsgLower.includes('aborted') || errMsgLower.includes('timed out') || errMsgLower.includes('timeout')) {
|
||||||
return this.finish(TerminationReason.USER_INTERRUPT);
|
return this.finish(TerminationReason.USER_INTERRUPT);
|
||||||
}
|
}
|
||||||
// v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw,
|
// v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw,
|
||||||
@@ -310,6 +319,29 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
this.emit('aborted');
|
this.emit('aborted');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MT-1 修复: 等待当前 run 结束(用于 abortSession IPC handler)
|
||||||
|
*
|
||||||
|
* abort() 只是设置了标志和触发了 abortController,
|
||||||
|
* 但 currentRunPromise 仍在进行中(可能在等待工具超时或 LLM 响应)。
|
||||||
|
* 如果不等待就返回,用户立即重发会导致新消息卡在 runStream 的 currentRunPromise 等待中。
|
||||||
|
*
|
||||||
|
* @param timeoutMs 等待超时(默认 5 秒,防止永久挂起)
|
||||||
|
* @returns true 表示 run 已结束,false 表示等待超时
|
||||||
|
*/
|
||||||
|
async waitForAbort(timeoutMs: number = 5_000): Promise<boolean> {
|
||||||
|
if (!this.currentRunPromise) return true;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
this.currentRunPromise.catch(() => {}),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 销毁引擎,清理所有监听器
|
* 销毁引擎,清理所有监听器
|
||||||
*/
|
*/
|
||||||
@@ -854,8 +886,10 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
if (err.status && err.status >= 500 && err.status < 600) return true;
|
if (err.status && err.status >= 500 && err.status < 600) return true;
|
||||||
// 网络超时/连接错误 — 可重试
|
// 网络超时/连接错误 — 可重试
|
||||||
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true;
|
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true;
|
||||||
// SSE 流中断 — 可重试
|
// P2-9 一致性修复: toLowerCase 避免大小写敏感漏判
|
||||||
if (err.message?.includes('aborted') || err.message?.includes('socket hang up')) return true;
|
// SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截)
|
||||||
|
const msg = err.message?.toLowerCase() ?? '';
|
||||||
|
if (msg.includes('aborted') || msg.includes('socket hang up')) return true;
|
||||||
// 其他错误(400/401/403/4xx)不重试
|
// 其他错误(400/401/403/4xx)不重试
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -954,8 +988,34 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool)
|
const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool)
|
||||||
if (messages.length <= keepRecent) return null;
|
if (messages.length <= keepRecent) return null;
|
||||||
|
|
||||||
const toCompress = messages.slice(0, messages.length - keepRecent);
|
let toCompress = messages.slice(0, messages.length - keepRecent);
|
||||||
const toKeep = messages.slice(messages.length - keepRecent);
|
let toKeep = messages.slice(messages.length - keepRecent);
|
||||||
|
|
||||||
|
// MT-3 修复: 确保 toKeep 不以孤立的 tool 消息开头
|
||||||
|
// OpenAI/DeepSeek API 要求 tool 消息前必须有带 tool_calls 的 assistant 消息
|
||||||
|
// 如果压缩边界正好切在 assistant(tool_calls) 和 tool 之间,下一轮 LLM 调用会返回 400
|
||||||
|
if (toKeep.length > 0 && toKeep[0].role === 'tool' && toCompress.length > 0) {
|
||||||
|
// 反向查找最近的带 tool_calls 的 assistant 消息
|
||||||
|
let assistantIdx = -1;
|
||||||
|
for (let i = toCompress.length - 1; i >= 0; i--) {
|
||||||
|
if (toCompress[i].role === 'assistant' && toCompress[i].toolCalls?.length) {
|
||||||
|
assistantIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (assistantIdx < 0) {
|
||||||
|
// 找不到配对的 assistant 消息,直接丢弃孤立的 tool 消息
|
||||||
|
toKeep = toKeep.slice(1);
|
||||||
|
} else {
|
||||||
|
// 将配对的 assistant 消息及其之后的所有消息移到 toKeep 开头
|
||||||
|
const moved = toCompress.slice(assistantIdx);
|
||||||
|
toCompress = toCompress.slice(0, assistantIdx);
|
||||||
|
toKeep = [...moved, ...toKeep];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 toCompress 为空,无法生成摘要
|
||||||
|
if (toCompress.length === 0) return null;
|
||||||
|
|
||||||
// 构建摘要请求
|
// 构建摘要请求
|
||||||
// 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口
|
// 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口
|
||||||
@@ -998,7 +1058,11 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
|
|
||||||
const summaryMessage: MetonaMessage = {
|
const summaryMessage: MetonaMessage = {
|
||||||
role: 'system',
|
// CE-1 修复: 使用 role: 'user' 而非 'system'
|
||||||
|
// adapter 的 buildOpenAICompatibleMessages 会过滤所有 role !== 'system' 的消息,
|
||||||
|
// 只保留 systemPrompt 构建的 system 消息。如果用 'system',摘要会被丢弃,压缩无效。
|
||||||
|
// 改为 'user' 后 LLM 会看到:system(systemPrompt) → user(摘要) → ...toKeep
|
||||||
|
role: 'user',
|
||||||
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
|
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -1023,6 +1087,26 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
answer?: string,
|
answer?: string,
|
||||||
error?: Error,
|
error?: Error,
|
||||||
): AgentLoopOutput {
|
): AgentLoopOutput {
|
||||||
|
// P0-1 修复: ERROR/DEAD_LOOP 终止时先发 ERROR 流式事件,让前端能看到错误
|
||||||
|
// v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见
|
||||||
|
// 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw
|
||||||
|
if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) {
|
||||||
|
this.emit('streamEvent', {
|
||||||
|
type: MetonaStreamEventType.ERROR,
|
||||||
|
requestId: this.currentRequestId,
|
||||||
|
sessionId: this.currentSessionId,
|
||||||
|
iteration: this.currentIteration,
|
||||||
|
seq: this.nextSeq(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
runId: this.runId,
|
||||||
|
error: {
|
||||||
|
code: MetonaErrorCode.UNKNOWN,
|
||||||
|
message: error.message,
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 发送唯一的最终 DONE 事件 — UI 只在此处结束流式状态
|
// 发送唯一的最终 DONE 事件 — UI 只在此处结束流式状态
|
||||||
this.emit('streamEvent', {
|
this.emit('streamEvent', {
|
||||||
type: MetonaStreamEventType.DONE,
|
type: MetonaStreamEventType.DONE,
|
||||||
@@ -1032,6 +1116,7 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
seq: this.nextSeq(),
|
seq: this.nextSeq(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
runId: this.runId,
|
runId: this.runId,
|
||||||
|
terminationReason: reason,
|
||||||
});
|
});
|
||||||
|
|
||||||
// H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI)
|
// H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI)
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ export interface MetonaStreamEvent {
|
|||||||
|
|
||||||
/** ERROR */
|
/** ERROR */
|
||||||
error?: MetonaError;
|
error?: MetonaError;
|
||||||
|
|
||||||
|
/** DONE — 终止原因(前端可据此区分正常完成/错误/中断) */
|
||||||
|
terminationReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 思考内容 =====
|
// ===== 思考内容 =====
|
||||||
|
|||||||
@@ -59,12 +59,31 @@ export function registerAllIPCHandlers(
|
|||||||
|
|
||||||
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
|
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
|
||||||
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
|
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
|
||||||
|
// P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死
|
||||||
if (!sessionId || typeof sessionId !== 'string') {
|
if (!sessionId || typeof sessionId !== 'string') {
|
||||||
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
|
||||||
|
if (!mainWindow.isDestroyed()) {
|
||||||
|
const errorEvent: MetonaStreamEvent = {
|
||||||
|
type: MetonaStreamEventType.ERROR,
|
||||||
|
requestId: '', sessionId: sessionId ?? '', iteration: 0, seq: 0, timestamp: Date.now(),
|
||||||
|
error: { code: MetonaErrorCode.UNKNOWN, message: '无效的会话 ID', retryable: false },
|
||||||
|
};
|
||||||
|
mainWindow.webContents.send('agent:streamEvent', errorEvent);
|
||||||
|
mainWindow.webContents.send('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
|
||||||
|
}
|
||||||
return { success: false, error: 'Invalid sessionId' };
|
return { success: false, error: 'Invalid sessionId' };
|
||||||
}
|
}
|
||||||
if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') {
|
if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') {
|
||||||
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
|
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
|
||||||
|
if (!mainWindow.isDestroyed()) {
|
||||||
|
const errorEvent: MetonaStreamEvent = {
|
||||||
|
type: MetonaStreamEventType.ERROR,
|
||||||
|
requestId: '', sessionId, iteration: 0, seq: 0, timestamp: Date.now(),
|
||||||
|
error: { code: MetonaErrorCode.UNKNOWN, message: '无效的消息格式', retryable: false },
|
||||||
|
};
|
||||||
|
mainWindow.webContents.send('agent:streamEvent', errorEvent);
|
||||||
|
mainWindow.webContents.send('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
|
||||||
|
}
|
||||||
return { success: false, error: 'Invalid message format' };
|
return { success: false, error: 'Invalid message format' };
|
||||||
}
|
}
|
||||||
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
|
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
|
||||||
@@ -404,6 +423,9 @@ export function registerAllIPCHandlers(
|
|||||||
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
||||||
log.info('[AGENT] Abort:', sessionId);
|
log.info('[AGENT] Abort:', sessionId);
|
||||||
agentLoop.abort();
|
agentLoop.abort();
|
||||||
|
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在
|
||||||
|
// runStream 的 currentRunPromise 等待中(最长卡 120s 工具超时)
|
||||||
|
await agentLoop.waitForAbort();
|
||||||
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
||||||
confirmationHook.clearPending();
|
confirmationHook.clearPending();
|
||||||
|
|
||||||
@@ -670,10 +692,11 @@ export function registerAllIPCHandlers(
|
|||||||
// v0.3.1: 加入 deepseek.contextWindow / agnes.contextWindow / mimo.contextWindow,使上下文窗口配置变化也触发热重载
|
// v0.3.1: 加入 deepseek.contextWindow / agnes.contextWindow / mimo.contextWindow,使上下文窗口配置变化也触发热重载
|
||||||
if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx',
|
if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx',
|
||||||
'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow'].includes(key)) {
|
'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow'].includes(key)) {
|
||||||
// C-1 修复: reloadAdapter 返回 false 时表示加载失败,需要通知前端
|
// P0-3 修复: reloadAdapter 失败时返回 success:false,让前端知道配置不完整
|
||||||
const reloadSuccess = reloadAdapter();
|
const reloadSuccess = reloadAdapter();
|
||||||
if (!reloadSuccess) {
|
if (!reloadSuccess) {
|
||||||
log.warn(`[CONFIG] Adapter reload failed after ${key} change`);
|
log.warn(`[CONFIG] Adapter reload failed after ${key} change`);
|
||||||
|
return { success: false, error: 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写' };
|
||||||
} else {
|
} else {
|
||||||
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
|
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-18
@@ -38,6 +38,7 @@ import { DeepSeekAdapter } from './harness/adapters/deepseek.adapter';
|
|||||||
import { AgnesAdapter } from './harness/adapters/agnes-ai.adapter';
|
import { AgnesAdapter } from './harness/adapters/agnes-ai.adapter';
|
||||||
import { MimoAdapter } from './harness/adapters/mimo.adapter';
|
import { MimoAdapter } from './harness/adapters/mimo.adapter';
|
||||||
import { OllamaAdapter } from './harness/adapters/ollama.adapter';
|
import { OllamaAdapter } from './harness/adapters/ollama.adapter';
|
||||||
|
import type { IMetonaProviderAdapter } from './harness/types/metona-adapter';
|
||||||
import {
|
import {
|
||||||
ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
|
ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
|
||||||
WebSearchTool, WebFetchTool,
|
WebSearchTool, WebFetchTool,
|
||||||
@@ -140,18 +141,22 @@ async function initialize(): Promise<void> {
|
|||||||
memoryManager.initialize();
|
memoryManager.initialize();
|
||||||
|
|
||||||
// ===== 步骤 4: Provider Adapter 工厂 =====
|
// ===== 步骤 4: Provider Adapter 工厂 =====
|
||||||
const createAdapter = () => {
|
// P0-2 修复: 配置缺失时返回 null 而非抛异常,让应用能启动到 Onboarding
|
||||||
|
const createAdapter = (): IMetonaProviderAdapter | null => {
|
||||||
const provider = configService.get<string>('llm.provider') ?? '';
|
const provider = configService.get<string>('llm.provider') ?? '';
|
||||||
const model = configService.get<string>('llm.model') ?? '';
|
const model = configService.get<string>('llm.model') ?? '';
|
||||||
const apiKey = configService.get<string>('llm.apiKey') ?? '';
|
const apiKey = configService.get<string>('llm.apiKey') ?? '';
|
||||||
const baseURL = configService.get<string>('llm.baseURL') ?? '';
|
const baseURL = configService.get<string>('llm.baseURL') ?? '';
|
||||||
|
|
||||||
|
// 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding)
|
||||||
if (!provider || !baseURL || !model) {
|
if (!provider || !baseURL || !model) {
|
||||||
log.warn('LLM not configured. Please set provider, baseURL, and model in Settings.');
|
log.warn('LLM not fully configured. Please set provider, baseURL, and model in Settings.');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKey && provider !== 'ollama') {
|
if (!apiKey && provider !== 'ollama') {
|
||||||
throw new Error(`API key is required for provider "${provider || 'unknown'}". Please set it in Settings.`);
|
log.warn(`API key is required for provider "${provider}". Please set it in Settings.`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.3.1: 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
|
// v0.3.1: 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
|
||||||
@@ -168,7 +173,12 @@ async function initialize(): Promise<void> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const adapter = createAdapter();
|
// 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但 P0-1 修复后前端可见
|
||||||
|
const FALLBACK_ADAPTER = new DeepSeekAdapter({
|
||||||
|
provider: '', baseURL: '', apiKey: '', defaultModel: '', contextWindow: 4096,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adapter = createAdapter() ?? FALLBACK_ADAPTER;
|
||||||
|
|
||||||
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
||||||
const contextBuilder = new ContextBuilder();
|
const contextBuilder = new ContextBuilder();
|
||||||
@@ -242,9 +252,7 @@ async function initialize(): Promise<void> {
|
|||||||
|
|
||||||
// ===== MCP Manager =====
|
// ===== MCP Manager =====
|
||||||
const mcpManager = new MCPManager(() => db, toolRegistry);
|
const mcpManager = new MCPManager(() => db, toolRegistry);
|
||||||
mcpManager.initialize().catch((err) => {
|
// P1-7 修复: initialize() 移到 agentLoop 创建之后,完成后重新同步工具
|
||||||
log.warn('MCP Manager initialization error:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== Hooks =====
|
// ===== Hooks =====
|
||||||
// v0.2.0: ConfirmationHook 注入到 preToolHooks 管道
|
// v0.2.0: ConfirmationHook 注入到 preToolHooks 管道
|
||||||
@@ -285,6 +293,14 @@ async function initialize(): Promise<void> {
|
|||||||
agentLoop.setTools(toolRegistry.listTools());
|
agentLoop.setTools(toolRegistry.listTools());
|
||||||
agentLoop.setWorkspacePath(workspaceInfo.path);
|
agentLoop.setWorkspacePath(workspaceInfo.path);
|
||||||
|
|
||||||
|
// P1-7 修复: MCP 初始化在 agentLoop 创建后执行,完成后重新同步工具到 AgentLoop
|
||||||
|
mcpManager.initialize().then(() => {
|
||||||
|
agentLoop.setTools(toolRegistry.listTools());
|
||||||
|
log.info('[MCP] Tools registered and synced to AgentLoop');
|
||||||
|
}).catch((err) => {
|
||||||
|
log.warn('MCP Manager initialization error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)=====
|
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)=====
|
||||||
const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService);
|
const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService);
|
||||||
|
|
||||||
@@ -343,6 +359,17 @@ async function initialize(): Promise<void> {
|
|||||||
|
|
||||||
// 配置变化 — 重建 adapter
|
// 配置变化 — 重建 adapter
|
||||||
const newAdapter = createAdapter();
|
const newAdapter = createAdapter();
|
||||||
|
if (!newAdapter) {
|
||||||
|
log.warn('[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)');
|
||||||
|
// 不更新 lastConfigSig,下次还会尝试
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('toast:show', {
|
||||||
|
type: 'warning',
|
||||||
|
message: 'LLM 配置不完整,请在设置中补全 Provider、API Key、Base URL 和 Model',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
agentLoop.setAdapter(newAdapter);
|
agentLoop.setAdapter(newAdapter);
|
||||||
memoryConsolidator.setAdapter(newAdapter);
|
memoryConsolidator.setAdapter(newAdapter);
|
||||||
// Provider 切换时同步 contextLength 和 contextWindow
|
// Provider 切换时同步 contextLength 和 contextWindow
|
||||||
@@ -399,10 +426,21 @@ async function initialize(): Promise<void> {
|
|||||||
id: 'main',
|
id: 'main',
|
||||||
workspacePath: workspaceInfo.path,
|
workspacePath: workspaceInfo.path,
|
||||||
title: 'MetonaAI Desktop',
|
title: 'MetonaAI Desktop',
|
||||||
|
// P2-11 修复: 在 loadURL 之前注册 IPC handler,消除渲染进程加载与 IPC 注册的时序窗口
|
||||||
|
beforeLoad: (win) => {
|
||||||
|
confirmationHook.setMainWindow(win);
|
||||||
|
registerAllIPCHandlers(
|
||||||
|
win, sessionService, configService, workspaceService,
|
||||||
|
contextBuilder, agentLoop, toolRegistry, auditService,
|
||||||
|
sessionRecorder, memoryManager, mcpManager, reloadAdapter,
|
||||||
|
promptDefender, outputValidator,
|
||||||
|
confirmationHook, memoryConsolidator, orchestrator,
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// v0.2.0: 为 ConfirmationHook 注入主窗口(用于向渲染进程发送确认请求)
|
// P2-11 修复: ConfirmationHook.setMainWindow 已在 beforeLoad 中调用,此处无需重复
|
||||||
confirmationHook.setMainWindow(mainWindow);
|
// (beforeLoad 在 loadURL 之前执行,确保 IPC handler 注册时 mainWindow 已注入)
|
||||||
|
|
||||||
// ===== 系统托盘 =====
|
// ===== 系统托盘 =====
|
||||||
const resourcesPath = join(__dirname, '../../assets');
|
const resourcesPath = join(__dirname, '../../assets');
|
||||||
@@ -432,15 +470,6 @@ async function initialize(): Promise<void> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== 注册 IPC =====
|
|
||||||
registerAllIPCHandlers(
|
|
||||||
mainWindow, sessionService, configService, workspaceService,
|
|
||||||
contextBuilder, agentLoop, toolRegistry, auditService,
|
|
||||||
sessionRecorder, memoryManager, mcpManager, reloadAdapter,
|
|
||||||
promptDefender, outputValidator,
|
|
||||||
confirmationHook, memoryConsolidator, orchestrator,
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Initialize UpdateService for auto-update functionality
|
// TODO: Initialize UpdateService for auto-update functionality
|
||||||
// const updateService = new UpdateService();
|
// const updateService = new UpdateService();
|
||||||
// updateService.initialize(mainWindow);
|
// updateService.initialize(mainWindow);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class WindowManager {
|
|||||||
workspacePath?: string;
|
workspacePath?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
state?: WindowState;
|
state?: WindowState;
|
||||||
|
beforeLoad?: (win: BrowserWindow) => void;
|
||||||
}): BrowserWindow {
|
}): BrowserWindow {
|
||||||
const id = options.id ?? `window_${Date.now()}`;
|
const id = options.id ?? `window_${Date.now()}`;
|
||||||
const state = options.state ?? { width: 1440, height: 900 };
|
const state = options.state ?? { width: 1440, height: 900 };
|
||||||
@@ -59,6 +60,9 @@ export class WindowManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// P2-11 修复: 在 loadURL 之前执行回调,确保 IPC handler 在渲染进程加载前注册
|
||||||
|
if (options.beforeLoad) options.beforeLoad(win);
|
||||||
|
|
||||||
// 加载页面
|
// 加载页面
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
win.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
win.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.4",
|
"version": "0.3.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.4",
|
"version": "0.3.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.4",
|
"version": "0.3.7",
|
||||||
"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",
|
||||||
|
|||||||
@@ -52,7 +52,12 @@ export default function App(): React.JSX.Element {
|
|||||||
const provider = providerResult.status === 'fulfilled' ? (providerResult.value as string) || '' : '';
|
const provider = providerResult.status === 'fulfilled' ? (providerResult.value as string) || '' : '';
|
||||||
const model = modelResult.status === 'fulfilled' ? (modelResult.value as string) || '' : '';
|
const model = modelResult.status === 'fulfilled' ? (modelResult.value as string) || '' : '';
|
||||||
if (provider || model) useAgentStore.getState().setProvider(provider, model);
|
if (provider || model) useAgentStore.getState().setProvider(provider, model);
|
||||||
|
// P1-6 修复: 配置加载完成,启用发送按钮
|
||||||
|
useAgentStore.getState().setConfigLoaded(true);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// 无 IPC 可用时也标记为已加载(避免永久禁用)
|
||||||
|
useAgentStore.getState().setConfigLoaded(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,20 @@ export function CommandPalette(): React.JSX.Element {
|
|||||||
|
|
||||||
const getCommands = useCallback((): CommandItem[] => {
|
const getCommands = useCallback((): CommandItem[] => {
|
||||||
const cmds: CommandItem[] = [
|
const cmds: CommandItem[] = [
|
||||||
{ id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => { if (window.metona?.sessions?.create) { const r = await window.metona.sessions.create() as MetonaSessionInfo; useSessionStore.getState().addSession(r); useSessionStore.getState().setCurrentSession(r.id); useAgentStore.getState().setCurrentSession(r.id); } setOpen(false); } },
|
{ id: 'new-session', icon: Plus, label: '新建会话', description: '创建一个新的对话会话', action: async () => {
|
||||||
|
if (window.metona?.sessions?.create) {
|
||||||
|
try {
|
||||||
|
const r = await window.metona.sessions.create() as MetonaSessionInfo;
|
||||||
|
useSessionStore.getState().addSession(r);
|
||||||
|
useSessionStore.getState().setCurrentSession(r.id);
|
||||||
|
useAgentStore.getState().setCurrentSession(r.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CommandPalette]', err);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
} },
|
||||||
{ id: 'clear', icon: Trash2, label: '清空当前会话', action: () => { useAgentStore.getState().clearMessages(); setOpen(false); } },
|
{ id: 'clear', icon: Trash2, label: '清空当前会话', action: () => { useAgentStore.getState().clearMessages(); setOpen(false); } },
|
||||||
{ id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
|
{ id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -75,24 +75,88 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
|||||||
|
|
||||||
case 'session': {
|
case 'session': {
|
||||||
const sid = (data as { sessionId?: string })?.sessionId;
|
const sid = (data as { sessionId?: string })?.sessionId;
|
||||||
|
// 修复: 4 个 session 操作统一改为 await IPC + 失败回滚,避免 UI 与 DB 状态不一致
|
||||||
|
// 之前 archive 仅调用前端 store 未调用 IPC,rename/pin/delete 未等待 IPC 结果即更新 UI
|
||||||
|
const showError = (msg: string) => {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(msg)).catch(() => {});
|
||||||
|
};
|
||||||
return [
|
return [
|
||||||
{ id: 'rename', icon: Edit, label: '重命名', action: () => {
|
{ id: 'rename', icon: Edit, label: '重命名', action: async () => {
|
||||||
|
if (!sid) return;
|
||||||
// window.prompt may be unreliable in Electron; fall back to sidebar double-click rename
|
// window.prompt may be unreliable in Electron; fall back to sidebar double-click rename
|
||||||
if (sid) { try { const t = prompt('新会话名称:'); if (t?.trim()) { window.metona?.sessions.rename(sid, t.trim()); useSessionStore.getState().updateSession(sid, { title: t.trim() }); } } catch { /* prompt unavailable, user can rename via sidebar double-click */ } }
|
let t: string | null = null;
|
||||||
|
try { t = prompt('新会话名称:'); } catch { /* prompt unavailable, user can rename via sidebar double-click */ return; }
|
||||||
|
if (!t?.trim()) return;
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.sessions?.rename(sid, t.trim());
|
||||||
|
if (r?.success) {
|
||||||
|
useSessionStore.getState().updateSession(sid, { title: t.trim() });
|
||||||
|
} else {
|
||||||
|
showError(r?.error ?? '重命名失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ContextMenu]', err);
|
||||||
|
showError('重命名失败');
|
||||||
|
}
|
||||||
}},
|
}},
|
||||||
{ id: 'pin', icon: Pin, label: '置顶', action: () => {
|
{ id: 'pin', icon: Pin, label: '置顶', action: async () => {
|
||||||
if (sid) { const s = useSessionStore.getState().sessions.find((x) => x.id === sid); if (s) { window.metona?.sessions.pin(sid, !s.pinned); useSessionStore.getState().pinSession(sid, !s.pinned); } }
|
if (!sid) return;
|
||||||
|
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
|
||||||
|
if (!s) return;
|
||||||
|
const newPinned = !s.pinned;
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.sessions?.pin(sid, newPinned);
|
||||||
|
if (r?.success) {
|
||||||
|
useSessionStore.getState().pinSession(sid, newPinned);
|
||||||
|
} else {
|
||||||
|
showError(r?.error ?? '置顶失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ContextMenu]', err);
|
||||||
|
showError('置顶失败');
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
// 修复: archive 之前完全未调用 IPC,UI 改了但 DB 没改,重启后状态丢失
|
||||||
|
{ id: 'archive', icon: Archive, label: '归档', action: async () => {
|
||||||
|
if (!sid) return;
|
||||||
|
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
|
||||||
|
const newArchived = s ? !s.archived : true;
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.sessions?.archive(sid, newArchived);
|
||||||
|
if (r?.success) {
|
||||||
|
useSessionStore.getState().archiveSession(sid, newArchived);
|
||||||
|
} else {
|
||||||
|
showError(r?.error ?? '归档失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ContextMenu]', err);
|
||||||
|
showError('归档失败');
|
||||||
|
}
|
||||||
}},
|
}},
|
||||||
{ id: 'archive', icon: Archive, label: '归档', action: () => { if (sid) useSessionStore.getState().archiveSession(sid, true); }},
|
|
||||||
{ id: 'export', icon: FileDown, label: '导出', action: () => {
|
{ id: 'export', icon: FileDown, label: '导出', action: () => {
|
||||||
if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => {
|
if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => {
|
||||||
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
|
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
|
||||||
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click();
|
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click();
|
||||||
}).catch((err) => console.error('[ContextMenu]', err));
|
}).catch((err) => {
|
||||||
|
console.error('[ContextMenu]', err);
|
||||||
|
showError('导出失败');
|
||||||
|
});
|
||||||
}},
|
}},
|
||||||
{ id: 'delete', icon: Trash2, label: '删除', action: () => {
|
{ id: 'delete', icon: Trash2, label: '删除', action: async () => {
|
||||||
|
if (!sid) return;
|
||||||
// window.confirm may be unreliable in Electron
|
// window.confirm may be unreliable in Electron
|
||||||
if (sid) { try { if (confirm('确定删除此会话?')) { window.metona?.sessions.delete(sid); useSessionStore.getState().removeSession(sid); } } catch { /* confirm unavailable */ } }
|
try { if (!confirm('确定删除此会话?')) return; } catch { /* confirm unavailable */ return; }
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.sessions?.delete(sid);
|
||||||
|
if (r?.success) {
|
||||||
|
useSessionStore.getState().removeSession(sid);
|
||||||
|
} else {
|
||||||
|
showError(r?.error ?? '删除失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ContextMenu]', err);
|
||||||
|
showError('删除失败');
|
||||||
|
}
|
||||||
}},
|
}},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
const sendMessage = useAgentStore((s) => s.sendMessage);
|
const sendMessage = useAgentStore((s) => s.sendMessage);
|
||||||
const abort = useAgentStore((s) => s.abort);
|
const abort = useAgentStore((s) => s.abort);
|
||||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||||
|
const configLoaded = useAgentStore((s) => s.configLoaded);
|
||||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||||
const provider = useAgentStore((s) => s.provider);
|
const provider = useAgentStore((s) => s.provider);
|
||||||
@@ -171,6 +172,7 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed && attachments.length === 0) return;
|
if (!trimmed && attachments.length === 0) return;
|
||||||
if (isStreaming) return;
|
if (isStreaming) return;
|
||||||
|
if (!configLoaded) return; // P1-6: 配置未加载完成时禁止发送
|
||||||
|
|
||||||
// 处理 / 命令
|
// 处理 / 命令
|
||||||
if (trimmed.startsWith('/')) {
|
if (trimmed.startsWith('/')) {
|
||||||
@@ -326,8 +328,8 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder="输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)"
|
placeholder={configLoaded ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '正在加载配置...'}
|
||||||
disabled={isStreaming}
|
disabled={isStreaming || !configLoaded}
|
||||||
multiline
|
multiline
|
||||||
rows={1}
|
rows={1}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -363,7 +365,7 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
<Square size={12} style={{ marginRight: 6 }} /> 中断
|
<Square size={12} style={{ marginRight: 6 }} /> 中断
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="contained" size="small" onClick={handleSend} disabled={!input.trim() && attachments.length === 0} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) ? 1 : 0.5 }}>
|
<Button variant="contained" size="small" onClick={handleSend} disabled={(!input.trim() && attachments.length === 0) || !configLoaded} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) && configLoaded ? 1 : 0.5 }}>
|
||||||
<Send size={12} style={{ marginRight: 6 }} /> 发送
|
<Send size={12} style={{ marginRight: 6 }} /> 发送
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -176,7 +176,12 @@ function ToolManagerPanel() {
|
|||||||
if (window.metona?.tools?.list) {
|
if (window.metona?.tools?.list) {
|
||||||
window.metona.tools.list().then((list) => {
|
window.metona.tools.list().then((list) => {
|
||||||
if (!cancelled) setTools(list as MetonaToolInfo[]);
|
if (!cancelled) setTools(list as MetonaToolInfo[]);
|
||||||
}).catch((err) => { console.error('[Sidebar]', err); });
|
}).catch((err) => {
|
||||||
|
console.error('[Sidebar]', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('加载工具列表失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Brain, Search, Trash2, ChevronDown } from 'lucide-react';
|
import { Brain, Search, Trash2, ChevronDown } from 'lucide-react';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||||
|
import { useUIStore } from '@renderer/stores/ui-store';
|
||||||
import { formatTime, truncate } from '@renderer/lib/formatters';
|
import { formatTime, truncate } from '@renderer/lib/formatters';
|
||||||
|
|
||||||
// ===== 类型 =====
|
// ===== 类型 =====
|
||||||
@@ -83,6 +84,8 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
|
// Agent 完成时自动刷新:监听 agentStatus 从 thinking/executing -> idle
|
||||||
const agentStatus = useAgentStore((s) => s.agentStatus);
|
const agentStatus = useAgentStore((s) => s.agentStatus);
|
||||||
const prevStatus = useRef(agentStatus);
|
const prevStatus = useRef(agentStatus);
|
||||||
|
// v0.3.6: 监听 memoryVersion 变化(SettingsModal 清理记忆后自增),触发重新加载
|
||||||
|
const memoryVersion = useUIStore((s) => s.memoryVersion);
|
||||||
// M-53 修复: 竞态保护 ref,防止多次 loadMemories 调用乱序完成导致旧数据覆盖
|
// M-53 修复: 竞态保护 ref,防止多次 loadMemories 调用乱序完成导致旧数据覆盖
|
||||||
const loadReqIdRef = useRef(0);
|
const loadReqIdRef = useRef(0);
|
||||||
// 审计补充修复: handleSearch 使用独立 ref,避免与 loadMemories 共用导致 searching 状态卡死
|
// 审计补充修复: handleSearch 使用独立 ref,避免与 loadMemories 共用导致 searching 状态卡死
|
||||||
@@ -130,6 +133,14 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [agentStatus, loadMemories]);
|
}, [agentStatus, loadMemories]);
|
||||||
|
|
||||||
|
// v0.3.6: SettingsModal 清理记忆后 memoryVersion 自增,触发重新加载
|
||||||
|
// 之前清理后需重启应用 MemoryViewer 才会刷新(与 clearSessions 同样的 bug)
|
||||||
|
useEffect(() => {
|
||||||
|
if (memoryVersion > 0) {
|
||||||
|
loadMemories();
|
||||||
|
}
|
||||||
|
}, [memoryVersion, loadMemories]);
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
const q = searchQuery.trim();
|
const q = searchQuery.trim();
|
||||||
if (!q || !window.metona?.memory?.search) return;
|
if (!q || !window.metona?.memory?.search) return;
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
|
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
|
||||||
|
// P1-6 修复: Onboarding 完成后标记配置已加载
|
||||||
|
useAgentStore.getState().setConfigLoaded(true);
|
||||||
}
|
}
|
||||||
setOnboardingCompleted(true);
|
setOnboardingCompleted(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -107,7 +109,15 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
<Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}>
|
<Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}>
|
||||||
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
|
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
|
||||||
<Button variant="outlined" size="small" onClick={async () => {
|
<Button variant="outlined" size="small" onClick={async () => {
|
||||||
if (window.metona?.app?.selectFolder) { const r = await window.metona.app.selectFolder(workspacePath || undefined); if (!r.canceled && r.path) setWorkspacePath(r.path); }
|
if (window.metona?.app?.selectFolder) {
|
||||||
|
try {
|
||||||
|
const r = await window.metona.app.selectFolder(workspacePath || undefined);
|
||||||
|
if (!r.canceled && r.path) setWorkspacePath(r.path);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OnboardingWizard]', err);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('选择文件夹失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}}>选择文件夹</Button>
|
}}>选择文件夹</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>包含 SOUL.md、AGENTS.md、MEMORY.md、USERS.md 四个必需文件,首次打开时自动创建。</Typography>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>包含 SOUL.md、AGENTS.md、MEMORY.md、USERS.md 四个必需文件,首次打开时自动创建。</Typography>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
* SettingsModal — 设置弹窗
|
* SettingsModal — 设置弹窗
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogActions, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip, Switch, Alert, CircularProgress } from '@mui/material';
|
import { Dialog, DialogContent, DialogTitle, DialogActions, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip, Switch, Alert, CircularProgress } from '@mui/material';
|
||||||
import { alpha } from '@mui/material/styles';
|
import { alpha } from '@mui/material/styles';
|
||||||
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe } from 'lucide-react';
|
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe, Folder, Copy } from 'lucide-react';
|
||||||
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
|
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||||
|
import { useSessionStore } from '@renderer/stores/session-store';
|
||||||
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||||
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
|
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
|
||||||
|
|
||||||
@@ -81,8 +82,28 @@ export function SettingsModal(): React.JSX.Element | null {
|
|||||||
|
|
||||||
function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
|
function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
|
||||||
const [value, setValue] = useState<T>(defaultValue);
|
const [value, setValue] = useState<T>(defaultValue);
|
||||||
|
const valueRef = useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
// v0.3.6 修复: 配置保存失败时回滚 UI 并提示用户,避免 UI 与 DB 状态不一致
|
||||||
|
// seqRef 防止竞态:连续修改时旧请求失败不回滚覆盖新值
|
||||||
|
const seqRef = useRef(0);
|
||||||
useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]);
|
useEffect(() => { if (window.metona?.config?.get) window.metona.config.get(key).then((v) => { if (v != null) setValue(v as T); }).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]);
|
||||||
const set = useCallback((v: T) => { setValue(v); window.metona?.config?.set(key, v).catch((err) => { console.error('[SettingsModal]', err); }); }, [key]);
|
const set = useCallback((v: T) => {
|
||||||
|
const seq = ++seqRef.current;
|
||||||
|
const prev = valueRef.current;
|
||||||
|
setValue(v);
|
||||||
|
window.metona?.config?.set(key, v).then((r: { success?: boolean; error?: string } | undefined) => {
|
||||||
|
if (r && !r.success) {
|
||||||
|
// 只有当没有后续 set 操作时才回滚,避免覆盖用户的新修改
|
||||||
|
if (seqRef.current === seq) setValue(prev);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
console.error('[SettingsModal]', err);
|
||||||
|
if (seqRef.current === seq) setValue(prev);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('配置保存失败')).catch(() => {});
|
||||||
|
});
|
||||||
|
}, [key]);
|
||||||
return [value, set];
|
return [value, set];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +184,18 @@ function WorkspaceSettings() {
|
|||||||
setCheckResult(null);
|
setCheckResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); };
|
const handleOpen = async () => {
|
||||||
|
if (!workspacePath) return;
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.app?.showItemInFolder(workspacePath);
|
||||||
|
if (r && !r.success) {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(r.error ?? '打开文件夹失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SettingsModal]', err);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
@@ -500,17 +532,37 @@ function ToolsSettings() {
|
|||||||
};
|
};
|
||||||
useEffect(() => { loadTools(); loadAutoExec(); }, []);
|
useEffect(() => { loadTools(); loadAutoExec(); }, []);
|
||||||
|
|
||||||
const handleToggle = (name: string, enabled: boolean) => {
|
const handleToggle = async (name: string, enabled: boolean) => {
|
||||||
|
// v0.3.6 修复: 乐观更新失败时回滚 UI,避免开关显示与实际状态不一致
|
||||||
|
// 注意: 只回滚失败的单个工具(用 !enabled),不能用 setTools(prev) 整体回滚,
|
||||||
|
// 否则会覆盖 await 期间用户对其他工具的并发修改
|
||||||
setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t));
|
setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t));
|
||||||
window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); });
|
try {
|
||||||
|
const r = await window.metona?.tools?.toggle(name, enabled);
|
||||||
|
if (r && !r.success) {
|
||||||
|
setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t));
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(r.error ?? '切换工具失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SettingsModal]', err);
|
||||||
|
setTools((p) => p.map((t) => t.name === name ? { ...t, enabled: !enabled } : t));
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('切换工具失败')).catch(() => {});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 设置/取消自动执行
|
// 设置/取消自动执行
|
||||||
const handleSetAutoExec = async (name: string, enabled: boolean) => {
|
const handleSetAutoExec = async (name: string, enabled: boolean) => {
|
||||||
if (!window.metona?.tool?.setAutoExecute) return;
|
if (!window.metona?.tool?.setAutoExecute) return;
|
||||||
|
try {
|
||||||
const r = await window.metona.tool.setAutoExecute(name, enabled);
|
const r = await window.metona.tool.setAutoExecute(name, enabled);
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name));
|
setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name));
|
||||||
|
} else {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(r.error ?? '设置自动执行失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SettingsModal]', err);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('设置自动执行失败')).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -656,7 +708,20 @@ function MCPSettings() {
|
|||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
const handleAdd = async () => { if (!newName.trim() || !newCommand.trim()) return; await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true }); setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers(); };
|
const handleAdd = async () => {
|
||||||
|
if (!newName.trim() || !newCommand.trim()) return;
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.mcp?.addServer({ name: newName.trim(), transport: 'stdio', command: newCommand.trim(), args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [], enabled: true });
|
||||||
|
if (r?.success) {
|
||||||
|
setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers();
|
||||||
|
} else {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SettingsModal]', err);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(`添加 MCP 服务失败:${(err as Error).message}`)).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
const statusColors: Record<string, string> = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' };
|
const statusColors: Record<string, string> = { connected: 'success.main', connecting: 'warning.main', disconnected: 'text.secondary', error: 'error.main' };
|
||||||
|
|
||||||
// L-11 修复: 确认移除 MCP 服务
|
// L-11 修复: 确认移除 MCP 服务
|
||||||
@@ -666,9 +731,13 @@ function MCPSettings() {
|
|||||||
if (!confirmRemove) return;
|
if (!confirmRemove) return;
|
||||||
try {
|
try {
|
||||||
setRemoveError(null);
|
setRemoveError(null);
|
||||||
await window.metona?.mcp?.removeServer(confirmRemove);
|
const r = await window.metona?.mcp?.removeServer(confirmRemove);
|
||||||
|
if (r?.success) {
|
||||||
setConfirmRemove(null);
|
setConfirmRemove(null);
|
||||||
loadServers();
|
loadServers();
|
||||||
|
} else {
|
||||||
|
setRemoveError(r?.error ?? '移除失败');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRemoveError((err as Error).message ?? '移除失败');
|
setRemoveError((err as Error).message ?? '移除失败');
|
||||||
}
|
}
|
||||||
@@ -686,7 +755,19 @@ function MCPSettings() {
|
|||||||
{s.toolCount > 0 && <Typography variant="caption" sx={{ color: 'text.secondary' }}>{s.toolCount} 工具</Typography>}
|
{s.toolCount > 0 && <Typography variant="caption" sx={{ color: 'text.secondary' }}>{s.toolCount} 工具</Typography>}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="row" spacing={0.5}>
|
<Stack direction="row" spacing={0.5}>
|
||||||
<Button size="small" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => { await window.metona?.mcp?.toggleServer(s.name, s.status !== 'connected'); loadServers(); }}>{s.status === 'connected' ? '断开' : '连接'}</Button>
|
<Button size="small" sx={{ fontSize: 10, minWidth: 40 }} onClick={async () => {
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.mcp?.toggleServer(s.name, s.status !== 'connected');
|
||||||
|
if (r?.success) {
|
||||||
|
loadServers();
|
||||||
|
} else {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(r?.error ?? '操作失败')).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SettingsModal]', err);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(`操作失败:${(err as Error).message}`)).catch(() => {});
|
||||||
|
}
|
||||||
|
}}>{s.status === 'connected' ? '断开' : '连接'}</Button>
|
||||||
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
|
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
|
||||||
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={() => setConfirmRemove(s.name)}>移除</Button>
|
<Button size="small" color="error" sx={{ fontSize: 10, minWidth: 40 }} onClick={() => setConfirmRemove(s.name)}>移除</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -935,7 +1016,76 @@ function LogsSettings() {
|
|||||||
// L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm() 和 alert(),保持 UI 一致性
|
// L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm() 和 alert(),保持 UI 一致性
|
||||||
const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null);
|
const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null);
|
||||||
const [resultAlert, setResultAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
const [resultAlert, setResultAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
const handleExport = async () => { if (!window.metona?.data?.exportData) return; const r = await window.metona.data.exportData(); if (r.success && r.data) { const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click(); } };
|
// P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮
|
||||||
|
// electron-log 默认写入路径为 ${userData}/logs/main.log
|
||||||
|
const [logFilePath, setLogFilePath] = useState<string>('');
|
||||||
|
const [logPathLoading, setLogPathLoading] = useState<boolean>(true);
|
||||||
|
const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
// P3-13: 组件挂载时获取日志文件路径
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const appData = await window.metona?.app?.getAppDataPath?.();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (appData) {
|
||||||
|
// electron-log 默认日志路径: ${userData}/logs/main.log
|
||||||
|
// 路径分隔符由系统决定,直接拼接避免引入 path 模块
|
||||||
|
const sep = appData.includes('/') && !appData.includes('\\') ? '/' : '\\';
|
||||||
|
setLogFilePath(`${appData}${sep}logs${sep}main.log`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 获取失败不阻塞 UI
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[LogsSettings] Failed to get app data path:', e);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLogPathLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenLogFolder = async () => {
|
||||||
|
if (!logFilePath) return;
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.app?.showItemInFolder?.(logFilePath);
|
||||||
|
if (r && !r.success) {
|
||||||
|
setResultAlert({ type: 'error', message: `打开失败: ${r.error ?? '未知错误'}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setResultAlert({ type: 'error', message: `打开失败: ${(e as Error).message}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLogPath = async () => {
|
||||||
|
if (!logFilePath) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(logFilePath);
|
||||||
|
setCopyState('success');
|
||||||
|
setTimeout(() => setCopyState('idle'), 1500);
|
||||||
|
} catch (e) {
|
||||||
|
setCopyState('error');
|
||||||
|
setTimeout(() => setCopyState('idle'), 1500);
|
||||||
|
setResultAlert({ type: 'error', message: `复制失败: ${(e as Error).message}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!window.metona?.data?.exportData) return;
|
||||||
|
try {
|
||||||
|
const r = await window.metona.data.exportData();
|
||||||
|
if (r.success && r.data) {
|
||||||
|
const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' });
|
||||||
|
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click();
|
||||||
|
} else {
|
||||||
|
setResultAlert({ type: 'error', message: `导出失败: ${r.error ?? '未知错误'}` });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LogsSettings]', err);
|
||||||
|
setResultAlert({ type: 'error', message: `导出失败: ${(err as Error).message}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' };
|
const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' };
|
||||||
|
|
||||||
// L-11 修复(审计补充): 清理数据改用 Dialog 确认 + Alert 反馈,不再用原生 confirm/alert
|
// L-11 修复(审计补充): 清理数据改用 Dialog 确认 + Alert 反馈,不再用原生 confirm/alert
|
||||||
@@ -951,6 +1101,17 @@ function LogsSettings() {
|
|||||||
else r = await window.metona?.data?.clearAuditLogs();
|
else r = await window.metona?.data?.clearAuditLogs();
|
||||||
if (r?.success) {
|
if (r?.success) {
|
||||||
setResultAlert({ type: 'success', message: `${labels[type]}已清理` });
|
setResultAlert({ type: 'success', message: `${labels[type]}已清理` });
|
||||||
|
// 修复: 清理会话后同步清空前端状态,无需重启应用
|
||||||
|
if (type === 'sessions') {
|
||||||
|
useSessionStore.getState().setSessions([]);
|
||||||
|
useSessionStore.getState().setCurrentSession(null);
|
||||||
|
// 同时清空当前消息列表,防止聊天面板显示已删除的会话内容
|
||||||
|
useAgentStore.getState().setMessages([]);
|
||||||
|
}
|
||||||
|
// v0.3.6 修复: 清理记忆后触发 MemoryViewer 重新加载(之前需重启应用才看到效果)
|
||||||
|
if (type === 'memories') {
|
||||||
|
useUIStore.getState().bumpMemoryVersion();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setResultAlert({ type: 'error', message: `失败: ${r?.error ?? '未知错误'}` });
|
setResultAlert({ type: 'error', message: `失败: ${r?.error ?? '未知错误'}` });
|
||||||
}
|
}
|
||||||
@@ -968,6 +1129,44 @@ function LogsSettings() {
|
|||||||
<MenuItem value="debug">DEBUG</MenuItem><MenuItem value="info">INFO</MenuItem><MenuItem value="warn">WARN</MenuItem><MenuItem value="error">ERROR</MenuItem>
|
<MenuItem value="debug">DEBUG</MenuItem><MenuItem value="info">INFO</MenuItem><MenuItem value="warn">WARN</MenuItem><MenuItem value="error">ERROR</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
{/* P3-13: 日志文件路径展示与打开按钮 */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mb: 0.5, display: 'block' }}>日志文件</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={logFilePath}
|
||||||
|
placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'}
|
||||||
|
readOnly
|
||||||
|
slotProps={{ input: { sx: { fontSize: 11, fontFamily: 'monospace' } } }}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Folder size={14} />}
|
||||||
|
onClick={handleOpenLogFolder}
|
||||||
|
disabled={!logFilePath}
|
||||||
|
>
|
||||||
|
打开日志文件夹
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Copy size={14} />}
|
||||||
|
onClick={handleCopyLogPath}
|
||||||
|
disabled={!logFilePath}
|
||||||
|
color={copyState === 'success' ? 'success' : copyState === 'error' ? 'error' : 'inherit'}
|
||||||
|
>
|
||||||
|
{copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败' : '复制路径'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', mt: 0.5, display: 'block' }}>
|
||||||
|
日志级别变更重启后生效。日志文件按日期滚动,旧日志保留在 logs 目录下。
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>数据管理</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>数据管理</Typography>
|
||||||
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>📦 导出全部数据(JSON)</Button>
|
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>📦 导出全部数据(JSON)</Button>
|
||||||
|
|||||||
@@ -65,8 +65,13 @@ export function WorkspaceViewer(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [agentStatus, loadInfo]);
|
}, [agentStatus, loadInfo]);
|
||||||
|
|
||||||
const handleOpenInFolder = (path: string) => {
|
const handleOpenInFolder = async (path: string) => {
|
||||||
window.metona?.app?.showItemInFolder(path);
|
try {
|
||||||
|
await window.metona?.app?.showItemInFolder(path);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WorkspaceViewer]', err);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -428,6 +428,22 @@ export function useAgentStream(): void {
|
|||||||
store.updateLastTraceStep({
|
store.updateLastTraceStep({
|
||||||
state: data.state,
|
state: data.state,
|
||||||
states: [...currentStates, data.state],
|
states: [...currentStates, data.state],
|
||||||
|
// MT-2 修复: TERMINATED 状态到达时,标记步骤为已完成
|
||||||
|
// 防止 abort 后最后一个步骤永远显示转圈
|
||||||
|
...(data.state === 'TERMINATED' ? { completedAt: Date.now() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// MT-2 修复: 当 state === 'TERMINATED' 且 iteration 不匹配时,
|
||||||
|
// 说明 abort 发生在循环条件检查时,currentIteration 已自增但没有对应 step。
|
||||||
|
// 此时不应创建孤立的只含 TERMINATED 的步骤,而是更新最后一个步骤的 states 数组
|
||||||
|
if (data.state === 'TERMINATED' && lastStep) {
|
||||||
|
const currentStates = lastStep.states ?? [lastStep.state];
|
||||||
|
if (currentStates[currentStates.length - 1] !== 'TERMINATED') {
|
||||||
|
store.updateLastTraceStep({
|
||||||
|
state: 'TERMINATED',
|
||||||
|
states: [...currentStates, 'TERMINATED'],
|
||||||
|
completedAt: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -444,6 +460,7 @@ export function useAgentStream(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Agent 状态映射 ---
|
// --- Agent 状态映射 ---
|
||||||
// INIT 不映射 — 避免 engine 的 transitionTo(INIT) 覆盖 sendMessage 设置的 'thinking' 状态
|
// INIT 不映射 — 避免 engine 的 transitionTo(INIT) 覆盖 sendMessage 设置的 'thinking' 状态
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ interface AgentState {
|
|||||||
// 流式
|
// 流式
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
|
||||||
|
// P1-6 修复: 配置加载完成标志 — 加载完成前禁用发送按钮
|
||||||
|
configLoaded: boolean;
|
||||||
|
|
||||||
// Run 标识(用于过滤旧流事件)
|
// Run 标识(用于过滤旧流事件)
|
||||||
currentRunId: string | null;
|
currentRunId: string | null;
|
||||||
|
|
||||||
@@ -140,6 +143,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||||
traceSteps: [],
|
traceSteps: [],
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
|
configLoaded: false,
|
||||||
currentRunId: null,
|
currentRunId: null,
|
||||||
provider: '',
|
provider: '',
|
||||||
model: '',
|
model: '',
|
||||||
@@ -342,6 +346,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
|
|
||||||
setStreaming: (streaming) => set({ isStreaming: streaming }),
|
setStreaming: (streaming) => set({ isStreaming: streaming }),
|
||||||
|
|
||||||
|
setConfigLoaded: (loaded) => set({ configLoaded: loaded }),
|
||||||
|
|
||||||
setCurrentRunId: (runId) => set({ currentRunId: runId }),
|
setCurrentRunId: (runId) => set({ currentRunId: runId }),
|
||||||
|
|
||||||
addTraceStep: (step) =>
|
addTraceStep: (step) =>
|
||||||
@@ -382,7 +388,10 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
if (window.metona?.config?.get) {
|
if (window.metona?.config?.get) {
|
||||||
// Ollama 用 ollama.numCtx,DeepSeek 用 deepseek.contextWindow,Agnes 用 agnes.contextWindow
|
// Ollama 用 ollama.numCtx,DeepSeek 用 deepseek.contextWindow,Agnes 用 agnes.contextWindow
|
||||||
const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`;
|
const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`;
|
||||||
|
// P2-12 修复: 竞态保护 — 快速切换 provider 时,旧 Promise resolve 不覆盖新值
|
||||||
|
const expectedProvider = provider;
|
||||||
window.metona.config.get(configKey).then((v) => {
|
window.metona.config.get(configKey).then((v) => {
|
||||||
|
if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果
|
||||||
if (v != null && typeof v === 'number' && v > 0) {
|
if (v != null && typeof v === 'number' && v > 0) {
|
||||||
set({ contextWindow: v });
|
set({ contextWindow: v });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ interface UIState {
|
|||||||
preFocusSidebarVisible: boolean;
|
preFocusSidebarVisible: boolean;
|
||||||
preFocusDetailVisible: boolean;
|
preFocusDetailVisible: boolean;
|
||||||
|
|
||||||
|
// v0.3.6: 记忆数据版本号 — 清理记忆后自增,MemoryViewer 监听变化重新加载
|
||||||
|
// 避免 SettingsModal 清理记忆后 MemoryViewer 仍显示旧数据(需重启应用才刷新)
|
||||||
|
memoryVersion: number;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setTheme: (theme: ThemeMode) => void;
|
setTheme: (theme: ThemeMode) => void;
|
||||||
setResolvedTheme: (theme: 'dark' | 'light') => void;
|
setResolvedTheme: (theme: 'dark' | 'light') => void;
|
||||||
@@ -49,6 +53,8 @@ interface UIState {
|
|||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
toggleDetail: () => void;
|
toggleDetail: () => void;
|
||||||
toggleFocusMode: () => void;
|
toggleFocusMode: () => void;
|
||||||
|
// v0.3.6: 记忆清理后触发前端刷新
|
||||||
|
bumpMemoryVersion: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set) => ({
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
@@ -64,6 +70,8 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
detailTab: 'trace',
|
detailTab: 'trace',
|
||||||
preFocusSidebarVisible: true,
|
preFocusSidebarVisible: true,
|
||||||
preFocusDetailVisible: true,
|
preFocusDetailVisible: true,
|
||||||
|
// v0.3.6: 记忆数据版本号初始为 0
|
||||||
|
memoryVersion: 0,
|
||||||
|
|
||||||
setTheme: (theme) => set({ theme }),
|
setTheme: (theme) => set({ theme }),
|
||||||
setResolvedTheme: (resolvedTheme) => set({ resolvedTheme }),
|
setResolvedTheme: (resolvedTheme) => set({ resolvedTheme }),
|
||||||
@@ -101,4 +109,6 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
detailVisible: s.preFocusDetailVisible,
|
detailVisible: s.preFocusDetailVisible,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
// v0.3.6: 清理记忆后自增版本号,触发 MemoryViewer 重新加载
|
||||||
|
bumpMemoryVersion: () => set((s) => ({ memoryVersion: s.memoryVersion + 1 })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Vendored
+2
-2
@@ -169,8 +169,8 @@ interface MetonaConfigAPI {
|
|||||||
interface MetonaAppAPI {
|
interface MetonaAppAPI {
|
||||||
getVersion: () => Promise<string>;
|
getVersion: () => Promise<string>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openExternal: (url: string) => Promise<void>;
|
openExternal: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
showItemInFolder: (path: string) => Promise<void>;
|
showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
|
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
|
||||||
restart: () => Promise<{ success: boolean }>;
|
restart: () => Promise<{ success: boolean }>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user