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
This commit is contained in:
+21
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 丢失
|
||||
|
||||
@@ -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-toast').then((mod) => mod.default.warning('工具正在加载中,请稍候...')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有当前会话时自动创建
|
||||
if (!sessionId && window.metona?.sessions?.create) {
|
||||
try {
|
||||
@@ -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 },
|
||||
}));
|
||||
|
||||
// 自动更新会话标题为用户第一条消息
|
||||
@@ -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,
|
||||
|
||||
Vendored
+4
@@ -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 =====
|
||||
|
||||
Reference in New Issue
Block a user