6 Commits
Author SHA1 Message Date
thzxx 983c52b0e4 chore: bump version to 0.3.20 + metona-toast 迁移至 @metona-team 私有源 2026-07-23 21:42:14 +08:00
thzxx 56bd3a46d7 chore: sync package-lock.json version to 0.3.19 2026-07-22 20:55:00 +08:00
thzxx 2f495775ea chore: bump version to 0.3.19 2026-07-22 20:53:43 +08:00
thzxx e161fe059f fix: MiMo 上下文窗口 1M + Agnes 多模态支持 Base64 2026-07-22 20:51:53 +08:00
thzxx 96b6f0b63d docs: 重写 README.md,精致美观完整覆盖项目架构 2026-07-22 20:29:49 +08:00
thzxx f3a0e751ba feat: v0.3.18 上下文压缩机制修复 + MCP 工具就绪竞态修复 + 记忆系统加固
【上下文压缩机制修复】
- engine.ts 新增 lastRealInputTokens 记录 LLM 返回的真实输入 token,压缩判断取 max(估算值, 真实值),避免估算偏低导致不压缩但 API 413
- 修复 effectiveContextWindow 缺少默认值导致 compressionThreshold 变 NaN、压缩永不触发的 bug(添加 ?? 128_000 兜底)
- compressMessages 保留区从固定 10 条改为按 token 预算动态截断(50% 上下文窗口)
- 二次截断 charsPerToken 从 2 调整为 1.0,与 CJK_TOKEN_RATIO 一致
- 压缩后重置 lastRealInputTokens,避免跨迭代污染
- 每个 run 开始时重置 lastRealInputTokens

【前端 Token 显示修复】
- 区分"累计消耗"和"上下文占用"语义——之前 totalTokens(累计) / contextWindow(单次窗口) 得出无意义百分比
- TokenUsage.tsx 上下文占用改用 lastInputTokens,新增压缩节省行(绿色,仅当 > 0 时显示)
- agent-store.ts TokenUsage 接口新增 lastInputTokens 和 lastCompressedSaved 字段,4 处初始值统一更新
- useAgentStream.ts usage 事件 lastInputTokens 替换不累加,compressed 事件通过 streamEvent 接收 savedTokens
- handlers.ts onCompressed 同时发 toast + streamEvent,解决"压缩触发但前端 token 显示不降"缺陷
- 旧数据兼容使用 ?? 0,保证历史会话加载不崩溃

【MCP 工具就绪竞态修复】
- 修复输入框永久显示"工具加载中"的竞态条件:MCP initialize 几乎立即 resolve(connectServer 不 await),tools:ready 事件在前端监听器注册前已发出
- main.ts 维护 toolsReady 标志 + 注册 tools:isReady IPC handler 查询当前状态
- preload.ts 暴露 tools.isReady() 方法
- App.tsx 注册 onReady 监听器后立即查询 isReady(),无论事件是否错过都能恢复正确状态

【记忆系统加固】
- consolidator.ts 新增 runningPromise + waitForCompletion(35s),before-quit 等待固化完成,防止退出时异步 consolidate 数据丢失
- 固化到 MEMORY.md 的同时写入 semantic_memories 表,解决双轨存储无交叉验证问题
- manager.ts tokenize 按中英文标点切分子句后再做 bigram,优化中文分词
- 清理正则冗余括号

【SOUL.md 降级处理】
- context-builder.ts SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
- 新增 fallbackRoleNotified 去重标志,仅首次降级通知,避免每次发消息都弹 toast
- SOUL.md 恢复内容时重置标志

【Token 估算调整】
- token-estimator.ts CJK_TOKEN_RATIO 从 1.5 调整为 1.0

【MCP 异步初始化】
- main.ts MCP 完成后广播 tools:ready 事件,前端 UI 据以控制输入框可用性
- preload.ts + global.d.ts 暴露 tools.onReady() 监听器
- App.tsx + ChatInput.tsx toolsReady 状态控制输入框

【版本号】
- package.json + package-lock.json 从 0.3.16 升级到 0.3.18
2026-07-22 15:17:58 +08:00
32 changed files with 1340 additions and 575 deletions
+3
View File
@@ -1,4 +1,7 @@
registry=https://registry.npmmirror.com
@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
//git.metona.cn/api/packages/MetonaTeam/npm/:_auth=dGh6eHg6Mjc5MjQ2MDE5MGJkcW4=
//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
# 允许包的 postinstall 脚本(electron 需要下载二进制文件)
ignore-scripts=false
+716 -437
View File
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
/**
* Agnes AI Provider Adapter
*
* OpenAI 兼容 API。支持 Tool Calling、Thinking 模式、多模态(图片)。
* OpenAI 兼容 API。支持 Tool Calling、Thinking 模式、多模态(图片 — URL + Base64)。
*
* 独立继承 BaseAdapter,通过 shared/openai-format 和 shared/sse-stream 复用
* OpenAI 兼容格式构建和 SSE 流式解析逻辑。不与其他 Provider Adapter 耦合。
@@ -37,7 +37,7 @@ export class AgnesAdapter extends BaseAdapter {
maxOutputTokens: 65_536,
supportsToolCalling: true,
supportsThinking: true,
description: 'Agnes AI 快速版,1M 上下文,支持多模态图片与思考模式',
description: 'Agnes AI 快速版,1M 上下文,支持多模态图片URL + Base64与思考模式',
},
};
@@ -136,6 +136,7 @@ export class AgnesAdapter extends BaseAdapter {
*
* Agnes AI 特有参数:
* - 多模态图片:user 消息的 images[] → OpenAI content 数组 [{type:"text"}, {type:"image_url"}]
* 支持 HTTPS URL 或 base64 Data URI(与 MiMo 一致)
* - chat_template_kwargs: { enable_thinking: true } — 启用思考模式(非 thinking 字段)
* - 默认 max_tokens: 655361M 上下文,65.5K 最大输出)
*/
+6 -6
View File
@@ -2,7 +2,7 @@
* MiMo (Xiaomi) Provider Adapter
*
* 基于 OpenAI 兼容 API。支持 Tool Calling、Thinking 模式、流式输出。
* 模型: mimo-v2.5-pro131072 max_tokens/ mimo-v2.532768 max_tokens
* 模型: mimo-v2.5-pro1M 上下文 / 131072 max_tokens/ mimo-v2.51M 上下文 / 32768 max_tokens
*
* 独立继承 BaseAdapter,通过 shared/openai-format 和 shared/sse-stream 复用
* OpenAI 兼容格式构建和 SSE 流式解析逻辑。不与其他 Provider Adapter 耦合。
@@ -31,13 +31,12 @@ export class MimoAdapter extends BaseAdapter {
readonly supportsThinking = true;
// MiMo 模型元信息
// mimo-v2.5-pro: 131072 max_completion_tokensmimo-v2.5: 32768
// 官方未公布上下文窗口大小,保守设为 131072(与 pro 的 max_output 一致)
// mimo-v2.5-pro: 1M 上下文(与 DeepSeek 一致)/ 131072 max_tokensmimo-v2.5: 1M 上下文 / 32768 max_tokens
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'mimo-v2.5-pro': {
id: 'mimo-v2.5-pro',
name: 'MiMo V2.5 Pro',
contextWindow: 131_072,
contextWindow: 1_000_000,
maxOutputTokens: 131_072,
supportsToolCalling: true,
supportsThinking: true,
@@ -46,7 +45,7 @@ export class MimoAdapter extends BaseAdapter {
'mimo-v2.5': {
id: 'mimo-v2.5',
name: 'MiMo V2.5',
contextWindow: 131_072,
contextWindow: 1_000_000,
maxOutputTokens: 32_768,
supportsToolCalling: true,
supportsThinking: true,
@@ -143,7 +142,7 @@ export class MimoAdapter extends BaseAdapter {
return this.config.contextWindow;
}
const modelInfo = MimoAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 131_072;
return modelInfo?.contextWindow ?? 1_000_000;
}
// ========== 私有方法 ==========
@@ -153,6 +152,7 @@ export class MimoAdapter extends BaseAdapter {
*
* MiMo 特有参数:
* - 多模态图片:user 消息的 images[] → OpenAI content 数组 [{type:"text"}, {type:"image_url"}]
* 支持 HTTPS URL 或 base64 Data URI
* - thinking: { type: "enabled" / "disabled" } — 与 DeepSeek 一致
* - max_completion_tokens — 非 max_tokensMiMo 使用新字段名)
* - stream_options: { include_usage: true } — 流式返回 usage
+91 -14
View File
@@ -81,6 +81,13 @@ export class AgentLoopEngine extends EventEmitter {
private workspacePath: string = '';
private abortController: AbortController | null = null;
private eventSeq = 0;
/**
* v0.3.18 修复: 最近一次 LLM 调用返回的真实输入 token 数
* 用于校正压缩判断——估算值可能偏低(尤其中文场景),
* 导致估算没到阈值不压缩,但真实 API 调用已经 413。
* 压缩判断时取 max(估算值, 真实值) 作为实际占用。
*/
private lastRealInputTokens = 0;
/** 当前 run 的唯一标识(用于前端过滤旧流事件) */
private runId: string = '';
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
@@ -192,6 +199,8 @@ export class AgentLoopEngine extends EventEmitter {
this.currentIteration = 0;
this.currentSessionId = sessionId;
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
// v0.3.18 修复: 每个 run 重置 lastRealInputTokens,避免跨 run 污染
this.lastRealInputTokens = 0;
this.abortController = new AbortController();
// C-2 修复: 将 abortController 的 signal 注入到 adapter
// 使正在进行的 fetch 可被用户中断,避免资源泄漏
@@ -485,6 +494,12 @@ export class AgentLoopEngine extends EventEmitter {
completionTokens: event.usage.outputTokens ?? 0,
totalTokens: event.usage.totalTokens ?? 0,
};
// v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断
// 估算值可能偏低(尤其中文场景),导致不压缩但 API 413
const realInput = event.usage.inputTokens ?? 0;
if (realInput > 0) {
this.lastRealInputTokens = realInput;
}
}
break;
@@ -597,18 +612,28 @@ export class AgentLoopEngine extends EventEmitter {
// === 上下文压缩(基于 token 使用率触发) ===
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow;
// v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000;
const estimatedTokens = this.estimateMessagesTokens(request.messages);
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413
// 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景)
// 真实值用于校正——LLM 返回的 inputTokens 是 BPE 真实分词结果,比字符估算准确
const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens);
const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow;
if (estimatedTokens > compressionThreshold && request.messages.length > 10) {
// v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4"
// 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
if (actualTokens > compressionThreshold && request.messages.length >= 4) {
await this.transitionTo(AgentLoopState.COMPRESSING);
const compressed = await this.compressMessages(request.messages);
if (compressed) {
// 原地替换数组内容,确保外层 messages 引用同步更新
request.messages.splice(0, request.messages.length, ...compressed);
// v0.3.18 修复: 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值
this.lastRealInputTokens = 0;
this.emit('compressed', {
iteration: this.currentIteration,
originalTokens: estimatedTokens,
originalTokens: actualTokens,
compressedTokens: this.estimateMessagesTokens(compressed),
});
}
@@ -961,7 +986,8 @@ export class AgentLoopEngine extends EventEmitter {
/**
* 估算消息列表的 token 数
* 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字
* 使用智能字符估算:中文 1.0 token/字,ASCII 0.25 token/字,其他 1 token/字
* v0.3.18: CJK 系数从 1.5 调整为 1.0,更贴近 BPE 实际值
* @see electron/harness/utils/token-estimator.ts
*/
private estimateMessagesTokens(messages: MetonaMessage[]): number {
@@ -1022,21 +1048,48 @@ export class AgentLoopEngine extends EventEmitter {
}
/**
* 上下文压缩 — 将旧消息摘要为一条 system 消息,保留最近 3 轮完整对话
* 上下文压缩 — 将旧消息摘要为一条 assistant 消息,保留近期完整对话
*
* v0.3.18 修复: 保留策略从"固定 10 条"改为"按 token 预算动态截断"
* 之前固定保留最近 10 条,若 10 条里有超长 tool_result(如 read_file 读了 5000 行),
* 压缩后仍超 token 限制,下一轮 LLM 调用会返回 413。
* 现在按 effectiveContextWindow 的 50% 预算从末尾反向累加消息,
* 直到预算用尽或只剩 1 条可压缩消息。
*
* 策略:
* 1. 保留最后 keepRecent 条消息(约 3 轮对话
* 2. 将前面的所有消息交给 LLM 生成摘要
* 3. 用 [Context Summary] system 消息 + 近期消息替换原数组
* 1. 计算 keepBudget = effectiveContextWindow * 0.5(保留区预算
* 2. 从末尾反向累加消息到 toKeep,直到累加 token 超过 keepBudget
* 3. 将前面的所有消息交给 LLM 生成摘要
* 4. 用 [Context Summary] assistant 消息 + 占位 user + 近期消息替换原数组
* 5. 若单条消息超 keepBudget(超长 tool_result),单独二次截断
*
* @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组)
*/
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool
if (messages.length <= keepRecent) return null;
// v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
// 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000;
const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50%
const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文
let toCompress = messages.slice(0, messages.length - keepRecent);
let toKeep = messages.slice(messages.length - keepRecent);
// 从末尾反向累加,确定 toKeep 范围
let keepStartIdx = messages.length;
let keepTokens = 0;
for (let i = messages.length - 1; i >= 0; i--) {
const msgTokens = this.estimateMessagesTokens([messages[i]]);
if (keepTokens + msgTokens > keepBudget && i < messages.length - minKeepCount) {
break;
}
keepTokens += msgTokens;
keepStartIdx = i;
}
// 至少保留 minKeepCount 条,且至少要有 1 条可压缩
if (keepStartIdx < minKeepCount) keepStartIdx = minKeepCount;
if (keepStartIdx >= messages.length) return null;
let toCompress = messages.slice(0, keepStartIdx);
let toKeep = messages.slice(keepStartIdx);
// MT-3 修复: 确保 toKeep 不以孤立的 tool 消息开头
// OpenAI/DeepSeek API 要求 tool 消息前必须有带 tool_calls 的 assistant 消息
@@ -1118,7 +1171,31 @@ export class AgentLoopEngine extends EventEmitter {
timestamp: Date.now(),
};
log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${toKeep.length} recent`);
// v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断
// 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文
const finalKeep = toKeep.map((msg) => {
const msgTokens = this.estimateMessagesTokens([msg]);
if (msgTokens > keepBudget && msg.content) {
// 截断内容,保留头部和尾部,中间用省略标记
const halfBudget = Math.floor(keepBudget / 2);
// v0.3.18 修复: charsPerToken 从 2 调整为 1.0(与 CJK_TOKEN_RATIO 一致)
// 原值 2 对中文偏激进(2 字符/token),实际中文约 1 字符/token,
// 导致截断后保留字符过多,实际 token 仍超 keepBudget
const charsPerToken = 1.0;
const keepChars = Math.floor(halfBudget * charsPerToken);
if (msg.content.length > keepChars * 2) {
const head = msg.content.slice(0, keepChars);
const tail = msg.content.slice(-keepChars);
return {
...msg,
content: `${head}\n\n... [truncated, ${msgTokens} tokens] ...\n\n${tail}`,
};
}
}
return msg;
});
log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`);
// 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息
// (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。
@@ -1127,7 +1204,7 @@ export class AgentLoopEngine extends EventEmitter {
summaryMessage,
// 审查修复: 插入占位 user 消息避免连续 assistant 消息
{ role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() },
...toKeep,
...finalKeep,
];
} catch (error) {
log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message);
+117
View File
@@ -25,6 +25,7 @@ import type { IMetonaProviderAdapter } from '../types/metona-adapter';
import type { MetonaRequest, MetonaMessage } from '../types';
import type { WorkspaceService } from '../../services/workspace.service';
import type { IterationStep } from '../agent-loop/types';
import type { MemoryManager } from './manager';
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
@@ -43,6 +44,11 @@ export interface ConsolidationResult {
}
export class MemoryConsolidator {
/** v0.3.18 修复: 跟踪进行中的 consolidate 任务,供应用退出时等待 */
private runningPromise: Promise<ConsolidationResult> | null = null;
/** v0.3.18 修复: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉写入 */
private memoryManager: MemoryManager | null = null;
constructor(
private adapter: IMetonaProviderAdapter,
private workspaceService: WorkspaceService,
@@ -55,6 +61,47 @@ export class MemoryConsolidator {
this.adapter = adapter;
}
/**
* v0.3.18 修复: 注入 MemoryManager,使 consolidate 同步写入 semantic_memories 表
*/
setMemoryManager(memoryManager: MemoryManager): void {
this.memoryManager = memoryManager;
}
/**
* v0.3.18 修复: 是否有进行中的 consolidate 任务
*/
isRunning(): boolean {
return this.runningPromise !== null;
}
/**
* v0.3.18 修复: 等待进行中的 consolidate 任务完成(应用退出时调用)
*
* @param timeoutMs 超时时间(默认 35 秒,略大于 LLM 调用的 30 秒超时)
* @returns true 表示已完成,false 表示超时
*/
async waitForCompletion(timeoutMs: number = 35_000): Promise<boolean> {
if (!this.runningPromise) return true;
let timedOut = false;
let timerHandle: ReturnType<typeof setTimeout> | undefined;
const timer = new Promise<void>((resolve) => {
timerHandle = setTimeout(() => {
timedOut = true;
resolve();
}, timeoutMs);
});
try {
await Promise.race([
this.runningPromise.catch(() => {}),
timer,
]);
return !timedOut;
} finally {
if (timerHandle) clearTimeout(timerHandle);
}
}
/**
* 固化本次对话的重要记忆到 MEMORY.md
*
@@ -67,6 +114,32 @@ export class MemoryConsolidator {
userMessage: string,
assistantAnswer: string,
iterations: IterationStep[],
): Promise<ConsolidationResult> {
// v0.3.18 修复: 跟踪进行中的 consolidate,供应用退出时等待
const promise = this.executeConsolidation(userMessage, assistantAnswer, iterations);
this.runningPromise = promise;
try {
return await promise;
} finally {
// 只有当前 promise 仍是自己时才清理,避免被后续调用覆盖
if (this.runningPromise === promise) {
this.runningPromise = null;
}
}
}
/**
* 实际执行固化的内部方法
*
* v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉
* 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。
* 现在每条固化到 MEMORY.md 的条目也同步存入 semantic_memories 表,
* 使 TF-IDF 检索能命中跨会话的用户偏好/决策/项目上下文。
*/
private async executeConsolidation(
userMessage: string,
assistantAnswer: string,
iterations: IterationStep[],
): Promise<ConsolidationResult> {
try {
// 1. 构建对话摘要
@@ -105,6 +178,27 @@ export class MemoryConsolidator {
try {
this.workspaceService.appendMemory(item.section, truncatedEntry);
validEntries.push({ section: item.section, entry: truncatedEntry });
// v0.3.18 修复: 同步写入 semantic_memories 表,实现 DB 记忆与 MEMORY.md 双轨交叉
// 之前仅写 MEMORY.md,导致 DB 检索不到用户偏好等非工具触发的重要信息。
// 现在每条固化到 MEMORY.md 的条目也存入 semantic_memories
// key 用 section(如"用户偏好"),value 用 entry 内容,
// confidence 按 section 语义分级(偏好/决策最高,待办最低)
if (this.memoryManager) {
try {
const confidence = this.getSectionConfidence(item.section);
this.memoryManager.store({
type: 'semantic',
content: truncatedEntry,
summary: `[${item.section}] ${truncatedEntry.slice(0, 60)}`,
source: 'agent_thought',
importance: confidence,
});
} catch (dbErr) {
// DB 写入失败不影响 MEMORY.md 已写入的结果,仅记录日志
log.warn(`[MemoryConsolidator] Failed to sync to semantic_memories:`, dbErr);
}
}
} catch (err) {
log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err);
skipped++;
@@ -278,6 +372,29 @@ export class MemoryConsolidator {
return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0;
}
/**
* v0.3.18 修复: 按 MEMORY.md 分区语义返回 confidence(重要度)
*
* 用于同步写入 semantic_memories 时的 importance 字段:
* - 用户偏好/重要决策:0.9(长期有效,跨会话高价值)
* - 项目上下文/已知问题:0.7(项目级,中等价值)
* - 待办事项:0.5(短期,可能很快完成)
*/
private getSectionConfidence(section: string): number {
switch (section) {
case '用户偏好':
case '重要决策':
return 0.9;
case '项目上下文':
case '已知问题':
return 0.7;
case '待办事项':
return 0.5;
default:
return 0.6;
}
}
/**
* 截断字符串
*/
+26 -10
View File
@@ -44,21 +44,37 @@ interface MemorySearchOptions {
minImportance?: number;
}
/** 分词:将文本拆分为词项(支持中英文) */
/**
* 分词:将文本拆分为词项(支持中英文)
*
* v0.3.18 修复: 改进中文分词策略,减少 bigram 噪声
* 之前对整段文本连续取 bigram,会跨越标点边界产生无意义组合
* (如"开发规范。下一句"会产生"范。"和"。下"这类噪声 bigram),
* 降低 IDF 区分度。
* 现在先按中英文标点切分子句,再在每个子句内做 bigram,
* 避免跨句组合,提升检索准确度。
*/
function tokenize(text: string): string[] {
// 转小写,按非字母数字字符分割
// 转小写
const lower = text.toLowerCase();
// 英文词
const words = lower.match(/[a-z][a-z0-9_-]{1,}/g) ?? [];
// 中文 bigram(相邻两字组合),覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文
const cjkChars = lower.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g) ?? [];
// v0.3.18 修复: 按中英文标点切分子句,再在每个子句内做 bigram
// 标点包括:中文句号/逗号/顿号/分号/感叹/问号 + 英文 .,;!?()
const sentences = lower.split(/[。,、;!?.,;!?()\n\r\t]/);
const bigrams: string[] = [];
for (let i = 0; i < cjkChars.length - 1; i++) {
bigrams.push(cjkChars[i] + cjkChars[i + 1]);
}
// 单字 CJK 补 unigram(避免单字文档无 token
if (cjkChars.length === 1) {
bigrams.push(cjkChars[0]);
for (const sentence of sentences) {
// 提取子句内的 CJK 字符(覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文)
const cjkChars = sentence.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g);
if (!cjkChars || cjkChars.length === 0) continue;
for (let i = 0; i < cjkChars.length - 1; i++) {
bigrams.push(cjkChars[i] + cjkChars[i + 1]);
}
// 单字 CJK 子句补 unigram(避免单字文档无 token
if (cjkChars.length === 1) {
bigrams.push(cjkChars[0]);
}
}
return [...words, ...bigrams];
}
+35 -1
View File
@@ -15,6 +15,7 @@
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
*/
import log from 'electron-log';
import type { MetonaContext, MetonaMemoryItem, MetonaMessage, MetonaSystemPrompt, MetonaToolDef } from '../types';
import type { WorkspaceFiles } from '../../services/workspace.service';
import { estimateStringTokens } from '../utils/token-estimator';
@@ -34,6 +35,30 @@ interface ContextBuildParams {
* 上下文构建器
*/
export class ContextBuilder {
/**
* v0.3.18 修复: 标记上次 build 是否使用了兜底身份(SOUL.md 为空或不存在)
* 供 handlers.ts 读取后决定是否向前端发送 toast 提示
*/
private lastUsedFallbackRole = false;
/**
* v0.3.18 修复: 降级通知去重标志
* 第一次降级时通知,之后不再重复通知(避免每次发消息都弹 toast)
* 当 SOUL.md 恢复内容后重置为 false,下次再降级时会重新通知
*/
private fallbackRoleNotified = false;
/**
* v0.3.18 修复: 获取上次 build 是否使用了兜底身份
* 仅在首次降级(或恢复后再次降级)时返回 true,避免每次发消息都弹 toast
*/
isUsingFallbackRole(): boolean {
if (this.lastUsedFallbackRole && !this.fallbackRoleNotified) {
this.fallbackRoleNotified = true;
return true;
}
return false;
}
/**
* 构建完整的 MetonaContext
*/
@@ -138,6 +163,8 @@ export class ContextBuilder {
*
* v0.3.14: 兜底身份定义使用 Metona 灵魂定义(含角色/原则/风格/边界/元指令)
* SOUL.md 存在但内容为空(仅空白字符)时也走兜底分支
* v0.3.18 修复: 降级时设置 lastUsedFallbackRole 标志 + 打 WARN 日志,
* 避免用户自定义人格丢失但不知情的"安静失败"问题
*/
private buildRoleDefinition(soulContent?: string): string {
const parts: string[] = [];
@@ -145,8 +172,14 @@ export class ContextBuilder {
// v0.3.14: SOUL.md 不存在或内容为空(仅空白)时使用兜底身份定义
if (soulContent && soulContent.trim()) {
// SOUL.md 存在且有内容 — 直接使用其全部内容,不加任何固定前缀
this.lastUsedFallbackRole = false;
// v0.3.18 修复: SOUL.md 恢复内容后重置通知标志,下次再降级时会重新通知
this.fallbackRoleNotified = false;
parts.push(soulContent);
} else {
// v0.3.18 修复: 降级时打 WARN 日志 + 设置标志,供 handlers.ts 读取后发 toast
this.lastUsedFallbackRole = true;
log.warn('[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity');
// 兜底身份定义(Metona 灵魂定义)
parts.push(`# Metona — 灵魂定义
> "想清楚再动手,做对比做快重要"
@@ -255,7 +288,8 @@ For multi-step complex tasks (3+ steps), proactively use \`task_manager\` to bre
/**
* Token 估算
* 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字
* 使用智能字符估算:中文 1.0 token/字,ASCII 0.25 token/字,其他 1 token/字
* v0.3.18: CJK 系数从 1.5 调整为 1.0,更贴近 BPE 实际值
* @see electron/harness/utils/token-estimator.ts
*/
private estimateTokens(
+8 -2
View File
@@ -2,10 +2,15 @@
* Token 估算工具 — 跨 Provider 通用
*
* 策略:智能字符估算,区分中文字符与 ASCII 字符
* - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token
* - 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token
* - ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
* - 其他 Unicodeemoji 等):1 字符 ≈ 1 token
*
* v0.3.18 修复: CJK 系数从 1.5 调整为 1.0
* 实测 DeepSeek/GLM 等 BPE tokenizer 对中文约 0.6-0.8 token/字,
* 原系数 1.5 导致中文场景估算偏高约 2 倍,80% 阈值实际在 40-50% 就触发压缩,
* 造成上下文过早丢失。1.0 仍保守(留 ~25% 安全裕度),但更接近真实值。
*
* 对比旧的 `length / 2` 方案:
* - 中文场景:估算准确度从 ~50% 提升到 ~90%
* - 英文场景:从偏低变为接近真实
@@ -19,9 +24,10 @@ const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\
/**
* L-17 修复: 提取魔法系数为命名常量,便于统一调整
* v0.3.18 修复: CJK_TOKEN_RATIO 从 1.5 调整为 1.0,更贴近 BPE 实际值
* @see project_memory.md — Token estimation coefficients
*/
const CJK_TOKEN_RATIO = 1.5; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token
const CJK_TOKEN_RATIO = 1.0; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token(保守,实测 0.6-0.8
const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
const OTHER_TOKEN_RATIO = 1; // 其他 Unicodeemoji 等):1 字符 ≈ 1 token
const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范)
+25 -2
View File
@@ -146,6 +146,15 @@ export function registerAllIPCHandlers(
const workspaceFiles = workspaceService.getFiles();
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
// 避免用户自定义人格丢失但不知情的"安静失败"问题
if (contextBuilder.isUsingFallbackRole() && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('toast:show', {
type: 'info',
message: '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
});
}
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
try {
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
@@ -265,12 +274,26 @@ export function registerAllIPCHandlers(
}
};
// M-6: 转发上下文压缩事件为 toast 通知
// M-6: 转发上下文压缩事件为 toast 通知 + streamEvent 通知
// v0.3.18 修复: 同时通过 streamEvent 转发压缩数据,前端 store 据以更新 token 显示
// 解决"压缩触发但前端 token 显示不降"的缺陷
const onCompressed = (data: { iteration?: number; originalTokens?: number; compressedTokens?: number }) => {
const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0));
if (!mainWindow.isDestroyed()) {
// toast 通知用户压缩已发生
mainWindow.webContents.send('toast:show', {
type: 'info',
message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens`,
message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens}`,
});
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
mainWindow.webContents.send('agent:streamEvent', {
type: 'compressed',
sessionId,
iteration: data.iteration ?? 0,
originalTokens: data.originalTokens,
compressedTokens: data.compressedTokens,
savedTokens,
timestamp: Date.now(),
});
}
};
+39 -3
View File
@@ -14,7 +14,7 @@
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
*/
import { app, shell, Menu, BrowserWindow } from 'electron';
import { app, shell, Menu, BrowserWindow, ipcMain } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { electronApp, optimizer } from '@electron-toolkit/utils';
@@ -331,15 +331,34 @@ async function initialize(): Promise<void> {
agentLoop.setWorkspacePath(workspaceInfo.path);
// P1-7 修复: MCP 初始化在 agentLoop 创建后执行,完成后重新同步工具到 AgentLoop
// v0.3.18 修复: MCP 完成后广播 tools:ready 事件,前端据以启用发送按钮
// 之前 MCP 初始化是异步的,用户在 MCP 完成前发送消息会用到不全的工具集
// v0.3.18 修复: 维护 toolsReady 标志 + 暴露 tools:isReady 查询,解决竞态条件
// MCP initialize 几乎立即 resolveconnectServer 不 await),tools:ready 事件
// 可能在前端监听器注册前就发出,导致前端永久错过事件、输入框永久禁用
let toolsReady = false;
mcpManager.initialize().then(() => {
agentLoop.setTools(toolRegistry.listTools());
log.info('[MCP] Tools registered and synced to AgentLoop');
toolsReady = true;
// 广播工具就绪事件给所有窗口
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
}
}).catch((err) => {
log.warn('MCP Manager initialization error:', err);
// 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
toolsReady = true;
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
}
});
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md=====
const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService);
// v0.3.18 修复: 注入 MemoryManager,使 consolidate 同步写入 semantic_memories 表
// 实现 DB 记忆与 MEMORY.md 双轨交叉,避免"AI 记得一半"的不一致
memoryConsolidator.setMemoryManager(memoryManager);
// ===== Task Orchestrator(子任务委派)=====
const orchestrator = new TaskOrchestrator(
@@ -473,6 +492,9 @@ async function initialize(): Promise<void> {
promptDefender, outputValidator,
confirmationHook, memoryConsolidator, orchestrator,
);
// v0.3.18 修复: 注册 tools:isReady 查询,前端注册 onReady 监听器后立即查询,
// 解决 tools:ready 事件在监听器注册前发出的竞态条件
ipcMain.handle('tools:isReady', () => ({ ready: toolsReady, toolCount: toolRegistry.size }));
},
});
@@ -541,12 +563,13 @@ async function initialize(): Promise<void> {
(global as Record<string, unknown>).shutdownInProgress = true;
event.preventDefault();
// 内部异步清理,加 5 秒超时保护防止卡死
// v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待
// 之前 5 秒超时会导致进行中的 consolidate 被 kill,记忆丢失
const shutdownTimeout = setTimeout(() => {
log.warn('[Shutdown] Timeout reached, forcing exit');
if (databaseService) { databaseService.close(); databaseService = null; }
app.exit(0);
}, 5_000);
}, 40_000);
(async () => {
try {
@@ -555,6 +578,19 @@ async function initialize(): Promise<void> {
log.error('[Shutdown] MCP shutdown failed:', err);
}
cleanupBrowser();
// v0.3.18 修复: 等待进行中的记忆固化任务完成,避免应用退出导致记忆丢失
// consolidate 内部有 30 秒 LLM 超时保护,此处等待 35 秒足够覆盖
if (memoryConsolidator.isRunning()) {
log.info('[Shutdown] Waiting for memory consolidation to complete...');
const completed = await memoryConsolidator.waitForCompletion(35_000);
if (!completed) {
log.warn('[Shutdown] Memory consolidation timed out, proceeding with exit');
} else {
log.info('[Shutdown] Memory consolidation completed');
}
}
if (databaseService) { databaseService.close(); databaseService = null; }
clearTimeout(shutdownTimeout);
app.exit(0);
+8
View File
@@ -167,6 +167,14 @@ const metonaAPI = {
tools: {
list: () => ipcRenderer.invoke('tools:list'),
toggle: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tools:toggle', toolName, enabled),
/** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */
onReady: (callback: (data: { toolCount: number }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { toolCount: number }) => callback(data);
ipcRenderer.on('tools:ready', listener);
return () => ipcRenderer.removeListener('tools:ready', listener);
},
/** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态,前端注册监听器后立即查询一次) */
isReady: () => ipcRenderer.invoke('tools:isReady') as Promise<{ ready: boolean; toolCount: number }>,
},
// ===== 数据管理 =====
+2 -2
View File
@@ -394,8 +394,8 @@ export class DatabaseService {
// v0.3.1: DeepSeek/Agnes 改为可配置,不再写死 1M
{ key: 'deepseek.contextWindow', value: '1000000', category: 'deepseek' },
{ key: 'agnes.contextWindow', value: '1000000', category: 'agnes' },
// v0.3.4: MiMo 上下文窗口(官方未公布,保守设为 131072
{ key: 'mimo.contextWindow', value: '131072', category: 'mimo' },
// v0.3.4: MiMo 上下文窗口(与 DeepSeek 一致,1M
{ key: 'mimo.contextWindow', value: '1000000', category: 'mimo' },
// Onboarding
{ key: 'onboarding.completed', value: 'false', category: 'general' },
+1 -1
View File
@@ -78,7 +78,7 @@ export const SEED_DEFAULTS: Record<string, unknown> = {
'ollama.numCtx': null,
'deepseek.contextWindow': 1000000,
'agnes.contextWindow': 1000000,
'mimo.contextWindow': 131072,
'mimo.contextWindow': 1000000,
'onboarding.completed': false,
};
+76 -16
View File
@@ -1,16 +1,17 @@
{
"name": "metona-ai-desktop",
"version": "0.3.16",
"version": "0.3.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ai-desktop",
"version": "0.3.16",
"version": "0.3.20",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@metona-team/metona-toast": "^2.0.1",
"@modelcontextprotocol/sdk": "^1.12.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.2",
@@ -21,7 +22,6 @@
"electron-store": "^10.0.1",
"fuse.js": "^7.1.0",
"lru-cache": "^11.1.0",
"metona-toast": "^2.0.0",
"nanoid": "^5.1.5",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@@ -1870,6 +1870,12 @@
"node": ">= 10.0.0"
}
},
"node_modules/@metona-team/metona-toast": {
"version": "2.0.1",
"resolved": "https://git.metona.cn/api/packages/MetonaTeam/npm/%40metona-team%2Fmetona-toast/-/2.0.1/metona-toast-2.0.1.tgz",
"integrity": "sha512-r8q9CYhtYbsz2hH3ffpqNeo7hV0f5qXWiL65boisc0VXFNht0ZDNn/ZbWneYM2/bOqC7Vh7dJlHe0GSVbPJ3Lg==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -2333,6 +2339,9 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2347,6 +2356,9 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2361,6 +2373,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2375,6 +2390,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2389,6 +2407,9 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2403,6 +2424,9 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2417,6 +2441,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2431,6 +2458,9 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2445,6 +2475,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2459,6 +2492,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2473,6 +2509,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2487,6 +2526,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2501,6 +2543,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2750,6 +2795,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2767,6 +2815,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2784,6 +2835,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2801,6 +2855,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3874,16 +3931,16 @@
"optional": true
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"version": "5.0.8",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/browserslist": {
@@ -7369,6 +7426,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7390,6 +7450,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7411,6 +7474,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7432,6 +7498,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7942,15 +8011,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/metona-toast": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/metona-toast/-/metona-toast-2.0.0.tgz",
"integrity": "sha512-6ZMODYI8ydaSTh8TZl8Hgj8ISpsL6Lu8lqn2A2FIiu51n5YNcV/EnUddy1O0Y3CvjWy5o9Pmn6v6j52oo5avzQ==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ai-desktop",
"version": "0.3.16",
"version": "0.3.20",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js",
"author": "Metona Team",
@@ -35,7 +35,7 @@
"electron-store": "^10.0.1",
"fuse.js": "^7.1.0",
"lru-cache": "^11.1.0",
"metona-toast": "^2.0.0",
"@metona-team/metona-toast": "^2.0.1",
"nanoid": "^5.1.5",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+21
View File
@@ -61,6 +61,27 @@ export default function App(): React.JSX.Element {
// 无 IPC 可用时也标记为已加载(避免永久禁用)
useAgentStore.getState().setConfigLoaded(true);
}
// v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发)
// 未就绪前 sendMessage 会被阻止,避免用户在工具不全时发送消息
// v0.3.18 修复: 注册监听器后立即查询一次,解决 tools:ready 事件在监听器注册前发出的竞态
if (window.metona?.tools?.onReady) {
const unsubscribe = window.metona.tools.onReady((data) => {
console.log('[App] Tools ready:', data.toolCount, 'tools');
useAgentStore.getState().setToolsReady(true);
});
// 竞态保护: 事件可能在监听器注册前已发出,主动查询当前状态
window.metona.tools.isReady?.().then((status) => {
if (status.ready) {
console.log('[App] Tools already ready (polled):', status.toolCount, 'tools');
useAgentStore.getState().setToolsReady(true);
}
}).catch((err) => { console.error('[App] tools.isReady query failed:', err); });
return unsubscribe;
} else {
// 无 IPC 可用时直接标记为就绪(避免永久禁用)
useAgentStore.getState().setToolsReady(true);
}
}, []);
return (
+1 -1
View File
@@ -44,7 +44,7 @@ export function CommandPalette(): React.JSX.Element {
useAgentStore.getState().setCurrentSession(r.id);
} catch (err) {
console.error('[CommandPalette]', err);
import('metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {});
}
}
setOpen(false);
+2 -2
View File
@@ -165,7 +165,7 @@ export function ContextMenuDialogHost(): React.JSX.Element {
function copyWithToast(text: string): void {
navigator.clipboard.writeText(text).catch((err) => {
console.error('[Clipboard]', err);
import('metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {});
});
}
@@ -203,7 +203,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
// 修复: 4 个 session 操作统一改为 await IPC + 失败回滚,避免 UI 与 DB 状态不一致
// 之前 archive 仅调用前端 store 未调用 IPCrename/pin/delete 未等待 IPC 结果即更新 UI
const showError = (msg: string) => {
import('metona-toast').then((mod) => mod.default.error(msg)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(msg)).catch(() => {});
};
return [
{ id: 'rename', icon: Edit, label: '重命名', action: async () => {
+2 -2
View File
@@ -19,7 +19,7 @@ import { useEffect } from 'react';
export function ToastContainer(): null {
useEffect(() => {
// 动态导入 metona-toast 并配置
import('metona-toast').then((mod) => {
import('@metona-team/metona-toast').then((mod) => {
const MeToast = mod.default;
// 全局配置(设计规范指定的参数)
@@ -52,7 +52,7 @@ export function ToastContainer(): null {
if (window.metona?.toast?.onShow) {
const unsubscribe = window.metona.toast.onShow(async (data) => {
try {
const MeToast = (await import('metona-toast')).default;
const MeToast = (await import('@metona-team/metona-toast')).default;
const { type, message, options } = data;
switch (type) {
case 'success': MeToast.success(message, options); break;
+9 -6
View File
@@ -47,6 +47,8 @@ export function ChatInput(): React.JSX.Element {
const abort = useAgentStore((s) => s.abort);
const isStreaming = useAgentStore((s) => s.isStreaming);
const configLoaded = useAgentStore((s) => s.configLoaded);
// v0.3.18 修复: 工具未就绪时禁用发送按钮
const toolsReady = useAgentStore((s) => s.toolsReady);
const tokenUsage = useAgentStore((s) => s.tokenUsage);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const provider = useAgentStore((s) => s.provider);
@@ -106,7 +108,7 @@ export function ChatInput(): React.JSX.Element {
// v0.3.0: 用 Toast 提示用户 DeepSeek 不支持图片
const skipped = fileArray.length - filtered.length;
// v0.3.0 修复:记录 Toast 加载失败错误到控制台,而非静默吞掉
import('metona-toast').then((mod) => {
import('@metona-team/metona-toast').then((mod) => {
mod.default.warning(`DeepSeek 不支持图片,已自动跳过 ${skipped} 个图片文件`);
}).catch((err) => {
console.error('[ChatInput] Failed to load metona-toast for DeepSeek image warning:', err);
@@ -121,14 +123,14 @@ export function ChatInput(): React.JSX.Element {
(r): r is PromiseFulfilledResult<Attachment> => r.status === 'fulfilled',
).map((r) => r.value);
if (successful.length === 0) {
import('metona-toast').then((mod) => {
import('@metona-team/metona-toast').then((mod) => {
mod.default.error('文件读取失败,请检查文件是否损坏或被锁定');
}).catch(() => {});
return;
}
if (successful.length < filtered.length) {
const failedCount = filtered.length - successful.length;
import('metona-toast').then((mod) => {
import('@metona-team/metona-toast').then((mod) => {
mod.default.warning(`${failedCount} 个文件读取失败,已跳过`);
}).catch(() => {});
}
@@ -173,6 +175,7 @@ export function ChatInput(): React.JSX.Element {
if (!trimmed && attachments.length === 0) return;
if (isStreaming) return;
if (!configLoaded) return; // P1-6: 配置未加载完成时禁止发送
if (!toolsReady) return; // v0.3.18: 工具未就绪时禁止发送
// 处理 / 命令
if (trimmed.startsWith('/')) {
@@ -328,8 +331,8 @@ export function ChatInput(): React.JSX.Element {
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={configLoaded ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '正在加载配置...'}
disabled={isStreaming || !configLoaded}
placeholder={configLoaded ? (toolsReady ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '工具加载中...') : '正在加载配置...'}
disabled={isStreaming || !configLoaded || !toolsReady}
multiline
rows={1}
sx={{
@@ -365,7 +368,7 @@ export function ChatInput(): React.JSX.Element {
<Square size={12} style={{ marginRight: 6 }} />
</Button>
) : (
<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 }}>
<Button variant="contained" size="small" onClick={handleSend} disabled={(!input.trim() && attachments.length === 0) || !configLoaded || !toolsReady} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) && configLoaded && toolsReady ? 1 : 0.5 }}>
<Send size={12} style={{ marginRight: 6 }} />
</Button>
)}
+3 -3
View File
@@ -56,7 +56,7 @@ export function Sidebar(): React.JSX.Element {
// M-9 修复: 显示错误提示而非静默吞错后创建本地假会话
// 之前的行为:catch 后继续创建本地 s_${Date.now()} 会话,但该会话在主进程不存在,下次刷新消失
console.error('[Sidebar] Failed to create session:', err);
import('metona-toast').then((mod) => {
import('@metona-team/metona-toast').then((mod) => {
mod.default.error('创建会话失败,请检查数据库状态');
}).catch(() => {});
return; // 不创建本地假会话
@@ -119,7 +119,7 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
await window.metona.sessions.delete(session.id);
} catch (err) {
console.error('[Sidebar] Failed to delete session:', err);
import('metona-toast').then((mod) => {
import('@metona-team/metona-toast').then((mod) => {
mod.default.error('删除会话失败,请重试');
}).catch(() => {});
// 不调用 removeSession,保留会话在 UI 中(与数据库状态一致)
@@ -191,7 +191,7 @@ function ToolManagerPanel() {
}).catch((err) => {
console.error('[Sidebar]', err);
if (!cancelled) {
import('metona-toast').then((mod) => mod.default.error('加载工具列表失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('加载工具列表失败')).catch(() => {});
}
});
}
+2 -2
View File
@@ -175,12 +175,12 @@ export function MemoryViewer(): React.JSX.Element {
const res = await window.metona.memory.delete(type, id);
if (!res.success) {
// 用户主动操作失败应用 toast(与项目惯例一致),不污染加载/搜索的 Alert
import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '删除失败')).catch(() => {});
return;
}
await loadMemories();
} catch (err) {
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除失败')).catch(() => {});
}
};
@@ -73,7 +73,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
const DEFAULT_CTX: Record<string, number | null> = {
deepseek: 1_000_000,
agnes: 1_000_000,
mimo: 131_072,
mimo: 1_000_000,
ollama: null,
};
// 上下文窗口校验:ollama 允许空,最小 512;其他 provider 最小 4096
@@ -110,7 +110,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
const r = await window.metona.config.setBatch(entries);
if (r && !r.success) {
console.error('[OnboardingWizard]', 'Batch config save failed:', r.error);
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试')).catch(() => {});
return;
}
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
@@ -127,7 +127,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
} catch (err) {
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
import('metona-toast').then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`)).catch(() => {});
}
};
@@ -210,7 +210,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
if (!r.canceled && r.path) setWorkspacePath(r.path);
} catch (err) {
console.error('[OnboardingWizard]', err);
import('metona-toast').then((mod) => mod.default.error('选择文件夹失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('选择文件夹失败')).catch(() => {});
}
}
}}></Button>
+34 -34
View File
@@ -96,12 +96,12 @@ function useConfig<T>(key: string, defaultValue: T): [T, (v: T) => void] {
if (r && !r.success) {
// 只有当没有后续 set 操作时才回滚,避免覆盖用户的新修改
if (seqRef.current === seq) setValue(prev);
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {});
import('@metona-team/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(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('配置保存失败')).catch(() => {});
});
}, [key]);
return [value, set];
@@ -159,18 +159,18 @@ function WorkspaceSettings() {
try {
const integrityResult = await window.metona?.workspace?.checkDatabaseIntegrity(currentPath);
if (!integrityResult?.success) {
import('metona-toast').then((mod) => mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`)).catch(() => {});
setApplying(false);
return;
}
if (!integrityResult.ok) {
import('metona-toast').then((mod) => mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`)).catch(() => {});
setApplying(false);
return;
}
} catch (err) {
console.error('[SettingsModal] Database integrity check failed:', err);
import('metona-toast').then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`)).catch(() => {});
setApplying(false);
return;
}
@@ -186,7 +186,7 @@ function WorkspaceSettings() {
} catch (err) {
// 用户勾选了继承文件,失败时必须告知,否则切到新空间才发现文件是空的,潜在数据丢失风险
console.error('[SettingsModal] Inherit failed:', err);
import('metona-toast').then((mod) => mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`)).catch(() => {});
}
}
@@ -200,7 +200,7 @@ function WorkspaceSettings() {
} catch (err) {
console.error('[SettingsModal]', err);
// 用户主动操作(切换工作空间)失败必须有反馈
import('metona-toast').then((mod) => mod.default.error(`切换工作空间失败:${(err as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`切换工作空间失败:${(err as Error).message}`)).catch(() => {});
} finally {
setApplying(false);
}
@@ -216,11 +216,11 @@ function WorkspaceSettings() {
try {
const r = await window.metona?.app?.showItemInFolder(workspacePath);
if (r && !r.success) {
import('metona-toast').then((mod) => mod.default.error(r.error ?? '打开文件夹失败')).catch(() => {});
import('@metona-team/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(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {});
}
};
@@ -364,7 +364,7 @@ function LLMSettings() {
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(131072);
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(1000000);
const [showKey, setShowKey] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
@@ -466,14 +466,14 @@ function LLMSettings() {
const handleSave = async () => {
if (hasBlockingError) {
import('metona-toast').then((mod) => mod.default.error('请修正表单中的错误后再保存')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('请修正表单中的错误后再保存')).catch(() => {});
return;
}
setSaving(true);
try {
const setBatch = window.metona?.config?.setBatch;
if (!setBatch) {
import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {});
return;
}
// v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败
@@ -493,12 +493,12 @@ function LLMSettings() {
];
const r = await setBatch(entries);
if (r && !r.success) {
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {});
} else {
import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {});
}
} catch (err) {
import('metona-toast').then((mod) => mod.default.error(`保存失败:${(err as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`保存失败:${(err as Error).message}`)).catch(() => {});
} finally {
setSaving(false);
}
@@ -608,11 +608,11 @@ function LLMSettings() {
label="上下文窗口 (contextWindow)"
type="number"
value={mimoCtxWindow}
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 131072)}
placeholder="如 32768、65536、131072"
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 1000000)}
placeholder="如 65536、131072、1000000"
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
error={mimoCtxError}
helperText={mimoCtxError ? '最小值为 4096' : '官方未公布具体值,默认 131072,用于压缩判断'}
helperText={mimoCtxError ? '最小值为 4096' : '默认 1000000(1M),用于上下文压缩判断'}
/>
)}
@@ -735,12 +735,12 @@ function ToolsSettings() {
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(() => {});
import('@metona-team/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(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('切换工具失败')).catch(() => {});
}
};
@@ -752,11 +752,11 @@ function ToolsSettings() {
if (r.success) {
setAutoExecList((p) => enabled ? [...p, name] : p.filter((n) => n !== name));
} else {
import('metona-toast').then((mod) => mod.default.error(r.error ?? '设置自动执行失败')).catch(() => {});
import('@metona-team/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(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('设置自动执行失败')).catch(() => {});
}
};
@@ -909,11 +909,11 @@ function MCPSettings() {
if (r?.success) {
setNewName(''); setNewCommand(''); setNewArgs(''); setShowAdd(false); loadServers();
} else {
import('metona-toast').then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败')).catch(() => {});
import('@metona-team/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(() => {});
import('@metona-team/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' };
@@ -955,11 +955,11 @@ function MCPSettings() {
if (r?.success) {
loadServers();
} else {
import('metona-toast').then((mod) => mod.default.error(r?.error ?? '操作失败')).catch(() => {});
import('@metona-team/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(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`操作失败:${(err as Error).message}`)).catch(() => {});
}
}}>{s.status === 'connected' ? '断开' : '连接'}</Button>
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
@@ -1244,10 +1244,10 @@ function LogsSettings() {
try {
const r = await window.metona?.app?.showItemInFolder?.(logFilePath);
if (r && !r.success) {
import('metona-toast').then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`)).catch(() => {});
}
} catch (e) {
import('metona-toast').then((mod) => mod.default.error(`打开失败: ${(e as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`打开失败: ${(e as Error).message}`)).catch(() => {});
}
};
@@ -1260,7 +1260,7 @@ function LogsSettings() {
} catch (e) {
setCopyState('error');
setTimeout(() => setCopyState('idle'), 1500);
import('metona-toast').then((mod) => mod.default.error(`复制失败: ${(e as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`复制失败: ${(e as Error).message}`)).catch(() => {});
}
};
@@ -1272,11 +1272,11 @@ function LogsSettings() {
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 {
import('metona-toast').then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`)).catch(() => {});
}
} catch (err) {
console.error('[LogsSettings]', err);
import('metona-toast').then((mod) => mod.default.error(`导出失败: ${(err as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`导出失败: ${(err as Error).message}`)).catch(() => {});
}
};
const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' };
@@ -1293,7 +1293,7 @@ function LogsSettings() {
else if (type === 'memories') r = await window.metona?.data?.clearMemories();
else r = await window.metona?.data?.clearAuditLogs();
if (r?.success) {
import('metona-toast').then((mod) => mod.default.success(`${labels[type]}已清理`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.success(`${labels[type]}已清理`)).catch(() => {});
// 修复: 清理会话后同步清空前端状态,无需重启应用
if (type === 'sessions') {
useSessionStore.getState().setSessions([]);
@@ -1306,10 +1306,10 @@ function LogsSettings() {
useUIStore.getState().bumpMemoryVersion();
}
} else {
import('metona-toast').then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`)).catch(() => {});
}
} catch (e) {
import('metona-toast').then((mod) => mod.default.error(`失败: ${(e as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`失败: ${(e as Error).message}`)).catch(() => {});
}
setClearing(null);
};
+7 -7
View File
@@ -110,7 +110,7 @@ export function TaskList(): React.JSX.Element {
const handleCreate = async () => {
if (!sessionId) {
// 用户主动操作失败应用 toast(与项目惯例一致)
import('metona-toast').then((mod) => mod.default.error('请先选择或创建会话')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('请先选择或创建会话')).catch(() => {});
return;
}
if (!newTitle.trim()) {
@@ -128,7 +128,7 @@ export function TaskList(): React.JSX.Element {
priority: newPriority,
});
if (!res.success) {
import('metona-toast').then((mod) => mod.default.error(res.error ?? '创建任务失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '创建任务失败')).catch(() => {});
return;
}
setNewTitle('');
@@ -136,7 +136,7 @@ export function TaskList(): React.JSX.Element {
setShowAddForm(false);
await loadTasks(sessionId);
} catch (err) {
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '创建任务失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error((err as Error).message ?? '创建任务失败')).catch(() => {});
} finally {
setCreating(false);
}
@@ -149,12 +149,12 @@ export function TaskList(): React.JSX.Element {
try {
const res = await window.metona.tasks.update(task.id, { status: nextStatus }, sessionId);
if (!res.success) {
import('metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {});
return;
}
await loadTasks(sessionId ?? undefined);
} catch (err) {
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '更新任务失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error((err as Error).message ?? '更新任务失败')).catch(() => {});
}
};
@@ -164,13 +164,13 @@ export function TaskList(): React.JSX.Element {
try {
const res = await window.metona.tasks.delete(id, sessionId);
if (!res.success) {
import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {});
return;
}
if (expandedId === id) setExpandedId(null);
await loadTasks(sessionId ?? undefined);
} catch (err) {
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除任务失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除任务失败')).catch(() => {});
}
};
+17 -6
View File
@@ -17,9 +17,12 @@ export function TokenUsage(): React.JSX.Element {
const contextWindow = useAgentStore((s) => s.contextWindow);
const maxTokens = contextWindow;
const usagePercent = tokenUsage.totalTokens > 0
? Math.min((tokenUsage.totalTokens / maxTokens) * 100, 100)
// v0.3.18 修复: 上下文占用百分比改用 lastInputTokens(单次占用),而非累计 totalTokens
// 之前 totalTokens 是所有轮次累加值,除以单次窗口得出无意义的百分比
const contextPercent = maxTokens > 0 && tokenUsage.lastInputTokens > 0
? Math.min((tokenUsage.lastInputTokens / maxTokens) * 100, 100)
: 0;
// 累计消耗的输入/输出占比(用于进度条展示累计消耗的构成)
const inputPercent = tokenUsage.totalTokens > 0
? (tokenUsage.inputTokens / tokenUsage.totalTokens) * 100
: 0;
@@ -50,20 +53,28 @@ export function TokenUsage(): React.JSX.Element {
<Table size="small" sx={{ '& .MuiTableCell-root': { border: 0, py: 0.5, px: 0.5 } }}>
<TableBody>
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'text.primary', fontSize: 12, textAlign: 'right' }}>
{tokenUsage.totalTokens > 0 ? formatTokens(tokenUsage.totalTokens) : '-'}
</TableCell>
</TableRow>
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{
fontFamily: 'monospace', fontWeight: 600, fontSize: 11, textAlign: 'right',
color: usagePercent > 80 ? 'error.main' : usagePercent > 60 ? 'warning.main' : 'text.secondary',
color: contextPercent > 80 ? 'error.main' : contextPercent > 60 ? 'warning.main' : 'text.secondary',
}}>
{tokenUsage.totalTokens > 0 ? `${usagePercent.toFixed(1)}%` : '-'}
{tokenUsage.lastInputTokens > 0 ? `${formatTokens(tokenUsage.lastInputTokens)} (${contextPercent.toFixed(1)}%)` : '-'}
</TableCell>
</TableRow>
{tokenUsage.lastCompressedSaved > 0 && (
<TableRow>
<TableCell sx={{ color: 'success.main', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'success.main', textAlign: 'right' }}>
{formatTokens(tokenUsage.lastCompressedSaved)}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}>
+1 -1
View File
@@ -70,7 +70,7 @@ export function WorkspaceViewer(): React.JSX.Element {
await window.metona?.app?.showItemInFolder(path);
} catch (err) {
console.error('[WorkspaceViewer]', err);
import('metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('打开文件夹失败')).catch(() => {});
}
};
+14
View File
@@ -104,6 +104,10 @@ export function useAgentStream(): void {
toolCall?: { id: string; name: string; args: Record<string, unknown> };
toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number };
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
/** v0.3.18 修复: 上下文压缩事件数据 */
savedTokens?: number;
originalTokens?: number;
compressedTokens?: number;
error?: { code: string; message: string };
state?: string;
};
@@ -345,9 +349,13 @@ export function useAgentStream(): void {
if (data.usage) {
const cur = getStore().tokenUsage;
getStore().updateTokenUsage({
// 累计值:所有轮次累加,只增不减
inputTokens: cur.inputTokens + (data.usage.inputTokens ?? 0),
outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0),
totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0),
// v0.3.18 修复: 单次上下文占用 — 替换不累加
// 反映当前对话上下文的真实大小,压缩后会大幅下降
lastInputTokens: data.usage.inputTokens ?? 0,
});
// 同步更新当前 Trace 步骤的 token 用量
@@ -361,6 +369,12 @@ export function useAgentStream(): void {
}
break;
// v0.3.18 修复: 上下文压缩事件 — 记录节省的 token,供 UI 显示压缩效果
case 'compressed': {
getStore().applyCompression(data.savedTokens ?? 0);
break;
}
// 流结束
case 'done':
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
+2 -2
View File
@@ -83,7 +83,7 @@ export function useKeyboardShortcuts(): void {
}).catch((err) => {
console.error('[KeyboardShortcuts]', err);
// 与 Sidebar/CommandPalette 入口对齐:失败时弹 toast 提示用户
import('metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {});
});
}
e.preventDefault();
@@ -171,7 +171,7 @@ export function useKeyboardShortcuts(): void {
navigator.clipboard.writeText(lastAssistant.content).catch((err) => {
console.error('[KeyboardShortcuts]', err);
// 复制是用户主动操作,失败时必须有反馈
import('metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {});
});
}
e.preventDefault();
+59 -7
View File
@@ -61,9 +61,24 @@ export type AgentStatus = 'idle' | 'thinking' | 'executing' | 'error';
// ===== Token 统计 =====
export interface TokenUsage {
/** 累计输入 token(所有轮次累加) */
inputTokens: number;
/** 累计输出 token(所有轮次累加) */
outputTokens: number;
/** 累计总 token(所有轮次累加) */
totalTokens: number;
/**
* v0.3.18 修复: 最近一次 LLM token
* "上下文占用百分比"
* LLM inputTokens
*
*/
lastInputTokens: number;
/**
* v0.3.18 修复: 最近一次上下文压缩节省的 token
* UI "压缩节省了 X tokens"
*/
lastCompressedSaved: number;
}
// ===== Trace 步骤 =====
@@ -107,6 +122,10 @@ interface AgentState {
// P1-6 修复: 配置加载完成标志 — 加载完成前禁用发送按钮
configLoaded: boolean;
// v0.3.18 修复: 工具就绪标志 — MCP 初始化完成前禁用发送按钮
// 避免用户在工具不全时发送消息,导致子任务委派等 MCP 工具不可用
toolsReady: boolean;
// Run 标识(用于过滤旧流事件)
currentRunId: string | null;
@@ -126,12 +145,19 @@ interface AgentState {
setStreaming: (streaming: boolean) => void;
// P1-6 修复: 配置加载完成标志的 setter
setConfigLoaded: (loaded: boolean) => void;
// v0.3.18 修复: 工具就绪标志的 setter
setToolsReady: (ready: boolean) => void;
setCurrentRunId: (runId: string | null) => void;
addTraceStep: (step: TraceStep) => void;
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
updateTraceStepById: (id: string, updates: Partial<TraceStep>) => void;
updateLastTraceStep: (updates: Partial<TraceStep>) => void;
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
/**
* v0.3.18 修复: 应用上下文压缩事件
* token UI
*/
applyCompression: (savedTokens: number) => void;
setProvider: (provider: string, model: string) => void;
setMaxIterations: (max: number) => void;
setCurrentIteration: (n: number) => void;
@@ -147,10 +173,11 @@ export const useAgentStore = create<AgentState>((set, get) => ({
agentStatus: 'idle',
currentIteration: 0,
maxIterations: 20,
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
traceSteps: [],
isStreaming: false,
configLoaded: false,
toolsReady: false,
currentRunId: null,
provider: '',
model: '',
@@ -159,7 +186,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// ===== Actions =====
setCurrentSession: (id) => {
set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null });
set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null });
// 从数据库加载该会话的消息
if (id && window.metona?.sessions?.getMessages) {
@@ -206,7 +233,19 @@ export const useAgentStore = create<AgentState>((set, get) => ({
}));
set({ traceSteps: steps });
}
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage });
// v0.3.18 修复: 兼容旧数据 — 旧 tokenUsage 没有 lastInputTokens/lastCompressedSaved 字段
if (data.tokenUsage) {
const old = data.tokenUsage as Partial<TokenUsage>;
set({
tokenUsage: {
inputTokens: old.inputTokens ?? 0,
outputTokens: old.outputTokens ?? 0,
totalTokens: old.totalTokens ?? 0,
lastInputTokens: old.lastInputTokens ?? 0,
lastCompressedSaved: old.lastCompressedSaved ?? 0,
},
});
}
}
}).catch((err) => { console.error('[AgentStore]', err); });
}
@@ -227,6 +266,12 @@ export const useAgentStore = create<AgentState>((set, get) => ({
sendMessage: async (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => {
let sessionId = get().currentSessionId;
// v0.3.18 修复: 工具未就绪时阻止发送,避免 MCP 工具不可用
if (!get().toolsReady) {
import('@metona-team/metona-toast').then((mod) => mod.default.warning('工具正在加载中,请稍候...')).catch(() => {});
return;
}
// 没有当前会话时自动创建
if (!sessionId && window.metona?.sessions?.create) {
try {
@@ -250,7 +295,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
timestamp: Date.now(),
});
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
import('metona-toast').then((mod) => mod.default.error(`无法创建会话:${(err as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`无法创建会话:${(err as Error).message}`)).catch(() => {});
return;
}
}
@@ -272,7 +317,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖
// TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps
// 历史 trace 仍在 DB 中,切换会话回来可恢复
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
}));
// 自动更新会话标题为用户第一条消息
@@ -331,7 +376,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
timestamp: Date.now(),
});
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
import('metona-toast').then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast').then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`)).catch(() => {});
});
}
},
@@ -362,6 +407,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
setStreaming: (streaming) => set({ isStreaming: streaming }),
setConfigLoaded: (loaded) => set({ configLoaded: loaded }),
// v0.3.18 修复: 工具就绪标志 setter
setToolsReady: (ready) => set({ toolsReady: ready }),
setCurrentRunId: (runId) => set({ currentRunId: runId }),
@@ -394,6 +441,11 @@ export const useAgentStore = create<AgentState>((set, get) => ({
updateTokenUsage: (usage) =>
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
// v0.3.18 修复: 应用上下文压缩事件,记录节省的 token 数
// 压缩后下一轮 LLM 调用的 inputTokens 会大幅下降,lastInputTokens 会自动反映
applyCompression: (savedTokens) =>
set((s) => ({ tokenUsage: { ...s.tokenUsage, lastCompressedSaved: savedTokens } })),
setProvider: (provider, model) => {
// L-16 修复: 使用命名常量替代魔法数字
// v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取
@@ -430,7 +482,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
messages: [],
agentStatus: 'idle',
currentIteration: 0,
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
traceSteps: [],
isStreaming: false,
currentRunId: null,
+4
View File
@@ -260,6 +260,10 @@ interface MetonaToolInfo {
interface MetonaToolsAPI {
list: () => Promise<MetonaToolInfo[]>;
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
/** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */
onReady: (callback: (data: { toolCount: number }) => void) => () => void;
/** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态) */
isReady: () => Promise<{ ready: boolean; toolCount: number }>;
}
// ===== SearXNG API =====