feat: v0.7.3 成本收口 · 状态一致 · 死账清理 — Prompt Cache 根治 + SSRF DNS Pinning + 87 用例扩充全量回归
P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息 前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 + cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token 成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter 同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点 时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用 + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数, 未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控 [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键) P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构 resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup 钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式 退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接, 替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用 pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为 可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin; ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent 终结三处接线, 根治 rememberedDecisions 泄漏) P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→ Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs 死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收 管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个 run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页 手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 + 审计链一键校验) P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用 同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时 刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机, teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页 显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐 docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/ OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐) 测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context 分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control 四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向 解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、 trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机 (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动 (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试 前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与 并发去重的检查顺序竞态(去重先于幂等) 版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect) 回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过 (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-0.7.2-blue?style=flat-square" alt="Version" />
|
<img src="https://img.shields.io/badge/version-0.7.3-blue?style=flat-square" alt="Version" />
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
|
||||||
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
|
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
|
||||||
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
|
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
|
||||||
@@ -657,6 +657,11 @@ OLLAMA_BASE_URL=http://localhost:11434
|
|||||||
| `agent.thinkingEnabled` | `true` | 启用 Thinking 推理模式 |
|
| `agent.thinkingEnabled` | `true` | 启用 Thinking 推理模式 |
|
||||||
| `agent.thinkingEffort` | `high` | 推理强度 (low / medium / high / max) |
|
| `agent.thinkingEffort` | `high` | 推理强度 (low / medium / high / max) |
|
||||||
| `agent.confirmationTimeoutMs` | `120000` | 确认弹窗超时 (30s ~ 600s) |
|
| `agent.confirmationTimeoutMs` | `120000` | 确认弹窗超时 (30s ~ 600s) |
|
||||||
|
| `agent.enableReflection` | `false` | 反思阶段开关 — 开启后每轮工具执行经过 REFLECTING 状态(失败结果告警,不阻断) |
|
||||||
|
| `memory.consolidationEnabled` | `true` | 会话结束记忆固化总开关(v0.7.3 节流策略) |
|
||||||
|
| `memory.consolidationMinChars` | `200` | 固化内容门控:回答字符数阈值(或存在成功工具调用) |
|
||||||
|
| `memory.consolidationIntervalMs` | `600000` | 固化频率窗口(同会话两次固化的最小间隔) |
|
||||||
|
| `mcp.autoReconnect` | `true` | MCP 断连自动重连(指数退避 5s/15s/60s,最多 3 次) |
|
||||||
| `deepseek.contextWindow` | `1000000` | DeepSeek 上下文窗口 |
|
| `deepseek.contextWindow` | `1000000` | DeepSeek 上下文窗口 |
|
||||||
| `agnes.contextWindow` | `1000000` | Agnes 上下文窗口 |
|
| `agnes.contextWindow` | `1000000` | Agnes 上下文窗口 |
|
||||||
| `mimo.contextWindow` | `1000000` | MiMo 上下文窗口 |
|
| `mimo.contextWindow` | `1000000` | MiMo 上下文窗口 |
|
||||||
@@ -873,8 +878,8 @@ npm run lint:fix # ESLint 自动修复
|
|||||||
npm run format # Prettier 格式化
|
npm run format # Prettier 格式化
|
||||||
|
|
||||||
# ─── 测试 ─────────────────────────────────
|
# ─── 测试 ─────────────────────────────────
|
||||||
npm test # 运行单元测试 (Vitest, 系统 Node — 473 通过, 34 个 SQLite 依赖用例因 better-sqlite3 ABI 自动跳过)
|
npm test # 运行单元测试 (Vitest, 系统 Node — SQLite 依赖用例因 better-sqlite3 ABI 自动跳过)
|
||||||
npm run test:electron # 运行全量单元测试 (Electron Node ABI, 507 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
|
npm run test:electron # 运行全量单元测试 (Electron Node ABI, 全部用例执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
|
||||||
npm run test:watch # 测试监听模式
|
npm run test:watch # 测试监听模式
|
||||||
|
|
||||||
# ─── 构建 ─────────────────────────────────
|
# ─── 构建 ─────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Anthropic system cache_control 断言测试(v0.7.3 P1-1)
|
||||||
|
*
|
||||||
|
* Anthropic 缓存按"内容块前缀"命中:system 必须以块数组传递并在块上打
|
||||||
|
* cache_control 才可缓存。本文件锁定:
|
||||||
|
* C1 非空 system → 块数组 + {type:'ephemeral'};
|
||||||
|
* C2 空 system → 保持空字符串(不发空块);
|
||||||
|
* C3 thinking 模式下断言仍然存在(cache 与 thinking 不互斥);
|
||||||
|
* C4 system 块文本为四分区完整拼接(roleDefinition/outputConstraints/
|
||||||
|
* safetyGuidelines/dynamicReminders)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('electron-log', () => ({
|
||||||
|
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AnthropicAdapter } from '../anthropic.adapter';
|
||||||
|
import type { MetonaRequest } from '../../types';
|
||||||
|
|
||||||
|
function captureFetch(): { bodies: Array<Record<string, unknown>> } {
|
||||||
|
const bodies: Array<Record<string, unknown>> = [];
|
||||||
|
const genericBody = {
|
||||||
|
content: [{ type: 'text', text: 'ok' }],
|
||||||
|
usage: { input_tokens: 3, output_tokens: 2 },
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
|
||||||
|
bodies.push(JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>);
|
||||||
|
return new Response(JSON.stringify(genericBody), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return { bodies };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeAdapter(): AnthropicAdapter {
|
||||||
|
return new AnthropicAdapter({
|
||||||
|
provider: 'anthropic',
|
||||||
|
baseURL: 'http://a.test',
|
||||||
|
apiKey: 'k',
|
||||||
|
defaultModel: 'claude-sonnet-4-5',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(overrides?: Partial<MetonaRequest>): MetonaRequest {
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
sessionId: 's1',
|
||||||
|
iteration: 1,
|
||||||
|
requestId: 'r1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
agentVersion: 'test',
|
||||||
|
},
|
||||||
|
systemPrompt: {
|
||||||
|
roleDefinition: 'You are Metona.',
|
||||||
|
outputConstraints: 'Be concise.',
|
||||||
|
safetyGuidelines: 'Stay safe.',
|
||||||
|
dynamicReminders: '## Current Workspace\n`/ws`',
|
||||||
|
},
|
||||||
|
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
|
||||||
|
params: { maxTokens: 63_488, temperature: 0, stream: false },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnthropicAdapter — system cache_control(P1-1)', () => {
|
||||||
|
it('C1: 非空 system → 单 text 块 + cache_control ephemeral', async () => {
|
||||||
|
const adapter = makeAdapter();
|
||||||
|
const { bodies } = captureFetch();
|
||||||
|
await adapter.send(makeRequest());
|
||||||
|
|
||||||
|
const system = bodies[0].system as Array<{
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
cache_control: { type: string };
|
||||||
|
}>;
|
||||||
|
expect(Array.isArray(system)).toBe(true);
|
||||||
|
expect(system).toHaveLength(1);
|
||||||
|
expect(system[0].type).toBe('text');
|
||||||
|
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C2: 空 system → 保持空字符串(不发空块)', async () => {
|
||||||
|
const adapter = makeAdapter();
|
||||||
|
const { bodies } = captureFetch();
|
||||||
|
await adapter.send(
|
||||||
|
makeRequest({
|
||||||
|
systemPrompt: { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(bodies[0].system).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C3: thinking 模式下 cache_control 断言仍然存在', async () => {
|
||||||
|
const adapter = makeAdapter();
|
||||||
|
const { bodies } = captureFetch();
|
||||||
|
await adapter.send(
|
||||||
|
makeRequest({
|
||||||
|
params: {
|
||||||
|
maxTokens: 8192,
|
||||||
|
temperature: 0,
|
||||||
|
stream: false,
|
||||||
|
thinkingEnabled: true,
|
||||||
|
thinkingEffort: 'high',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const system = bodies[0].system as Array<{ cache_control: { type: string } }>;
|
||||||
|
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||||
|
// thinking 与 cache 共存:thinking 块也在请求体中
|
||||||
|
expect(bodies[0].thinking).toMatchObject({ type: 'enabled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C4: system 块文本为四分区完整拼接', async () => {
|
||||||
|
const adapter = makeAdapter();
|
||||||
|
const { bodies } = captureFetch();
|
||||||
|
await adapter.send(makeRequest());
|
||||||
|
|
||||||
|
const system = bodies[0].system as Array<{ text: string }>;
|
||||||
|
expect(system[0].text).toContain('You are Metona.');
|
||||||
|
expect(system[0].text).toContain('Be concise.');
|
||||||
|
expect(system[0].text).toContain('Stay safe.');
|
||||||
|
expect(system[0].text).toContain('## Current Workspace');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -109,29 +109,69 @@ describe('AnthropicAdapter — 请求体契约', () => {
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: null,
|
content: null,
|
||||||
toolCalls: [
|
toolCalls: [
|
||||||
{ id: 'tc_1', name: 'read_file', args: { path: 'a.txt' }, iteration: 1, timestamp: Date.now() },
|
{
|
||||||
|
id: 'tc_1',
|
||||||
|
name: 'read_file',
|
||||||
|
args: { path: 'a.txt' },
|
||||||
|
iteration: 1,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
{ role: 'tool', content: null, toolResult: { toolCallId: 'tc_1', toolName: 'read_file', result: 'data', success: true, durationMs: 1, timestamp: Date.now() }, timestamp: Date.now() },
|
{
|
||||||
|
role: 'tool',
|
||||||
|
content: null,
|
||||||
|
toolResult: {
|
||||||
|
toolCallId: 'tc_1',
|
||||||
|
toolName: 'read_file',
|
||||||
|
result: 'data',
|
||||||
|
success: true,
|
||||||
|
durationMs: 1,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
// 孤立 tool_result(前面没有对应 tool_use)应被过滤
|
// 孤立 tool_result(前面没有对应 tool_use)应被过滤
|
||||||
{ role: 'tool', content: null, toolResult: { toolCallId: 'tc_orphan', toolName: 'x', result: '', success: true, durationMs: 1, timestamp: Date.now() }, timestamp: Date.now() },
|
{
|
||||||
|
role: 'tool',
|
||||||
|
content: null,
|
||||||
|
toolResult: {
|
||||||
|
toolCallId: 'tc_orphan',
|
||||||
|
toolName: 'x',
|
||||||
|
result: '',
|
||||||
|
success: true,
|
||||||
|
durationMs: 1,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
{ role: 'user', content: 'next?', timestamp: Date.now() },
|
{ role: 'user', content: 'next?', timestamp: Date.now() },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const body = bodies[0];
|
const body = bodies[0];
|
||||||
expect(body.system).toContain('You are Metona.');
|
// v0.7.3 P1-1: system 转为块数组并打 cache_control 断言(稳定前缀 prompt cache)
|
||||||
|
const system = body.system as Array<{
|
||||||
|
type: string;
|
||||||
|
text: string;
|
||||||
|
cache_control: { type: string };
|
||||||
|
}>;
|
||||||
|
expect(Array.isArray(system)).toBe(true);
|
||||||
|
expect(system[0].text).toContain('You are Metona.');
|
||||||
|
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||||
expect(Array.isArray(body.messages)).toBe(true);
|
expect(Array.isArray(body.messages)).toBe(true);
|
||||||
const msgs = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
const msgs = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
||||||
// tool_use 的 assistant 消息存在且携带 id/name
|
// tool_use 的 assistant 消息存在且携带 id/name
|
||||||
const assistantToolMsg = msgs.find((m) => m.role === 'assistant');
|
const assistantToolMsg = msgs.find((m) => m.role === 'assistant');
|
||||||
expect(assistantToolMsg?.content[0]).toMatchObject({ type: 'tool_use', id: 'tc_1', name: 'read_file' });
|
expect(assistantToolMsg?.content[0]).toMatchObject({
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tc_1',
|
||||||
|
name: 'read_file',
|
||||||
|
});
|
||||||
// tool 结果以 user 角色 tool_result 形态出现且配对 id 正确;孤立者被丢弃
|
// tool 结果以 user 角色 tool_result 形态出现且配对 id 正确;孤立者被丢弃
|
||||||
const toolResultBlocks = msgs.flatMap((m) =>
|
const toolResultBlocks = msgs.flatMap((m) => m.content.filter((c) => c.type === 'tool_result'));
|
||||||
m.content.filter((c) => c.type === 'tool_result'),
|
|
||||||
);
|
|
||||||
expect(toolResultBlocks).toHaveLength(1);
|
expect(toolResultBlocks).toHaveLength(1);
|
||||||
expect(toolResultBlocks[0].tool_use_id).toBe('tc_1');
|
expect(toolResultBlocks[0].tool_use_id).toBe('tc_1');
|
||||||
});
|
});
|
||||||
@@ -166,7 +206,15 @@ describe('AnthropicAdapter — 请求体契约', () => {
|
|||||||
});
|
});
|
||||||
const { bodies } = captureFetch();
|
const { bodies } = captureFetch();
|
||||||
await adapter.send(
|
await adapter.send(
|
||||||
makeRequest({ params: { maxTokens: 1500, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'low' } }),
|
makeRequest({
|
||||||
|
params: {
|
||||||
|
maxTokens: 1500,
|
||||||
|
temperature: 0,
|
||||||
|
stream: false,
|
||||||
|
thinkingEnabled: true,
|
||||||
|
thinkingEffort: 'low',
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const body = bodies[0];
|
const body = bodies[0];
|
||||||
const thinking = body.thinking as { type: string; budget_tokens: number };
|
const thinking = body.thinking as { type: string; budget_tokens: number };
|
||||||
@@ -185,13 +233,17 @@ describe('AnthropicAdapter — 请求体契约', () => {
|
|||||||
});
|
});
|
||||||
const { bodies } = captureFetch();
|
const { bodies } = captureFetch();
|
||||||
await adapter.send(
|
await adapter.send(
|
||||||
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: true } }),
|
makeRequest({
|
||||||
|
params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: true },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(bodies[0].temperature).toBeUndefined();
|
expect(bodies[0].temperature).toBeUndefined();
|
||||||
expect(bodies[0].thinking).toBeDefined();
|
expect(bodies[0].thinking).toBeDefined();
|
||||||
|
|
||||||
await adapter.send(
|
await adapter.send(
|
||||||
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false } }),
|
makeRequest({
|
||||||
|
params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(bodies[1].temperature).toBe(0.7);
|
expect(bodies[1].temperature).toBe(0.7);
|
||||||
expect(bodies[1].thinking).toBeUndefined();
|
expect(bodies[1].thinking).toBeUndefined();
|
||||||
@@ -236,13 +288,37 @@ describe('OllamaAdapter — 请求体契约', () => {
|
|||||||
const adapter = makeOllama();
|
const adapter = makeOllama();
|
||||||
const { bodies } = captureFetch();
|
const { bodies } = captureFetch();
|
||||||
|
|
||||||
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'low' } }));
|
await adapter.send(
|
||||||
|
makeRequest({
|
||||||
|
params: {
|
||||||
|
maxTokens: 4096,
|
||||||
|
temperature: 0,
|
||||||
|
stream: false,
|
||||||
|
thinkingEnabled: true,
|
||||||
|
thinkingEffort: 'low',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(bodies[0].think).toBe('low');
|
expect(bodies[0].think).toBe('low');
|
||||||
|
|
||||||
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'max' } }));
|
await adapter.send(
|
||||||
|
makeRequest({
|
||||||
|
params: {
|
||||||
|
maxTokens: 4096,
|
||||||
|
temperature: 0,
|
||||||
|
stream: false,
|
||||||
|
thinkingEnabled: true,
|
||||||
|
thinkingEffort: 'max',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(bodies[1].think).toBe(true);
|
expect(bodies[1].think).toBe(true);
|
||||||
|
|
||||||
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false } }));
|
await adapter.send(
|
||||||
|
makeRequest({
|
||||||
|
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(bodies[2].think).toBeUndefined();
|
expect(bodies[2].think).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,12 +395,26 @@ describe('AgnesAdapter — 思考模式对称性(v0.6.4)', () => {
|
|||||||
});
|
});
|
||||||
const { bodies } = captureFetch();
|
const { bodies } = captureFetch();
|
||||||
|
|
||||||
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'high' } }));
|
await adapter.send(
|
||||||
|
makeRequest({
|
||||||
|
params: {
|
||||||
|
maxTokens: 4096,
|
||||||
|
temperature: 0,
|
||||||
|
stream: false,
|
||||||
|
thinkingEnabled: true,
|
||||||
|
thinkingEffort: 'high',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
((bodies[0].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
|
((bodies[0].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false } }));
|
await adapter.send(
|
||||||
|
makeRequest({
|
||||||
|
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
((bodies[1].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
|
((bodies[1].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|||||||
@@ -248,8 +248,7 @@ export class AnthropicAdapter extends BaseAdapter {
|
|||||||
usage: {
|
usage: {
|
||||||
inputTokens: messageStartInputTokens,
|
inputTokens: messageStartInputTokens,
|
||||||
outputTokens: (usage.output_tokens as number) ?? 0,
|
outputTokens: (usage.output_tokens as number) ?? 0,
|
||||||
totalTokens:
|
totalTokens: messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
|
||||||
messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
|
|
||||||
// v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集,
|
// v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集,
|
||||||
// cache_read/creation_input_tokens 与 output_tokens 同在 usage 内)
|
// cache_read/creation_input_tokens 与 output_tokens 同在 usage 内)
|
||||||
cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined,
|
cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined,
|
||||||
@@ -343,10 +342,7 @@ export class AnthropicAdapter extends BaseAdapter {
|
|||||||
try {
|
try {
|
||||||
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
|
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
args = truncatedArgumentsPayload(
|
args = truncatedArgumentsPayload((err as Error).message, block.argsBuffer.slice(-120));
|
||||||
(err as Error).message,
|
|
||||||
block.argsBuffer.slice(-120),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
yield {
|
yield {
|
||||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||||
@@ -506,11 +502,22 @@ export class AnthropicAdapter extends BaseAdapter {
|
|||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model: this.config.defaultModel,
|
model: this.config.defaultModel,
|
||||||
max_tokens: maxTokensForRequest,
|
max_tokens: maxTokensForRequest,
|
||||||
system,
|
|
||||||
messages: merged,
|
messages: merged,
|
||||||
stream,
|
stream,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v0.7.3 P1-1: system 稳定前缀打 prompt cache 断言。
|
||||||
|
// Anthropic 缓存按"内容块前缀"命中 —— system 以字符串传递时无法附加
|
||||||
|
// cache_control,必须转为块数组并在最后一个块上打 {type:'ephemeral'}。
|
||||||
|
// 缓存前缀覆盖 tools + system(请求组装顺序 tools 在前):system 稳定后,
|
||||||
|
// 多轮对话/多轮迭代复用同一前缀,输入 token 计费按缓存读价(约 1/10)。
|
||||||
|
// 前缀稳定性由 P1-1 保证:易变内容(日期/记忆/附件提示)已迁入用户消息。
|
||||||
|
if (system) {
|
||||||
|
body.system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
|
||||||
|
} else {
|
||||||
|
body.system = system;
|
||||||
|
}
|
||||||
|
|
||||||
// 工具定义(input_schema 命名)
|
// 工具定义(input_schema 命名)
|
||||||
if (request.tools?.length) {
|
if (request.tools?.length) {
|
||||||
body.tools = request.tools.map((t) => ({
|
body.tools = request.tools.map((t) => ({
|
||||||
|
|||||||
@@ -59,17 +59,21 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
const nativeRequest = await this.toNativeRequest(request);
|
const nativeRequest = await this.toNativeRequest(request);
|
||||||
|
|
||||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||||
const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
|
const response = await this.fetchWithTimeout(
|
||||||
|
`${this.baseURL}/api/chat`,
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...nativeRequest, stream: false }),
|
body: JSON.stringify({ ...nativeRequest, stream: false }),
|
||||||
}, this.config.timeoutMs ?? 300_000);
|
},
|
||||||
|
this.config.timeoutMs ?? 300_000,
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await this.throwHttpError(response, 'Ollama API error');
|
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>;
|
||||||
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration);
|
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +83,15 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
const nativeRequest = await this.toNativeRequest(request);
|
const nativeRequest = await this.toNativeRequest(request);
|
||||||
|
|
||||||
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
|
||||||
const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
|
const response = await this.fetchWithTimeout(
|
||||||
|
`${this.baseURL}/api/chat`,
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...nativeRequest, stream: true }),
|
body: JSON.stringify({ ...nativeRequest, stream: true }),
|
||||||
}, this.config.timeoutMs ?? 300_000);
|
},
|
||||||
|
this.config.timeoutMs ?? 300_000,
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
await this.throwHttpError(response, 'Ollama stream error');
|
await this.throwHttpError(response, 'Ollama stream error');
|
||||||
@@ -209,7 +217,10 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
}
|
}
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
// P2-8 修复: 与 sse-stream.ts 一致,记录解析失败行便于诊断
|
// P2-8 修复: 与 sse-stream.ts 一致,记录解析失败行便于诊断
|
||||||
log.warn(`[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`, trimmed.slice(0, 200));
|
log.warn(
|
||||||
|
`[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`,
|
||||||
|
trimmed.slice(0, 200),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,7 +250,13 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
format?: string | object;
|
format?: string | object;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
}): Promise<{ response: string; thinking?: string; done: boolean; totalDuration: number; evalCount: number }> {
|
}): Promise<{
|
||||||
|
response: string;
|
||||||
|
thinking?: string;
|
||||||
|
done: boolean;
|
||||||
|
totalDuration: number;
|
||||||
|
evalCount: number;
|
||||||
|
}> {
|
||||||
const response = await fetch(`${this.baseURL}/api/generate`, {
|
const response = await fetch(`${this.baseURL}/api/generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -248,9 +265,12 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Ollama generate error: ${response.status}`);
|
if (!response.ok) throw new Error(`Ollama generate error: ${response.status}`);
|
||||||
const data = await response.json() as {
|
const data = (await response.json()) as {
|
||||||
response?: string; thinking?: string; done?: boolean;
|
response?: string;
|
||||||
total_duration?: number; eval_count?: number;
|
thinking?: string;
|
||||||
|
done?: boolean;
|
||||||
|
total_duration?: number;
|
||||||
|
eval_count?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -277,7 +297,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Ollama embed error: ${response.status}`);
|
if (!response.ok) throw new Error(`Ollama embed error: ${response.status}`);
|
||||||
const data = await response.json() as { embeddings?: number[][]; total_duration?: number };
|
const data = (await response.json()) as { embeddings?: number[][]; total_duration?: number };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
embeddings: data.embeddings ?? [],
|
embeddings: data.embeddings ?? [],
|
||||||
@@ -299,7 +319,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json() as {
|
const data = (await response.json()) as {
|
||||||
models?: Array<{
|
models?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -309,6 +329,8 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
if (data.models?.length) {
|
if (data.models?.length) {
|
||||||
// v0.6.4 P4-1: 能力标志改为逐模型 /api/show 实测探测;单个探测失败
|
// v0.6.4 P4-1: 能力标志改为逐模型 /api/show 实测探测;单个探测失败
|
||||||
// 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性)
|
// 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性)
|
||||||
|
// v0.7.3 P1-4: supportsVision 随探测结果透出(undefined = 未知 → 前端保守放行),
|
||||||
|
// 供上传入口拒绝不支持图片的本地语言模型
|
||||||
const enriched = await Promise.all(
|
const enriched = await Promise.all(
|
||||||
data.models.map(async (m) => {
|
data.models.map(async (m) => {
|
||||||
const caps = await this.probeCapabilities(m.name);
|
const caps = await this.probeCapabilities(m.name);
|
||||||
@@ -319,6 +341,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
|
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
|
||||||
supportsToolCalling: caps ? caps.supportsTools : true,
|
supportsToolCalling: caps ? caps.supportsTools : true,
|
||||||
supportsThinking: caps ? caps.supportsThinking : true,
|
supportsThinking: caps ? caps.supportsThinking : true,
|
||||||
|
supportsVision: caps ? caps.supportsVision : undefined,
|
||||||
description: m.details
|
description: m.details
|
||||||
? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}`
|
? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}`
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -403,7 +426,9 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
|
|
||||||
// ===== POST /api/show =====
|
// ===== POST /api/show =====
|
||||||
|
|
||||||
async showModel(model: string): Promise<{ parameters: string; template: string; capabilities: string[] } | null> {
|
async showModel(
|
||||||
|
model: string,
|
||||||
|
): Promise<{ parameters: string; template: string; capabilities: string[] } | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseURL}/api/show`, {
|
const response = await fetch(`${this.baseURL}/api/show`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -412,7 +437,11 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json() as { parameters?: string; template?: string; capabilities?: string[] };
|
const data = (await response.json()) as {
|
||||||
|
parameters?: string;
|
||||||
|
template?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
parameters: data.parameters ?? '',
|
parameters: data.parameters ?? '',
|
||||||
template: data.template ?? '',
|
template: data.template ?? '',
|
||||||
@@ -470,13 +499,22 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
|
|
||||||
// ===== GET /api/ps =====
|
// ===== GET /api/ps =====
|
||||||
|
|
||||||
async listRunning(): Promise<Array<{ name: string; size: number; sizeVram: number; contextLength: number }>> {
|
async listRunning(): Promise<
|
||||||
|
Array<{ name: string; size: number; sizeVram: number; contextLength: number }>
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseURL}/api/ps`, {
|
const response = await fetch(`${this.baseURL}/api/ps`, {
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json() as { models?: Array<{ name: string; size?: number; size_vram?: number; context_length?: number }> };
|
const data = (await response.json()) as {
|
||||||
|
models?: Array<{
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
size_vram?: number;
|
||||||
|
context_length?: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
return (data.models ?? []).map((m) => ({
|
return (data.models ?? []).map((m) => ({
|
||||||
name: m.name ?? '',
|
name: m.name ?? '',
|
||||||
size: m.size ?? 0,
|
size: m.size ?? 0,
|
||||||
@@ -496,7 +534,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
signal: AbortSignal.timeout(5_000),
|
signal: AbortSignal.timeout(5_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) return 'unknown';
|
if (!response.ok) return 'unknown';
|
||||||
const data = await response.json() as { version?: string };
|
const data = (await response.json()) as { version?: string };
|
||||||
return data.version ?? 'unknown';
|
return data.version ?? 'unknown';
|
||||||
} catch {
|
} catch {
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
@@ -525,7 +563,9 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
const buf = Buffer.from(await res.arrayBuffer());
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
return buf.toString('base64');
|
return buf.toString('base64');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn(`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`);
|
log.warn(
|
||||||
|
`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,7 +579,9 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
request.systemPrompt.outputConstraints,
|
request.systemPrompt.outputConstraints,
|
||||||
request.systemPrompt.safetyGuidelines,
|
request.systemPrompt.safetyGuidelines,
|
||||||
request.systemPrompt.dynamicReminders,
|
request.systemPrompt.dynamicReminders,
|
||||||
].filter(Boolean).join('\n\n'),
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -576,9 +618,9 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
|
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
|
||||||
msg.content = m.toolResult.error
|
msg.content = m.toolResult.error
|
||||||
? m.toolResult.error
|
? m.toolResult.error
|
||||||
: (typeof m.toolResult.result === 'string'
|
: 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) {
|
||||||
@@ -619,14 +661,23 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
|
|
||||||
// Thinking 模式
|
// Thinking 模式
|
||||||
if (request.params.thinkingEnabled) {
|
if (request.params.thinkingEnabled) {
|
||||||
const effortMap: Record<string, string | boolean> = { low: 'low', medium: 'medium', high: 'high', max: true };
|
const effortMap: Record<string, string | boolean> = {
|
||||||
|
low: 'low',
|
||||||
|
medium: 'medium',
|
||||||
|
high: 'high',
|
||||||
|
max: true,
|
||||||
|
};
|
||||||
body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true;
|
body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toMetonaResponse(data: Record<string, unknown>, requestId: string, iteration: number = 0): MetonaResponse {
|
private toMetonaResponse(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
requestId: string,
|
||||||
|
iteration: number = 0,
|
||||||
|
): MetonaResponse {
|
||||||
const message = data.message as Record<string, unknown> | undefined;
|
const message = data.message as Record<string, unknown> | undefined;
|
||||||
const toolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
|
const toolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
|
||||||
return {
|
return {
|
||||||
@@ -638,10 +689,13 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
perfStats: {
|
perfStats: {
|
||||||
loadDurationMs: data.load_duration ? (data.load_duration as number) / 1e6 : undefined,
|
loadDurationMs: data.load_duration ? (data.load_duration as number) / 1e6 : undefined,
|
||||||
promptEvalDurationMs: data.prompt_eval_duration ? (data.prompt_eval_duration as number) / 1e6 : undefined,
|
promptEvalDurationMs: data.prompt_eval_duration
|
||||||
|
? (data.prompt_eval_duration as number) / 1e6
|
||||||
|
: undefined,
|
||||||
evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined,
|
evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined,
|
||||||
tokensPerSecond: data.eval_count && data.eval_duration
|
tokensPerSecond:
|
||||||
? ((data.eval_count as number) / ((data.eval_duration as number) / 1e9))
|
data.eval_count && data.eval_duration
|
||||||
|
? (data.eval_count as number) / ((data.eval_duration as number) / 1e9)
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -652,7 +706,10 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
const rawArgs = fn?.arguments;
|
const rawArgs = fn?.arguments;
|
||||||
let args: Record<string, unknown> = {};
|
let args: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
args = typeof rawArgs === 'string' ? JSON.parse(rawArgs) : (rawArgs as Record<string, unknown>) ?? {};
|
args =
|
||||||
|
typeof rawArgs === 'string'
|
||||||
|
? JSON.parse(rawArgs)
|
||||||
|
: ((rawArgs as Record<string, unknown>) ?? {});
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
// v0.6.4: 非流式路径截断自愈对齐 —— 原 catch 静默降级 {},与流式修复后的
|
// v0.6.4: 非流式路径截断自愈对齐 —— 原 catch 静默降级 {},与流式修复后的
|
||||||
// 行为不一致。统一转为 _truncatedArguments 错误参数。
|
// 行为不一致。统一转为 _truncatedArguments 错误参数。
|
||||||
@@ -677,7 +734,10 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
outputTokens: (data.eval_count as number) ?? 0,
|
outputTokens: (data.eval_count as number) ?? 0,
|
||||||
totalTokens: ((data.prompt_eval_count as number) ?? 0) + ((data.eval_count as number) ?? 0),
|
totalTokens: ((data.prompt_eval_count as number) ?? 0) + ((data.eval_count as number) ?? 0),
|
||||||
},
|
},
|
||||||
finishReason: mapOllamaDoneReason(data.done_reason as string | undefined, !!message?.tool_calls),
|
finishReason: mapOllamaDoneReason(
|
||||||
|
data.done_reason as string | undefined,
|
||||||
|
!!message?.tool_calls,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,10 +753,15 @@ function mapOllamaDoneReason(
|
|||||||
): MetonaFinishReason {
|
): MetonaFinishReason {
|
||||||
if (hasToolCalls) return MetonaFinishReason.TOOL_CALLS;
|
if (hasToolCalls) return MetonaFinishReason.TOOL_CALLS;
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'stop': return MetonaFinishReason.STOP;
|
case 'stop':
|
||||||
case 'length': return MetonaFinishReason.LENGTH;
|
return MetonaFinishReason.STOP;
|
||||||
case 'load': return MetonaFinishReason.STOP; // 冷启动加载完成,非错误
|
case 'length':
|
||||||
case 'unload': return MetonaFinishReason.STOP;
|
return MetonaFinishReason.LENGTH;
|
||||||
default: return MetonaFinishReason.STOP;
|
case 'load':
|
||||||
|
return MetonaFinishReason.STOP; // 冷启动加载完成,非错误
|
||||||
|
case 'unload':
|
||||||
|
return MetonaFinishReason.STOP;
|
||||||
|
default:
|
||||||
|
return MetonaFinishReason.STOP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import type { IMetonaProviderAdapter, MetonaResponse, MetonaStreamEvent } from '
|
|||||||
import { MetonaStreamEventType } from '../../types';
|
import { MetonaStreamEventType } from '../../types';
|
||||||
|
|
||||||
/** 构造 Mock Adapter:sendStream 按脚本产出事件 */
|
/** 构造 Mock Adapter:sendStream 按脚本产出事件 */
|
||||||
function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: Error }): IMetonaProviderAdapter {
|
function createMockAdapter(
|
||||||
|
scripts: MetonaStreamEvent[][],
|
||||||
|
opts?: { failWith?: Error },
|
||||||
|
): IMetonaProviderAdapter {
|
||||||
let call = 0;
|
let call = 0;
|
||||||
return {
|
return {
|
||||||
providerId: 'mock',
|
providerId: 'mock',
|
||||||
@@ -23,12 +26,20 @@ function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: E
|
|||||||
supportsToolCalling: true,
|
supportsToolCalling: true,
|
||||||
supportsThinking: false,
|
supportsThinking: false,
|
||||||
getContextWindow: () => 1_000_000,
|
getContextWindow: () => 1_000_000,
|
||||||
send: vi.fn(async (): Promise<MetonaResponse> => ({
|
send: vi.fn(
|
||||||
meta: { requestId: 'r_test', provider: 'mock', model: 'mock-model', latencyMs: 1, timestamp: Date.now() },
|
async (): Promise<MetonaResponse> => ({
|
||||||
|
meta: {
|
||||||
|
requestId: 'r_test',
|
||||||
|
provider: 'mock',
|
||||||
|
model: 'mock-model',
|
||||||
|
latencyMs: 1,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
content: 'ok',
|
content: 'ok',
|
||||||
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||||||
finishReason: 'stop' as never,
|
finishReason: 'stop' as never,
|
||||||
})),
|
}),
|
||||||
|
),
|
||||||
sendStream: vi.fn(async function* (): AsyncIterable<MetonaStreamEvent> {
|
sendStream: vi.fn(async function* (): AsyncIterable<MetonaStreamEvent> {
|
||||||
if (opts?.failWith) throw opts.failWith;
|
if (opts?.failWith) throw opts.failWith;
|
||||||
const script = scripts[call % scripts.length];
|
const script = scripts[call % scripts.length];
|
||||||
@@ -42,8 +53,23 @@ function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: E
|
|||||||
|
|
||||||
function textDoneEvent(text: string): MetonaStreamEvent[] {
|
function textDoneEvent(text: string): MetonaStreamEvent[] {
|
||||||
return [
|
return [
|
||||||
{ type: MetonaStreamEventType.TEXT_DELTA, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), delta: text },
|
{
|
||||||
{ type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() },
|
type: MetonaStreamEventType.TEXT_DELTA,
|
||||||
|
requestId: 'r1',
|
||||||
|
sessionId: 's1',
|
||||||
|
iteration: 1,
|
||||||
|
seq: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
delta: text,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: MetonaStreamEventType.DONE,
|
||||||
|
requestId: 'r1',
|
||||||
|
sessionId: 's1',
|
||||||
|
iteration: 1,
|
||||||
|
seq: 1,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +77,21 @@ function toolCallEvent(name: string, args: Record<string, unknown>): MetonaStrea
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||||
requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(),
|
requestId: 'r1',
|
||||||
|
sessionId: 's1',
|
||||||
|
iteration: 1,
|
||||||
|
seq: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
toolCall: { id: 'tc_test', name, args, iteration: 1, timestamp: Date.now() },
|
toolCall: { id: 'tc_test', name, args, iteration: 1, timestamp: Date.now() },
|
||||||
},
|
},
|
||||||
{ type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() },
|
{
|
||||||
|
type: MetonaStreamEventType.DONE,
|
||||||
|
requestId: 'r1',
|
||||||
|
sessionId: 's1',
|
||||||
|
iteration: 1,
|
||||||
|
seq: 1,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +158,9 @@ describe('AgentLoopEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('不可重试错误直接 ERROR(无 fallback 时)', async () => {
|
it('不可重试错误直接 ERROR(无 fallback 时)', async () => {
|
||||||
const adapter = createMockAdapter([], { failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }) });
|
const adapter = createMockAdapter([], {
|
||||||
|
failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }),
|
||||||
|
});
|
||||||
const engine = new AgentLoopEngine({ retryCount: 0 }, adapter);
|
const engine = new AgentLoopEngine({ retryCount: 0 }, adapter);
|
||||||
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
|
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
|
||||||
expect(output.terminationReason).toBe(TerminationReason.ERROR);
|
expect(output.terminationReason).toBe(TerminationReason.ERROR);
|
||||||
@@ -129,7 +168,9 @@ describe('AgentLoopEngine', () => {
|
|||||||
|
|
||||||
it('P1 故障转移:主 Provider 失败后切换到 fallback Provider', async () => {
|
it('P1 故障转移:主 Provider 失败后切换到 fallback Provider', async () => {
|
||||||
// 主 adapter 每次都失败(401 不可重试)
|
// 主 adapter 每次都失败(401 不可重试)
|
||||||
const primary = createMockAdapter([], { failWith: Object.assign(new Error('401 invalid key'), { status: 401 }) });
|
const primary = createMockAdapter([], {
|
||||||
|
failWith: Object.assign(new Error('401 invalid key'), { status: 401 }),
|
||||||
|
});
|
||||||
// fallback 正常返回
|
// fallback 正常返回
|
||||||
const fallback = createMockAdapter([textDoneEvent('fallback answer')]);
|
const fallback = createMockAdapter([textDoneEvent('fallback answer')]);
|
||||||
|
|
||||||
@@ -151,8 +192,12 @@ describe('AgentLoopEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('P1 故障转移仅触发一次(fallback 也失败不回切)', async () => {
|
it('P1 故障转移仅触发一次(fallback 也失败不回切)', async () => {
|
||||||
const primary = createMockAdapter([], { failWith: Object.assign(new Error('401'), { status: 401 }) });
|
const primary = createMockAdapter([], {
|
||||||
const fallback = createMockAdapter([], { failWith: Object.assign(new Error('500'), { status: 500 }) });
|
failWith: Object.assign(new Error('401'), { status: 401 }),
|
||||||
|
});
|
||||||
|
const fallback = createMockAdapter([], {
|
||||||
|
failWith: Object.assign(new Error('500'), { status: 500 }),
|
||||||
|
});
|
||||||
|
|
||||||
const engine = new AgentLoopEngine({ retryCount: 0 }, primary);
|
const engine = new AgentLoopEngine({ retryCount: 0 }, primary);
|
||||||
engine.setFallbackAdapter(fallback);
|
engine.setFallbackAdapter(fallback);
|
||||||
@@ -162,3 +207,72 @@ describe('AgentLoopEngine', () => {
|
|||||||
expect(output.terminationReason).toBe(TerminationReason.ERROR);
|
expect(output.terminationReason).toBe(TerminationReason.ERROR);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== v0.7.3 P4-4 / P3-1: 死循环乒乓检测 + REFLECTING 状态接线 =====
|
||||||
|
|
||||||
|
describe('AgentLoopEngine — 死循环乒乓检测(ABAB,P4-4)', () => {
|
||||||
|
it('最近 4 轮 A→B→A→B 交替(A≠B)触发 DEAD_LOOP(驻留模式抓不住的乒乓)', async () => {
|
||||||
|
const readScript = toolCallEvent('read_file', { file_path: 'x.ts' });
|
||||||
|
const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' });
|
||||||
|
// 1:read 2:write 3:read 4:write ← 第 4 轮 PARSING 时滑窗构成 ABAB
|
||||||
|
const adapter = createMockAdapter([readScript, writeScript, readScript, writeScript]);
|
||||||
|
const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter);
|
||||||
|
const deadLoopEvents: unknown[] = [];
|
||||||
|
engine.on('deadLoop', (d) => deadLoopEvents.push(d));
|
||||||
|
|
||||||
|
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
|
||||||
|
expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP);
|
||||||
|
expect(deadLoopEvents.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A→B→C 交替(无重复模式)不误报,按 MAX_ITERATIONS 终止', async () => {
|
||||||
|
const scripts = [
|
||||||
|
toolCallEvent('read_file', { file_path: 'a.ts' }),
|
||||||
|
toolCallEvent('write_file', { file_path: 'a.ts' }),
|
||||||
|
toolCallEvent('lint_code', {}),
|
||||||
|
];
|
||||||
|
const adapter = createMockAdapter(scripts);
|
||||||
|
const engine = new AgentLoopEngine({ maxIterations: 4 }, adapter);
|
||||||
|
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
|
||||||
|
expect(output.terminationReason).toBe(TerminationReason.MAX_ITERATIONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A→B→B→B 前缀不构成 ABAB(A≠B 约束),由驻留模式在 3 连 B 时接管', async () => {
|
||||||
|
const readScript = toolCallEvent('read_file', { file_path: 'x.ts' });
|
||||||
|
const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' });
|
||||||
|
// 1:read 2:write 3:write 4:write —— 第 4 轮时 ABAB 不成立,但 3 连 write 命中驻留模式
|
||||||
|
const adapter = createMockAdapter([readScript, writeScript, writeScript, writeScript]);
|
||||||
|
const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter);
|
||||||
|
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
|
||||||
|
expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AgentLoopEngine — REFLECTING 状态接线(P3-1 enableReflection)', () => {
|
||||||
|
const collectStates = async (config: Record<string, unknown>): Promise<string[]> => {
|
||||||
|
const adapter = createMockAdapter([
|
||||||
|
toolCallEvent('read_file', { file_path: 'a.ts' }),
|
||||||
|
textDoneEvent('done'),
|
||||||
|
]);
|
||||||
|
const engine = new AgentLoopEngine(config as never, adapter);
|
||||||
|
const states: string[] = [];
|
||||||
|
engine.on('stateChange', (d: { state?: string; current?: string }) => {
|
||||||
|
const s = d.state ?? d.current ?? '';
|
||||||
|
if (!states.includes(s)) states.push(s);
|
||||||
|
});
|
||||||
|
await engine.runStream(userMessage, 's1', [], systemPrompt);
|
||||||
|
return states;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('enableReflection=true 时工具执行后进入 REFLECTING 状态', async () => {
|
||||||
|
const states = await collectStates({ maxIterations: 2, enableReflection: true });
|
||||||
|
expect(states).toContain('REFLECTING');
|
||||||
|
expect(states).toContain('EXECUTING');
|
||||||
|
expect(states).toContain('OBSERVING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enableReflection=false(默认)时不进入 REFLECTING', async () => {
|
||||||
|
const states = await collectStates({ maxIterations: 2, enableReflection: false });
|
||||||
|
expect(states).not.toContain('REFLECTING');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ class DeadLoopError extends Error {
|
|||||||
|
|
||||||
const DEFAULT_CONFIG: AgentLoopConfig = {
|
const DEFAULT_CONFIG: AgentLoopConfig = {
|
||||||
maxIterations: 20,
|
maxIterations: 20,
|
||||||
timeoutMs: 120_000,
|
|
||||||
totalTimeoutMs: 600_000,
|
totalTimeoutMs: 600_000,
|
||||||
enableReflection: false,
|
enableReflection: false,
|
||||||
compressionThreshold: 0.8,
|
compressionThreshold: 0.8,
|
||||||
@@ -563,7 +562,7 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
});
|
});
|
||||||
// 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止
|
// 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止
|
||||||
throw new DeadLoopError(
|
throw new DeadLoopError(
|
||||||
`Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations. Please refine the approach or provide more specific instructions.`,
|
`Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations, or two alternating call patterns kept cycling (A→B→A→B) without progress. Please refine the approach or provide more specific instructions.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,7 +619,11 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
await this.transitionTo(AgentLoopState.OBSERVING);
|
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||||
|
|
||||||
// === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 ===
|
// === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 ===
|
||||||
// 如果有工具调用且需要后续推理,进入 REFLECTING 状态
|
// v0.7.3 接线说明:REFLECTING 分支此前依赖 enableReflection 配置,但该配置
|
||||||
|
// 全链路无任何置 true 的路径(死配置)。现由 agent.enableReflection 配置
|
||||||
|
// 真实驱动(main.ts baseConfig → updateConfigAll → 本分支),启用后每轮
|
||||||
|
// 工具执行完毕会经过 REFLECTING 状态:工具结果存在失败时记录告警日志,
|
||||||
|
// 供 SLO 与排障观察(不阻断循环——错误结果已由 CE-2 路径回传模型自愈)。
|
||||||
if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) {
|
if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) {
|
||||||
await this.transitionTo(AgentLoopState.REFLECTING);
|
await this.transitionTo(AgentLoopState.REFLECTING);
|
||||||
// 检查工具执行是否有错误,如果有严重错误可以提前终止
|
// 检查工具执行是否有错误,如果有严重错误可以提前终止
|
||||||
@@ -1087,9 +1090,13 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* v0.3.0: 死循环检测
|
* v0.3.0: 死循环检测
|
||||||
*
|
*
|
||||||
* 检测策略:
|
* 检测策略(v0.7.3 起双模式):
|
||||||
* 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。
|
* 1. 驻留模式 — 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。
|
||||||
* 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
|
* 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
|
||||||
|
* 2. 乒乓模式(v0.7.3 新增)— 最近4轮构成 ABAB 交替(r1===r3 && r2===r4 && r1!==r2)。
|
||||||
|
* 典型场景:模型在"读文件 A → 写文件 B"两步之间无限往返(每次读完又改回),
|
||||||
|
* 单步签名各不相同,驻留模式永不命中;docs/Agentic-Loop详解.md 第五章将
|
||||||
|
* "两种状态间反复来回切换、毫无进展"列为必须检测的停滞模式。
|
||||||
*
|
*
|
||||||
* v0.3.0 修复:
|
* v0.3.0 修复:
|
||||||
* - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报
|
* - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报
|
||||||
@@ -1123,21 +1130,31 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
|
|
||||||
this.toolCallHistory.push(signature);
|
this.toolCallHistory.push(signature);
|
||||||
|
|
||||||
// 只保留最近5轮的记录(足够检测3轮重复,同时避免内存增长)
|
// 只保留最近5轮的记录(足够检测3轮重复与4轮乒乓,同时避免内存增长)
|
||||||
if (this.toolCallHistory.length > 5) {
|
if (this.toolCallHistory.length > 5) {
|
||||||
this.toolCallHistory.shift();
|
this.toolCallHistory.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要至少3轮数据才能检测
|
|
||||||
if (this.toolCallHistory.length < 3) return false;
|
|
||||||
|
|
||||||
const len = this.toolCallHistory.length;
|
const len = this.toolCallHistory.length;
|
||||||
|
|
||||||
|
// 模式 1:连续3轮完全相同 → 死循环
|
||||||
|
if (len >= 3) {
|
||||||
const r1 = this.toolCallHistory[len - 1]; // 当前轮
|
const r1 = this.toolCallHistory[len - 1]; // 当前轮
|
||||||
const r2 = this.toolCallHistory[len - 2]; // 上一轮
|
const r2 = this.toolCallHistory[len - 2]; // 上一轮
|
||||||
const r3 = this.toolCallHistory[len - 3]; // 上上一轮
|
const r3 = this.toolCallHistory[len - 3]; // 上上一轮
|
||||||
|
if (r1 === r2 && r2 === r3) return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 连续3轮完全相同 → 死循环
|
// 模式 2(v0.7.3):最近4轮 ABAB 交替(A≠B)→ 乒乓死循环
|
||||||
return r1 === r2 && r2 === r3;
|
if (len >= 4) {
|
||||||
|
const a1 = this.toolCallHistory[len - 4];
|
||||||
|
const b1 = this.toolCallHistory[len - 3];
|
||||||
|
const a2 = this.toolCallHistory[len - 2];
|
||||||
|
const b2 = this.toolCallHistory[len - 1];
|
||||||
|
if (a1 === a2 && b1 === b2 && a1 !== b1) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export interface TokenUsage {
|
|||||||
|
|
||||||
export interface AgentLoopConfig {
|
export interface AgentLoopConfig {
|
||||||
maxIterations: number;
|
maxIterations: number;
|
||||||
timeoutMs: number;
|
|
||||||
totalTimeoutMs: number;
|
totalTimeoutMs: number;
|
||||||
enableReflection: boolean;
|
enableReflection: boolean;
|
||||||
compressionThreshold: number;
|
compressionThreshold: number;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* ConfirmationHook forgetSession 测试(v0.7.3 P2-3)
|
||||||
|
*
|
||||||
|
* 锁定会话终态清理契约:
|
||||||
|
* F1 forgetSession 清空该会话的决策记忆(拒绝记忆不再残留);
|
||||||
|
* F2 forgetSession 同时拒绝该会话等待中的确认(clearPending 语义);
|
||||||
|
* F3 会话隔离:清理 A 不影响 B;
|
||||||
|
* F4 空/未知 sessionId 幂等无副作用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// 模拟一个存活窗口 —— beforeExecute 的 hasAvailableWindow 守卫需要它,
|
||||||
|
// 否则确认请求在创建 pending 之前即被短路(测不到记忆/pending 路径)
|
||||||
|
const fakeWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: { send: vi.fn() },
|
||||||
|
};
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
BrowserWindow: { getAllWindows: vi.fn(() => [fakeWindow]) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ConfirmationHook } from '../confirmation-hook';
|
||||||
|
import type { MetonaToolCall, MetonaToolDef } from '../../../harness/types';
|
||||||
|
import { MetonaRiskLevel, MetonaToolCategory } from '../../../harness/types';
|
||||||
|
|
||||||
|
function makeToolCall(name: string, id = `tc_${name}`): MetonaToolCall {
|
||||||
|
return { id, name, args: {}, iteration: 1, timestamp: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEEDS_CONFIRM_DEF: MetonaToolDef = {
|
||||||
|
name: 'run_command',
|
||||||
|
description: 'test',
|
||||||
|
parameters: { type: 'object', properties: {}, required: [] },
|
||||||
|
category: MetonaToolCategory.CODE_EXECUTION,
|
||||||
|
riskLevel: MetonaRiskLevel.HIGH,
|
||||||
|
requiresPermission: true,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ConfirmationHook — forgetSession(P2-3)', () => {
|
||||||
|
let hook: ConfirmationHook;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
hook = new ConfirmationHook(null, null);
|
||||||
|
hook.setToolDefs([NEEDS_CONFIRM_DEF]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F1: 会话删除后决策记忆被清空(拒绝记忆不再跨生命周期残留)', async () => {
|
||||||
|
const pending = hook.beforeExecute(makeToolCall('run_command'), 'sess-A');
|
||||||
|
hook.resolveConfirmation(`tc_run_command`, false, true); // 记住拒绝
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(hook.getRememberedDenials('sess-A')).toHaveLength(1);
|
||||||
|
hook.forgetSession('sess-A');
|
||||||
|
expect(hook.getRememberedDenials('sess-A')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F2: forgetSession 拒绝该会话等待中的确认(clearPending 语义)', async () => {
|
||||||
|
const p1 = hook.beforeExecute(makeToolCall('run_command'), 'sess-A');
|
||||||
|
// resolve pending(拒绝)后走记忆清理路径
|
||||||
|
hook.forgetSession('sess-A');
|
||||||
|
await expect(p1).resolves.toMatchObject({ blocked: true });
|
||||||
|
// pending 已清空 —— getPendingConfirmations 无残留
|
||||||
|
expect(hook.getPendingConfirmations()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F3: 会话隔离 —— 清理 A 不影响 B 的决策记忆', async () => {
|
||||||
|
const p1 = hook.beforeExecute(makeToolCall('run_command', 'tc_A'), 'sess-A');
|
||||||
|
hook.resolveConfirmation('tc_A', false, true);
|
||||||
|
await p1;
|
||||||
|
|
||||||
|
const p2 = hook.beforeExecute(makeToolCall('run_command', 'tc_B'), 'sess-B');
|
||||||
|
hook.resolveConfirmation('tc_B', false, true);
|
||||||
|
await p2;
|
||||||
|
|
||||||
|
hook.forgetSession('sess-A');
|
||||||
|
expect(hook.getRememberedDenials('sess-A')).toHaveLength(0);
|
||||||
|
expect(hook.getRememberedDenials('sess-B')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F4: 空 sessionId 幂等无副作用;未知会话不抛错', () => {
|
||||||
|
expect(() => hook.forgetSession('')).not.toThrow();
|
||||||
|
expect(() => hook.forgetSession('nonexistent')).not.toThrow();
|
||||||
|
expect(hook.getRememberedDenials()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -550,6 +550,22 @@ export class ConfirmationHook implements PreToolHook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.3 P2-3: 会话生命周期终态清理(会话删除 / SubAgent 终结时调用)。
|
||||||
|
*
|
||||||
|
* 此前 rememberedDecisions 两级 Map 只增不减 —— 会话删除/子任务终结后其
|
||||||
|
* 决策记忆永久残留,长期运行实例随会话数缓慢泄漏。本方法与 clearPending
|
||||||
|
* 的区别:clearPending 只处理等待中的确认(会话中断时用,会话本身仍存活),
|
||||||
|
* 本方法面向"会话已终结"的终态,同时清空 pending 与决策记忆。
|
||||||
|
*
|
||||||
|
* @param sessionId 会话 ID(主会话或 SubAgent taskId)
|
||||||
|
*/
|
||||||
|
forgetSession(sessionId: string): void {
|
||||||
|
if (!sessionId) return;
|
||||||
|
this.clearPending(sessionId);
|
||||||
|
this.rememberedDecisions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 私有辅助(v0.5.0: 会话隔离) =====
|
// ===== 私有辅助(v0.5.0: 会话隔离) =====
|
||||||
|
|
||||||
/** 获取(或创建)指定会话的决策记忆表 */
|
/** 获取(或创建)指定会话的决策记忆表 */
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 记忆固化触发决策测试(v0.7.3 P1-5)
|
||||||
|
*
|
||||||
|
* 锁定 shouldConsolidate 的四类判定:总开关 / 内容门控(回答长度 ∨ 成功工具调用)/
|
||||||
|
* 频率窗口(含首次不限)/ 配置兜底(非法数值回退安全下限)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
shouldConsolidate,
|
||||||
|
MIN_CONSOLIDATION_INTERVAL_MS,
|
||||||
|
MIN_CONSOLIDATION_MIN_CHARS,
|
||||||
|
} from '../consolidation-policy';
|
||||||
|
|
||||||
|
const BASE = {
|
||||||
|
enabled: true,
|
||||||
|
answerChars: 500,
|
||||||
|
minChars: 200,
|
||||||
|
hadSuccessfulToolCall: false,
|
||||||
|
lastConsolidationAt: 0,
|
||||||
|
now: 1_000_000,
|
||||||
|
intervalMs: 600_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('shouldConsolidate', () => {
|
||||||
|
it('总开关显式关闭 → disabled', () => {
|
||||||
|
expect(shouldConsolidate({ ...BASE, enabled: false })).toEqual({
|
||||||
|
consolidate: false,
|
||||||
|
reason: 'disabled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('回答够长且首次固化 → 允许(lastConsolidationAt=0 不受频率限制)', () => {
|
||||||
|
expect(shouldConsolidate(BASE)).toEqual({
|
||||||
|
consolidate: true,
|
||||||
|
reason: 'content-and-frequency-pass',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('回答过短且无成功工具调用 → below-threshold(短寒暄不触发固化)', () => {
|
||||||
|
expect(shouldConsolidate({ ...BASE, answerChars: 50, hadSuccessfulToolCall: false })).toEqual({
|
||||||
|
consolidate: false,
|
||||||
|
reason: 'below-threshold',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('回答过短但存在成功工具调用 → 允许(事实性上下文可沉淀)', () => {
|
||||||
|
expect(shouldConsolidate({ ...BASE, answerChars: 50, hadSuccessfulToolCall: true })).toEqual({
|
||||||
|
consolidate: true,
|
||||||
|
reason: 'content-and-frequency-pass',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('频率窗口内重复触发 → throttled', () => {
|
||||||
|
expect(
|
||||||
|
shouldConsolidate({
|
||||||
|
...BASE,
|
||||||
|
lastConsolidationAt: BASE.now - 60_000, // 1 分钟前刚固化过
|
||||||
|
}),
|
||||||
|
).toEqual({ consolidate: false, reason: 'throttled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('频率窗口已过 → 允许', () => {
|
||||||
|
expect(
|
||||||
|
shouldConsolidate({
|
||||||
|
...BASE,
|
||||||
|
lastConsolidationAt: BASE.now - 600_001,
|
||||||
|
}),
|
||||||
|
).toEqual({ consolidate: true, reason: 'content-and-frequency-pass' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('配置兜底:minChars=0 不会让纯寒暄触发(安全下限生效)', () => {
|
||||||
|
expect(shouldConsolidate({ ...BASE, minChars: 0, answerChars: 10 })).toEqual({
|
||||||
|
consolidate: false,
|
||||||
|
reason: 'below-threshold',
|
||||||
|
});
|
||||||
|
expect(MIN_CONSOLIDATION_MIN_CHARS).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('配置兜底:intervalMs=0 不会退化为每条消息固化(安全下限生效)', () => {
|
||||||
|
expect(
|
||||||
|
shouldConsolidate({
|
||||||
|
...BASE,
|
||||||
|
intervalMs: 0,
|
||||||
|
lastConsolidationAt: BASE.now - 30_000, // 30 秒前刚固化
|
||||||
|
}),
|
||||||
|
).toEqual({ consolidate: false, reason: 'throttled' });
|
||||||
|
expect(MIN_CONSOLIDATION_INTERVAL_MS).toBeGreaterThanOrEqual(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('配置兜底:minChars/intervalMs 为 NaN 时按默认值处理', () => {
|
||||||
|
expect(shouldConsolidate({ ...BASE, minChars: Number.NaN, intervalMs: Number.NaN })).toEqual({
|
||||||
|
consolidate: true,
|
||||||
|
reason: 'content-and-frequency-pass',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Consolidation Policy — 记忆固化触发决策(v0.7.3 P1-5)
|
||||||
|
*
|
||||||
|
* 背景:MemoryConsolidator 在每次 run 完成后无条件发起一次非流式 LLM 请求
|
||||||
|
* (30s 超时)判断本次对话是否有值得持久化的记忆。短寒暄/单轮问答同样触发,
|
||||||
|
* 纯成本浪费且对 Provider 构成无意义请求压力。
|
||||||
|
*
|
||||||
|
* 本模块把触发决策收敛为纯函数(可表测),决策输入:
|
||||||
|
* - 总开关 memory.consolidationEnabled(fail-secure:仅显式 false 才关闭)
|
||||||
|
* - 内容门控:本次回答 ≥ minChars 字符 **或** 本次 run 存在成功的工具调用
|
||||||
|
* (工具调用意味着产生了可沉淀的事实性上下文)
|
||||||
|
* - 频率门控:距该会话上次固化 ≥ intervalMs(首次不设限,但仍受内容门控约束)
|
||||||
|
*
|
||||||
|
* 决策与执行解耦:本模块不做 IO,调用方(ipc/agent.ts)持有会话级
|
||||||
|
* lastConsolidationAt 状态并执行 consolidate。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ConsolidationDecisionInput {
|
||||||
|
/** 总开关(memory.consolidationEnabled;undefined/null 视为开启) */
|
||||||
|
enabled: boolean | null | undefined;
|
||||||
|
/** 本次 Agent 最终回答的字符数 */
|
||||||
|
answerChars: number;
|
||||||
|
/** 内容门控阈值(memory.consolidationMinChars,默认 200) */
|
||||||
|
minChars: number;
|
||||||
|
/** 本次 run 是否存在成功的工具调用 */
|
||||||
|
hadSuccessfulToolCall: boolean;
|
||||||
|
/** 该会话上次固化的时间戳(0 = 从未固化) */
|
||||||
|
lastConsolidationAt: number;
|
||||||
|
/** 当前时间戳 */
|
||||||
|
now: number;
|
||||||
|
/** 频率门控窗口(memory.consolidationIntervalMs,默认 600000) */
|
||||||
|
intervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConsolidationDecision =
|
||||||
|
| { consolidate: true; reason: 'content-and-frequency-pass' }
|
||||||
|
| { consolidate: false; reason: 'disabled' | 'below-threshold' | 'throttled' };
|
||||||
|
|
||||||
|
/** 频率窗口合法下限(防误配 0/负值导致门控失效——0 等价于每条消息都固化) */
|
||||||
|
export const MIN_CONSOLIDATION_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
/** 内容门控合法下限(防误配 0 导致纯寒暄也固化) */
|
||||||
|
export const MIN_CONSOLIDATION_MIN_CHARS = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判定本次 run 是否应触发记忆固化。
|
||||||
|
*/
|
||||||
|
export function shouldConsolidate(input: ConsolidationDecisionInput): ConsolidationDecision {
|
||||||
|
// 1. 总开关 —— fail-secure 语义由调用方负责(!== false 才视为开启后传入布尔)
|
||||||
|
if (input.enabled === false) {
|
||||||
|
return { consolidate: false, reason: 'disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 内容门控:回答够长 或 有成功的工具调用(事实性上下文)
|
||||||
|
const minChars = Math.max(
|
||||||
|
MIN_CONSOLIDATION_MIN_CHARS,
|
||||||
|
Number.isFinite(input.minChars) ? input.minChars : 200,
|
||||||
|
);
|
||||||
|
const contentWorthy = input.answerChars >= minChars || input.hadSuccessfulToolCall;
|
||||||
|
if (!contentWorthy) {
|
||||||
|
return { consolidate: false, reason: 'below-threshold' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 频率门控:上次固化距今不足窗口 → 跳过(首次 lastConsolidationAt=0 不受限)
|
||||||
|
const intervalMs = Math.max(
|
||||||
|
MIN_CONSOLIDATION_INTERVAL_MS,
|
||||||
|
Number.isFinite(input.intervalMs) ? input.intervalMs : 600_000,
|
||||||
|
);
|
||||||
|
if (input.lastConsolidationAt > 0 && input.now - input.lastConsolidationAt < intervalMs) {
|
||||||
|
return { consolidate: false, reason: 'throttled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { consolidate: true, reason: 'content-and-frequency-pass' };
|
||||||
|
}
|
||||||
@@ -167,6 +167,8 @@ export class TaskOrchestrator extends EventEmitter {
|
|||||||
thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium',
|
thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium',
|
||||||
contextLength: this.defaultConfig?.contextLength,
|
contextLength: this.defaultConfig?.contextLength,
|
||||||
contextWindow: this.defaultConfig?.contextWindow ?? 128_000,
|
contextWindow: this.defaultConfig?.contextWindow ?? 128_000,
|
||||||
|
// v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection(REFLECTING 状态开关)
|
||||||
|
enableReflection: this.defaultConfig?.enableReflection ?? false,
|
||||||
},
|
},
|
||||||
this.engines.createAdapter(),
|
this.engines.createAdapter(),
|
||||||
this.toolRegistry,
|
this.toolRegistry,
|
||||||
|
|||||||
@@ -64,11 +64,23 @@ describe('ContextBuilder — isUsingFallbackRole 首次降级通知语义', () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('ContextBuilder — 动态区注入', () => {
|
describe('ContextBuilder — 动态区注入', () => {
|
||||||
it('注入当前日期时间(含本地时区)', () => {
|
it('v0.7.3 P1-1: 不再注入日期时间(prompt cache 前缀稳定性)', () => {
|
||||||
const cb = new ContextBuilder();
|
const cb = new ContextBuilder();
|
||||||
const prompt = cb.buildSystemPrompt({ soul: 'x', memory: '' });
|
const prompt = cb.buildSystemPrompt({ soul: 'x', memory: '' });
|
||||||
expect(prompt.dynamicReminders).toContain('## Current Date & Time');
|
expect(prompt.dynamicReminders).not.toContain('## Current Date & Time');
|
||||||
expect(prompt.dynamicReminders).toMatch(/UTC[+-]/);
|
expect(prompt.dynamicReminders).not.toMatch(/UTC[+-]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('v0.7.3 P1-1: system 输出跨调用字节级稳定(同输入 → 同字节)', () => {
|
||||||
|
const cb = new ContextBuilder();
|
||||||
|
const files = { soul: 'x', memory: '## 用户偏好\n- 偏好深色主题' };
|
||||||
|
const a = cb.buildSystemPrompt(files, '/tmp/ws-demo');
|
||||||
|
const b = cb.buildSystemPrompt(files, '/tmp/ws-demo');
|
||||||
|
// 跨 run 缓存命中的前提:四分区逐字节一致(日期时间已移入用户消息前置块)
|
||||||
|
expect(a.roleDefinition).toBe(b.roleDefinition);
|
||||||
|
expect(a.outputConstraints).toBe(b.outputConstraints);
|
||||||
|
expect(a.safetyGuidelines).toBe(b.safetyGuidelines);
|
||||||
|
expect(a.dynamicReminders).toBe(b.dynamicReminders);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('注入工作空间路径(动态区,路径可切换)', () => {
|
it('注入工作空间路径(动态区,路径可切换)', () => {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 用户上下文前置块测试(v0.7.3 P1-1)
|
||||||
|
*
|
||||||
|
* 锁定三类动态内容(日期时间 / 记忆 / 附件提示)在用户消息前置块的
|
||||||
|
* 分组结构与空值收缩行为 —— 它们从 system prompt 迁出的契约面。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildUserContextPrefix, withUserContextPrefix } from '../user-context';
|
||||||
|
|
||||||
|
describe('buildUserContextPrefix', () => {
|
||||||
|
it('恒含头部说明与日期时间分区(唯一无条件分区)', () => {
|
||||||
|
const prefix = buildUserContextPrefix({ now: Date.UTC(2026, 7, 30, 6, 30) });
|
||||||
|
expect(prefix).toContain('[Contextual information for this message');
|
||||||
|
expect(prefix).toContain('## Current Date & Time');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无记忆/附件时不产出对应分区(空值收缩)', () => {
|
||||||
|
const prefix = buildUserContextPrefix({ now: Date.now() });
|
||||||
|
expect(prefix).not.toContain('## Relevant Memories (Retrieved)');
|
||||||
|
expect(prefix).not.toContain('## User Attachments (Direct Upload)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('记忆分区:条目格式与截断口径(沿用原 system 注入契约)', () => {
|
||||||
|
const prefix = buildUserContextPrefix({
|
||||||
|
now: Date.now(),
|
||||||
|
memories: [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
type: 'semantic',
|
||||||
|
content: 'x'.repeat(500),
|
||||||
|
source: 'agent_thought',
|
||||||
|
importance: 0.9,
|
||||||
|
score: 0.8,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(prefix).toContain('## Relevant Memories (Retrieved)');
|
||||||
|
expect(prefix).toMatch(/\[1\] \(semantic, 重要度: 0\.9\)/);
|
||||||
|
// 内容截断到 200 字符
|
||||||
|
expect(prefix).toContain('x'.repeat(200));
|
||||||
|
expect(prefix).not.toContain('x'.repeat(201));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('附件分区:图片提示禁止重复读图;文本截断标记透传', () => {
|
||||||
|
const prefix = buildUserContextPrefix({
|
||||||
|
now: Date.now(),
|
||||||
|
attachments: [
|
||||||
|
{ name: 'shot.png', type: 'image' },
|
||||||
|
{ name: 'big.log', type: 'text', truncated: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(prefix).toContain('## User Attachments (Direct Upload)');
|
||||||
|
expect(prefix).toContain('1. [image] shot.png');
|
||||||
|
expect(prefix).toContain('do NOT call view_image');
|
||||||
|
expect(prefix).toContain('2. [text file] big.log');
|
||||||
|
expect(prefix).toContain('TRUNCATED — only the first 512KB is included');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分区以 --- 分隔且以前缀分隔符收尾(调用方可直接拼接用户内容)', () => {
|
||||||
|
const prefix = buildUserContextPrefix({
|
||||||
|
now: Date.now(),
|
||||||
|
memories: [
|
||||||
|
{
|
||||||
|
id: 'm',
|
||||||
|
type: 'episodic',
|
||||||
|
content: 'c',
|
||||||
|
source: 'user_input',
|
||||||
|
importance: 0.5,
|
||||||
|
score: 0.5,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attachments: [{ name: 'a.txt', type: 'text' }],
|
||||||
|
});
|
||||||
|
expect(prefix).toMatch(/---\s*$/);
|
||||||
|
// 三个分区恰好两个内部 --- + 收尾 1 个 ---(共 3 个独立行)
|
||||||
|
expect(prefix.match(/^---$/gm)?.length ?? 0).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withUserContextPrefix', () => {
|
||||||
|
it('前置块与用户内容拼接(前置块自带收尾分隔符)', () => {
|
||||||
|
const prefix = buildUserContextPrefix({ now: Date.now() });
|
||||||
|
const out = withUserContextPrefix(prefix, '你好,帮我写个脚本');
|
||||||
|
expect(out.startsWith(prefix)).toBe(true);
|
||||||
|
expect(out.endsWith('你好,帮我写个脚本')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空前缀原样返回(契约防御)', () => {
|
||||||
|
expect(withUserContextPrefix('', 'hello')).toBe('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,10 @@ export class ContextBuilder {
|
|||||||
*
|
*
|
||||||
* v0.3.14: 移除 AGENTS.md 和 USERS.md 的读取,SOUL.md 仅做角色定义
|
* v0.3.14: 移除 AGENTS.md 和 USERS.md 的读取,SOUL.md 仅做角色定义
|
||||||
*/
|
*/
|
||||||
buildSystemPrompt(workspaceFiles?: WorkspaceFiles, workspacePath?: string): {
|
buildSystemPrompt(
|
||||||
|
workspaceFiles?: WorkspaceFiles,
|
||||||
|
workspacePath?: string,
|
||||||
|
): {
|
||||||
roleDefinition: string;
|
roleDefinition: string;
|
||||||
outputConstraints: string;
|
outputConstraints: string;
|
||||||
safetyGuidelines: string;
|
safetyGuidelines: string;
|
||||||
@@ -76,23 +79,19 @@ export class ContextBuilder {
|
|||||||
// ===== 动态区:记忆 =====
|
// ===== 动态区:记忆 =====
|
||||||
const dynamicParts: string[] = [];
|
const dynamicParts: string[] = [];
|
||||||
|
|
||||||
// v0.3.14: 注入当前系统日期时间(每次构建时获取最新时间)
|
// v0.7.3 P1-1 根治: 当前日期时间不再注入 system prompt —— 此前每次构建都
|
||||||
// 用于让 AI 准确理解"今天"、"昨天"等相对时间表达
|
// 产生不同字节(秒级时间戳 + 时区),导致跨 run 的 system 前缀永不一致,
|
||||||
// #43 修复: 时区硬编码 Asia/Shanghai 改为使用系统本地时区,跨时区用户显示正确
|
// DeepSeek 自动上下文缓存 / Anthropic 显式缓存全部 miss。现移入用户消息
|
||||||
const now = new Date();
|
// 前置块(@see user-context.ts),system 保持跨 run 字节级稳定。
|
||||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Asia/Shanghai';
|
// MEMORY.md 的 `> 创建时间/最后更新` 元数据行由 extractContent 剥离,
|
||||||
// 审查修复: 恢复 UTC 偏移显示,在时区名后附加 UTC 偏移,避免丢失时区偏移信息
|
// 正文提取不受时间戳更新影响 —— 此处无需额外处理。
|
||||||
const offset = -now.getTimezoneOffset() / 60;
|
|
||||||
const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`;
|
|
||||||
const dateTimeStr = now.toLocaleString('zh-CN', {
|
|
||||||
timeZone: localTimezone,
|
|
||||||
hour12: false,
|
|
||||||
});
|
|
||||||
dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (${localTimezone}, ${offsetStr})`);
|
|
||||||
|
|
||||||
// 注入当前工作空间路径(动态区,路径可能切换故不放入静态区)
|
// 注入当前工作空间路径(动态区,路径可能切换故不放入静态区;
|
||||||
|
// 会话期间路径恒定,不破坏缓存)
|
||||||
if (workspacePath) {
|
if (workspacePath) {
|
||||||
dynamicParts.push(`## Current Workspace\nWorkspace root path: \`${workspacePath}\`\n\nAll relative paths in tool calls are resolved against this workspace root. Use this path when absolute paths are required (e.g., in run_command).`);
|
dynamicParts.push(
|
||||||
|
`## Current Workspace\nWorkspace root path: \`${workspacePath}\`\n\nAll relative paths in tool calls are resolved against this workspace root. Use this path when absolute paths are required (e.g., in run_command).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspaceFiles?.memory) {
|
if (workspaceFiles?.memory) {
|
||||||
@@ -138,7 +137,9 @@ export class ContextBuilder {
|
|||||||
} else {
|
} else {
|
||||||
// v0.3.18 修复: 降级时打 WARN 日志 + 设置标志,供 IPC 层读取后发 toast
|
// v0.3.18 修复: 降级时打 WARN 日志 + 设置标志,供 IPC 层读取后发 toast
|
||||||
this.lastUsedFallbackRole = true;
|
this.lastUsedFallbackRole = true;
|
||||||
log.warn('[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity');
|
log.warn(
|
||||||
|
'[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity',
|
||||||
|
);
|
||||||
// 兜底身份定义(Metona 灵魂定义)
|
// 兜底身份定义(Metona 灵魂定义)
|
||||||
parts.push(`# Metona — 灵魂定义
|
parts.push(`# Metona — 灵魂定义
|
||||||
> "想清楚再动手,做对比做快重要"
|
> "想清楚再动手,做对比做快重要"
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* User Context Prefix — 每条用户消息的系统上下文前置块(v0.7.3 P1-1 根治)
|
||||||
|
*
|
||||||
|
* 背景(Prompt Cache 被打穿的根因):
|
||||||
|
* 此前「当前日期时间」「检索到的相关记忆」「附件提示」三类**每条消息都在变**
|
||||||
|
* 的内容被追加进 systemPrompt.dynamicReminders —— OpenAI 兼容系将其拼进首条
|
||||||
|
* system 消息、Anthropic 写入顶层 system 字段。任何一次变化都会使整个 system
|
||||||
|
* 前缀失配,DeepSeek 自动上下文缓存 / Anthropic 显式缓存全部 miss。长 system
|
||||||
|
* (SOUL + 安全准则 + MEMORY.md)× 每 run 最多 20 轮迭代 × 全量重算输入 token,
|
||||||
|
* 成本与首字延迟被系统性放大。
|
||||||
|
*
|
||||||
|
* 现契约(单一事实来源):
|
||||||
|
* - system prompt 只保留跨 run 字节级稳定的内容(SOUL / 约束 / 安全准则 /
|
||||||
|
* 工作空间路径 / MEMORY.md 正文——其易变的 `> 最后更新` 元数据行本就被
|
||||||
|
* extractContent 剥离);Anthropic 侧对该稳定前缀打 cache_control 断言;
|
||||||
|
* - 易变内容(日期时间 / 记忆 / 附件提示)由本模块构建为**用户消息前置块**,
|
||||||
|
* 随当次请求注入首条 user 消息(LLM 语义等价:Claude Code 同款上下文注入位);
|
||||||
|
* - DB 持久化 / 前端展示 / 记忆固化 / 注入检测均使用**原始干净内容**,
|
||||||
|
* 前置块只存在于发给引擎的副本上。
|
||||||
|
*
|
||||||
|
* 纯函数、零副作用:可在 node vitest 下直接表测(稳定性/分组/空值收缩)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SearchResult } from '../memory/manager';
|
||||||
|
|
||||||
|
/** 附件提示所需的元信息子集(与 agent-store AttachmentInfo 对齐的渲染端子集) */
|
||||||
|
export interface AttachmentHint {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserContextPrefixInput {
|
||||||
|
/** 当前时间戳(前置块内降精度到分钟,减少无意义抖动) */
|
||||||
|
now?: number;
|
||||||
|
/** 检索到的相关记忆(空数组时不产出记忆分区) */
|
||||||
|
memories?: SearchResult[];
|
||||||
|
/** 用户附件元信息(空数组/undefined 时不产出附件分区) */
|
||||||
|
attachments?: AttachmentHint[];
|
||||||
|
/**
|
||||||
|
* 时区标签(如 "Asia/Shanghai (UTC+8)")。
|
||||||
|
* 由调用方计算(Intl.DateTimeFormat().resolvedOptions().timeZone)——
|
||||||
|
* 本模块保持纯函数语义,不做 Electron/Intl 环境依赖。
|
||||||
|
*/
|
||||||
|
timezoneLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记忆注入条目的内容截断(与原 dynamicReminders 注入口径一致) */
|
||||||
|
const MEMORY_EXCERPT_CHARS = 200;
|
||||||
|
/** 附件提示上限(与输入侧 5 个附件的硬上限对齐) */
|
||||||
|
const MAX_ATTACHMENT_HINTS = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建用户消息上下文前置块。
|
||||||
|
*
|
||||||
|
* 输出形态(各分区以 `\n\n---\n\n` 分隔,整体以分隔符结尾,
|
||||||
|
* 调用方直接 `${prefix}${userContent}` 拼接):
|
||||||
|
* ```
|
||||||
|
* [Contextual information for this message — system-generated metadata, not part of the user's request.]
|
||||||
|
*
|
||||||
|
* ## Current Date & Time
|
||||||
|
* 2026/8/30 14:30:00 (Asia/Shanghai, UTC+8)
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* ## Relevant Memories (Retrieved)
|
||||||
|
* [1] (semantic, 重要度: 0.9) ...
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* ## User Attachments (Direct Upload)
|
||||||
|
* ...
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function buildUserContextPrefix(input: UserContextPrefixInput): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// ===== 分区 1:当前日期时间(降精度到分钟) =====
|
||||||
|
const now = input.now ?? Date.now();
|
||||||
|
const timezoneLabel = input.timezoneLabel ?? 'UTC';
|
||||||
|
const dateStr = new Date(now).toLocaleString('sv-SE', {
|
||||||
|
timeZone: undefined,
|
||||||
|
hour12: false,
|
||||||
|
}); // sv-SE 给出 ISO 形态 "2026-08-30 14:30:00"
|
||||||
|
parts.push(`## Current Date & Time\n${dateStr} (${timezoneLabel})`);
|
||||||
|
|
||||||
|
// ===== 分区 2:相关记忆注入(沿用原 system 注入的展示口径) =====
|
||||||
|
const memories = (input.memories ?? []).slice(0, 5);
|
||||||
|
if (memories.length > 0) {
|
||||||
|
const memorySection = memories
|
||||||
|
.map(
|
||||||
|
(m, i) =>
|
||||||
|
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, MEMORY_EXCERPT_CHARS)}`,
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
parts.push(`## Relevant Memories (Retrieved)\n${memorySection}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 分区 3:附件提示(沿用原 system 注入的语义与文案契约) =====
|
||||||
|
const attachments = (input.attachments ?? []).slice(0, MAX_ATTACHMENT_HINTS);
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
const attachmentList = attachments
|
||||||
|
.map((att, i) => {
|
||||||
|
const typeLabel =
|
||||||
|
att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
|
||||||
|
// 文本附件被上传入口截断(512KB 上限)时,明确告知 LLM 内容不完整,
|
||||||
|
// 防止模型把残缺内容当作完整文件事实(v0.7.2 A5 契约延续)
|
||||||
|
const truncatedNote =
|
||||||
|
att.truncated === true
|
||||||
|
? ' (TRUNCATED — only the first 512KB is included; the full content is NOT available)'
|
||||||
|
: '';
|
||||||
|
const note =
|
||||||
|
att.type === 'image'
|
||||||
|
? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again'
|
||||||
|
: att.type === 'text'
|
||||||
|
? `content${truncatedNote} already inlined in the user message, do NOT search in workspace or read it again`
|
||||||
|
: 'uploaded directly by user, do NOT search in workspace';
|
||||||
|
return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
`## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header =
|
||||||
|
"[Contextual information for this message — system-generated metadata, not part of the user's request.]";
|
||||||
|
|
||||||
|
return `${header}\n\n${parts.join('\n\n---\n\n')}\n\n---\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将前置块与用户原始内容拼装为发送给引擎的消息内容。
|
||||||
|
* 空前缀(理论上不会发生——日期分区恒存在,但契约上防御)时原样返回。
|
||||||
|
*/
|
||||||
|
export function withUserContextPrefix(prefix: string, userContent: string): string {
|
||||||
|
if (!prefix) return userContent;
|
||||||
|
return `${prefix}${userContent}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* SSRF DNS Pinning 测试(v0.7.3 P2-1)
|
||||||
|
*
|
||||||
|
* 锁定三个单元:
|
||||||
|
* D1 createPinnedLookup —— 只返回校验阶段锁定的 IP 集合(过滤非法 family),
|
||||||
|
* 空集合返回 ENOTFOUND(防御)。
|
||||||
|
* D2 resolveRedirectTarget —— 重定向状态识别 + 相对 Location 解析 +
|
||||||
|
* 非法/缺失 Location 返回 null。
|
||||||
|
* D3 resolvePinnedIps —— IP 直连与私网拒绝(走 ssrf-guard 单一事实来源;
|
||||||
|
* 域名解析路径由 ssrf-guard 表测覆盖,此处不重复触网)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createPinnedLookup, resolveRedirectTarget, resolvePinnedIps } from '../ssrf-dispatcher';
|
||||||
|
import type { LookupCallback } from '../ssrf-dispatcher';
|
||||||
|
|
||||||
|
describe('createPinnedLookup', () => {
|
||||||
|
it('D1: 仅返回钉死的 IP 集合(忽略 hostname),family 正确标注', async () => {
|
||||||
|
const lookup = createPinnedLookup(['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946']);
|
||||||
|
const result = await new Promise<{ address: string; family: number }[]>((resolve, reject) => {
|
||||||
|
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses!));
|
||||||
|
lookup('attacker.example', {}, cb);
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toEqual({ address: '93.184.216.34', family: 4 });
|
||||||
|
expect(result[1].family).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D1: 非法 family(非 IPv4/IPv6 字符串)被过滤', async () => {
|
||||||
|
const lookup = createPinnedLookup(['not-an-ip']);
|
||||||
|
await expect(
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses));
|
||||||
|
lookup('h', {}, cb as never);
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'ENOTFOUND' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D1: 空集合 → ENOTFOUND(防御:调用方不应构造空 pin dispatcher)', async () => {
|
||||||
|
const lookup = createPinnedLookup([]);
|
||||||
|
await expect(
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses));
|
||||||
|
lookup('h', {}, cb as never);
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'ENOTFOUND' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveRedirectTarget', () => {
|
||||||
|
const makeResponse = (status: number, location?: string) => ({
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
get: (name: string) => (name.toLowerCase() === 'location' ? (location ?? null) : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D2: 301/302/303/307/308 识别并解析绝对 Location', () => {
|
||||||
|
for (const status of [301, 302, 303, 307, 308]) {
|
||||||
|
expect(
|
||||||
|
resolveRedirectTarget(makeResponse(status, 'https://cdn.example.com/x'), 'https://a.test/'),
|
||||||
|
).toBe('https://cdn.example.com/x');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D2: 相对 Location 以当前 URL 为基解析(RFC 7231)', () => {
|
||||||
|
expect(resolveRedirectTarget(makeResponse(302, '/next?a=1'), 'https://a.test/dir/page')).toBe(
|
||||||
|
'https://a.test/next?a=1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D2: 非 3xx 状态 → null(终态)', () => {
|
||||||
|
expect(resolveRedirectTarget(makeResponse(200), 'https://a.test/')).toBeNull();
|
||||||
|
expect(resolveRedirectTarget(makeResponse(404), 'https://a.test/')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D2: 缺失/非法 Location → null', () => {
|
||||||
|
expect(resolveRedirectTarget(makeResponse(302), 'https://a.test/')).toBeNull();
|
||||||
|
expect(resolveRedirectTarget(makeResponse(302, ''), 'https://a.test/')).toBeNull();
|
||||||
|
expect(resolveRedirectTarget(makeResponse(302, 'http://[::bad'), 'https://a.test/')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolvePinnedIps', () => {
|
||||||
|
it('D3: IP 直连 URL —— 公网 IP 直接返回', async () => {
|
||||||
|
const ips = await resolvePinnedIps('https://93.184.216.34/x');
|
||||||
|
expect(ips).toEqual(['93.184.216.34']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D3: 私有/回环 IP 直连被拒(单一事实来源 ssrf-guard)', async () => {
|
||||||
|
for (const host of ['127.0.0.1', '10.0.0.5', '169.254.169.254', '192.168.1.1', '[::1]']) {
|
||||||
|
await expect(resolvePinnedIps(`http://${host}/latest`)).rejects.toThrow(/Blocked SSRF/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D3: 非 http/https 协议被拒', async () => {
|
||||||
|
await expect(resolvePinnedIps('ftp://example.com')).rejects.toThrow(/not allowed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('D3: 非法 URL 被拒', async () => {
|
||||||
|
await expect(resolvePinnedIps('not a url')).rejects.toThrow(/Invalid URL/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
import { BrowserWindow, session } from 'electron';
|
import { BrowserWindow, session } from 'electron';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
// v0.7.3 P2-2: CORS Origin 回显(纯函数在 network-utils,可表测)
|
||||||
|
import { corsAllowOrigin, extractOriginHeader } from './network-utils';
|
||||||
|
|
||||||
/** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */
|
/** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */
|
||||||
const AGENT_PARTITION = 'persist:metona-agent-browser';
|
const AGENT_PARTITION = 'persist:metona-agent-browser';
|
||||||
@@ -128,12 +130,22 @@ export class BrowserWindowManager {
|
|||||||
|
|
||||||
// v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false
|
// v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false
|
||||||
// 仅对 agent session 放行 CORS,不影响主应用
|
// 仅对 agent session 放行 CORS,不影响主应用
|
||||||
|
// v0.7.3 P2-2 收紧: ACAO 从通配 '*' 改为回显请求 Origin —— 通配值让任意
|
||||||
|
// 第三方页面都能借该分区跨域读取;回显等价保留截图/页面自身跨域能力,
|
||||||
|
// 并附加 Vary: Origin 防止共享缓存把定向值串到其他 Origin。
|
||||||
const agentSession = session.fromPartition(AGENT_PARTITION);
|
const agentSession = session.fromPartition(AGENT_PARTITION);
|
||||||
agentSession.webRequest.onHeadersReceived((details, callback) => {
|
agentSession.webRequest.onHeadersReceived((details, callback) => {
|
||||||
|
// Electron 类型在此版本的 OnHeadersReceivedListenerDetails 上不暴露
|
||||||
|
// requestHeaders —— 显式声明读取面(Origin 大小写不敏感提取)
|
||||||
|
const requestHeaders = (
|
||||||
|
details as unknown as { requestHeaders?: Record<string, string | string[] | undefined> }
|
||||||
|
).requestHeaders;
|
||||||
|
const originHeader = extractOriginHeader(requestHeaders);
|
||||||
callback({
|
callback({
|
||||||
responseHeaders: {
|
responseHeaders: {
|
||||||
...details.responseHeaders,
|
...details.responseHeaders,
|
||||||
'Access-Control-Allow-Origin': ['*'],
|
'Access-Control-Allow-Origin': corsAllowOrigin(originHeader),
|
||||||
|
Vary: [...(details.responseHeaders?.Vary ?? []), 'Origin'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import type { MetonaToolDef } from '../../../harness/types';
|
|||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
|
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
|
||||||
import type { SandboxManager } from '../../sandbox/sandbox';
|
import type { SandboxManager } from '../../sandbox/sandbox';
|
||||||
|
// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源
|
||||||
|
// (与 MCP stdio 启动共用同一黑名单,历史双实现已漂移过一次)
|
||||||
|
import { buildSafeChildEnv } from '../../../utils/safe-env';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -52,47 +55,22 @@ function decodeBuffer(buf: Buffer): string {
|
|||||||
/**
|
/**
|
||||||
* #9 修复 + 审查修复: 构建安全的子进程环境变量
|
* #9 修复 + 审查修复: 构建安全的子进程环境变量
|
||||||
*
|
*
|
||||||
* 审查修复: 原白名单方案遗漏了 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量,导致子进程功能破坏。
|
* v0.7.3 P3-2: 实现收敛到 utils/safe-env.ts(buildSafeChildEnv)——
|
||||||
* 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
|
* 与 MCP stdio 启动共用同一黑名单,本文件仅保留 run_command 的运行时差异注入
|
||||||
|
* (Windows 中文编码变量)。黑名单方案的设计原因见 safe-env.ts 模块注释:
|
||||||
|
* 白名单方案会遗漏 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量导致子进程功能破坏。
|
||||||
*/
|
*/
|
||||||
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
|
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
|
||||||
// 敏感变量后缀黑名单
|
return buildSafeChildEnv({
|
||||||
const SENSITIVE_SUFFIXES = [
|
runtime: isWindows
|
||||||
'_API_KEY',
|
? {
|
||||||
'_TOKEN',
|
NODE_ENV: 'production',
|
||||||
'_SECRET',
|
PYTHONIOENCODING: 'utf-8',
|
||||||
'_PASSWORD',
|
LANG: 'zh_CN.UTF-8',
|
||||||
'_PASSWD',
|
LC_ALL: 'zh_CN.UTF-8',
|
||||||
'_CREDENTIAL',
|
|
||||||
'_CREDENTIALS',
|
|
||||||
'_PRIVATE_KEY',
|
|
||||||
];
|
|
||||||
// 敏感变量名黑名单(精确匹配)
|
|
||||||
const SENSITIVE_KEYS = new Set([
|
|
||||||
'DEEPSEEK_API_KEY',
|
|
||||||
'AGNES_API_KEY',
|
|
||||||
'MIMO_API_KEY',
|
|
||||||
'GITEA_PASSWORD',
|
|
||||||
'DATABASE_PASSWORD',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [key, val] of Object.entries(process.env)) {
|
|
||||||
if (!val) continue;
|
|
||||||
if (SENSITIVE_KEYS.has(key)) continue;
|
|
||||||
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
|
|
||||||
env[key] = val;
|
|
||||||
}
|
}
|
||||||
|
: { NODE_ENV: 'production' },
|
||||||
// 添加必要的运行时变量
|
});
|
||||||
env.NODE_ENV = 'production';
|
|
||||||
if (isWindows) {
|
|
||||||
env.PYTHONIOENCODING = 'utf-8';
|
|
||||||
env.LANG = 'zh_CN.UTF-8';
|
|
||||||
env.LC_ALL = 'zh_CN.UTF-8';
|
|
||||||
}
|
|
||||||
|
|
||||||
return env;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import type { MetonaToolDef } from '../../../harness/types';
|
|||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
// v0.6.4 P2-2: SSRF 校验收敛到共享模块 ssrf-guard.ts —— 原实现是本文件私有逻辑,
|
// v0.6.4 P2-2: SSRF 校验收敛到共享模块 ssrf-guard.ts —— 原实现是本文件私有逻辑,
|
||||||
// web_fetch 无校验造成工具层最大的安全不对称。单源后所有网络工具行为一致。
|
// web_fetch 无校验造成工具层最大的安全不对称。单源后所有网络工具行为一致。
|
||||||
|
// v0.7.3 P2-1 根治: 请求层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到
|
||||||
|
// 连接层(undici connect.lookup),校验与连接共用同一批 IP,DNS rebinding
|
||||||
|
// 窗口(M7 已知限制)就此关闭;代理激活时自动退化为仅入口校验(见模块注释)。
|
||||||
import { validateSSRF } from './ssrf-guard';
|
import { validateSSRF } from './ssrf-guard';
|
||||||
|
import { ssrfPinnedFetch } from './ssrf-dispatcher';
|
||||||
|
|
||||||
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const;
|
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const;
|
||||||
const MAX_BODY_BYTES = 50 * 1024; // 50KB
|
const MAX_BODY_BYTES = 50 * 1024; // 50KB
|
||||||
@@ -52,7 +56,8 @@ const MAX_BODY_BYTES = 50 * 1024; // 50KB
|
|||||||
export class HttpRequestTool implements IMetonaTool {
|
export class HttpRequestTool implements IMetonaTool {
|
||||||
readonly definition: MetonaToolDef = {
|
readonly definition: MetonaToolDef = {
|
||||||
name: 'http_request',
|
name: 'http_request',
|
||||||
description: 'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.',
|
description:
|
||||||
|
'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -64,7 +69,10 @@ export class HttpRequestTool implements IMetonaTool {
|
|||||||
},
|
},
|
||||||
headers: { type: 'object', description: 'Request headers as key-value pairs' },
|
headers: { type: 'object', description: 'Request headers as key-value pairs' },
|
||||||
body: { type: 'string', description: 'Request body (string)' },
|
body: { type: 'string', description: 'Request body (string)' },
|
||||||
timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000, max 60000)' },
|
timeout: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Timeout in milliseconds (default 30000, max 60000)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['url'],
|
required: ['url'],
|
||||||
},
|
},
|
||||||
@@ -102,15 +110,12 @@ export class HttpRequestTool implements IMetonaTool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 超时控制
|
// 超时控制由 ssrfPinnedFetch 内部管理(超时 → ETIMEDOUT;
|
||||||
const controller = new AbortController();
|
// 工具执行层的 abort signal 经 context 传入 registry 兜底)
|
||||||
const timer = setTimeout(() => controller.abort(), timeout);
|
{
|
||||||
|
|
||||||
try {
|
|
||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal,
|
|
||||||
// #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验
|
// #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验
|
||||||
// 重定向后的 URL 由用户自行处理(响应中会包含 Location 头)
|
// 重定向后的 URL 由用户自行处理(响应中会包含 Location 头)
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
@@ -120,7 +125,9 @@ export class HttpRequestTool implements IMetonaTool {
|
|||||||
fetchOptions.body = body;
|
fetchOptions.body = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, fetchOptions);
|
// v0.7.3 P2-1: pinned fetch —— 校验通过的 IP pin 到连接层,
|
||||||
|
// 关闭校验-连接之间的 DNS rebinding 窗口
|
||||||
|
const response = await ssrfPinnedFetch(url, fetchOptions, timeout);
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
// 截断到 50KB
|
// 截断到 50KB
|
||||||
@@ -146,12 +153,12 @@ export class HttpRequestTool implements IMetonaTool {
|
|||||||
ok: response.ok,
|
ok: response.ok,
|
||||||
success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段
|
success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段
|
||||||
};
|
};
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 区分超时(AbortError)与其他网络错误
|
// 区分超时与其他网络错误:AbortError(外部中断)与
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
// ETIMEDOUT(ssrfPinnedFetch 超时转译,v0.7.3 P2-1)均归为超时语义
|
||||||
|
const err = error as Error & { code?: string };
|
||||||
|
if (err?.name === 'AbortError' || err?.code === 'ETIMEDOUT') {
|
||||||
return { error: 'Request timeout', success: false };
|
return { error: 'Request timeout', success: false };
|
||||||
}
|
}
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export const UA_POOL = [
|
|||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MOBILE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
|
export const MOBILE_UA =
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
|
||||||
|
|
||||||
export const ACCEPT_LANGUAGE_POOL = [
|
export const ACCEPT_LANGUAGE_POOL = [
|
||||||
'zh-CN,zh;q=0.9,en;q=0.8',
|
'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
@@ -54,21 +55,25 @@ export function buildAntiCrawlHeaders(
|
|||||||
const userAgent = mobileUA ? MOBILE_UA : UA_POOL[uaIdx];
|
const userAgent = mobileUA ? MOBILE_UA : UA_POOL[uaIdx];
|
||||||
|
|
||||||
let origin = '';
|
let origin = '';
|
||||||
try { origin = new URL(url).origin; } catch { /* ignore */ }
|
try {
|
||||||
|
origin = new URL(url).origin;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'User-Agent': userAgent,
|
'User-Agent': userAgent,
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||||
'Accept-Language': ACCEPT_LANGUAGE_POOL[langIdx],
|
'Accept-Language': ACCEPT_LANGUAGE_POOL[langIdx],
|
||||||
'Accept-Encoding': 'gzip, deflate, br',
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
'DNT': '1',
|
DNT: '1',
|
||||||
'Referer': origin || '',
|
Referer: origin || '',
|
||||||
'Sec-Fetch-Dest': 'document',
|
'Sec-Fetch-Dest': 'document',
|
||||||
'Sec-Fetch-Mode': 'navigate',
|
'Sec-Fetch-Mode': 'navigate',
|
||||||
'Sec-Fetch-Site': 'none',
|
'Sec-Fetch-Site': 'none',
|
||||||
'Sec-Fetch-User': '?1',
|
'Sec-Fetch-User': '?1',
|
||||||
'Pragma': 'no-cache',
|
Pragma: 'no-cache',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +112,15 @@ export function normalizeUrl(url: string): string {
|
|||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
|
|
||||||
// 去除追踪参数
|
// 去除追踪参数
|
||||||
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'];
|
const trackingParams = [
|
||||||
|
'utm_source',
|
||||||
|
'utm_medium',
|
||||||
|
'utm_campaign',
|
||||||
|
'utm_term',
|
||||||
|
'utm_content',
|
||||||
|
'gclid',
|
||||||
|
'fbclid',
|
||||||
|
];
|
||||||
for (const p of trackingParams) u.searchParams.delete(p);
|
for (const p of trackingParams) u.searchParams.delete(p);
|
||||||
|
|
||||||
// H-6 增强: 排序查询参数(确保参数顺序一致,便于去重)
|
// H-6 增强: 排序查询参数(确保参数顺序一致,便于去重)
|
||||||
@@ -125,7 +138,7 @@ export function normalizeUrl(url: string): string {
|
|||||||
(u.protocol === 'https:' && u.port === '443') ||
|
(u.protocol === 'https:' && u.port === '443') ||
|
||||||
(u.protocol === 'ws:' && u.port === '80') ||
|
(u.protocol === 'ws:' && u.port === '80') ||
|
||||||
(u.protocol === 'wss:' && u.port === '443');
|
(u.protocol === 'wss:' && u.port === '443');
|
||||||
const portSuffix = isDefaultPort ? '' : (u.port ? `:${u.port}` : '');
|
const portSuffix = isDefaultPort ? '' : u.port ? `:${u.port}` : '';
|
||||||
|
|
||||||
// 强制小写 host
|
// 强制小写 host
|
||||||
return `${u.protocol}//${u.hostname.toLowerCase()}${portSuffix}${path}${u.search}${u.hash}`;
|
return `${u.protocol}//${u.hostname.toLowerCase()}${portSuffix}${path}${u.search}${u.hash}`;
|
||||||
@@ -158,15 +171,32 @@ export function isInterceptedPage(html: string): boolean {
|
|||||||
// ===== HTML → 纯文本转换 =====
|
// ===== HTML → 纯文本转换 =====
|
||||||
|
|
||||||
const HTML_ENTITY_MAP: Record<string, string> = {
|
const HTML_ENTITY_MAP: Record<string, string> = {
|
||||||
' ': ' ', '<': '<', '>': '>', '&': '&', '"': '"',
|
' ': ' ',
|
||||||
''': "'", '…': '…', '—': '—', '–': '–',
|
'<': '<',
|
||||||
'«': '«', '»': '»', '×': '×', '÷': '÷',
|
'>': '>',
|
||||||
'©': '©', '®': '®', '™': '™', '€': '€',
|
'&': '&',
|
||||||
'£': '£', '¥': '¥', '¢': '¢', '°': '°',
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
'…': '…',
|
||||||
|
'—': '—',
|
||||||
|
'–': '–',
|
||||||
|
'«': '«',
|
||||||
|
'»': '»',
|
||||||
|
'×': '×',
|
||||||
|
'÷': '÷',
|
||||||
|
'©': '©',
|
||||||
|
'®': '®',
|
||||||
|
'™': '™',
|
||||||
|
'€': '€',
|
||||||
|
'£': '£',
|
||||||
|
'¥': '¥',
|
||||||
|
'¢': '¢',
|
||||||
|
'°': '°',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function htmlToText(html: string): string {
|
export function htmlToText(html: string): string {
|
||||||
return html
|
return (
|
||||||
|
html
|
||||||
// 移除噪声标签及内容
|
// 移除噪声标签及内容
|
||||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
@@ -193,12 +223,16 @@ export function htmlToText(html: string): string {
|
|||||||
.replace(/\n{3,}/g, '\n\n')
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
.replace(/[ \t]+/g, ' ')
|
.replace(/[ \t]+/g, ' ')
|
||||||
.replace(/^[ \t]+/gm, '')
|
.replace(/^[ \t]+/gm, '')
|
||||||
.trim();
|
.trim()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 流式读取(大文件保护,10MB 上限) =====
|
// ===== 流式读取(大文件保护,10MB 上限) =====
|
||||||
|
|
||||||
export async function readBodyWithLimit(response: Response, maxBytes = 10 * 1024 * 1024): Promise<string> {
|
export async function readBodyWithLimit(
|
||||||
|
response: Response,
|
||||||
|
maxBytes = 10 * 1024 * 1024,
|
||||||
|
): Promise<string> {
|
||||||
const contentLength = response.headers.get('content-length');
|
const contentLength = response.headers.get('content-length');
|
||||||
if (contentLength && parseInt(contentLength) > maxBytes) {
|
if (contentLength && parseInt(contentLength) > maxBytes) {
|
||||||
throw new Error(`Response too large: ${contentLength} bytes (limit: ${maxBytes})`);
|
throw new Error(`Response too large: ${contentLength} bytes (limit: ${maxBytes})`);
|
||||||
@@ -249,6 +283,40 @@ export function logTool(toolName: string, message: string): void {
|
|||||||
log.info(`[Tool:${toolName}] ${message}`);
|
log.info(`[Tool:${toolName}] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== v0.7.3 P2-2: Agent 浏览器 CORS Origin 回显 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算响应应携带的 Access-Control-Allow-Origin 值(纯函数,表测锁定)。
|
||||||
|
*
|
||||||
|
* 背景:Agent 浏览器专用 session 此前对所有响应注入 ACAO:* —— 任意被 Agent
|
||||||
|
* 打开的第三方页面都能借该分区无差别跨域读取。改为回显请求 Origin(等价能力:
|
||||||
|
* 页面对自己的 Origin 仍可跨域读,如截图所需),无 Origin(同源导航/非浏览器
|
||||||
|
* 客户端)回退 '*' 保持既有能力不回退。
|
||||||
|
*
|
||||||
|
* @param requestOrigin 请求头 Origin(可能为 undefined / 任意字符串)
|
||||||
|
* @returns 应写入响应的 ACAO 值(单元素数组,供 Electron responseHeaders 使用)
|
||||||
|
*/
|
||||||
|
export function corsAllowOrigin(requestOrigin: string | undefined | null): string[] {
|
||||||
|
const origin = requestOrigin?.trim();
|
||||||
|
if (origin && /^https?:\/\//i.test(origin)) {
|
||||||
|
return [origin];
|
||||||
|
}
|
||||||
|
return ['*'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从请求头集合中大小写不敏感地提取 Origin 值 */
|
||||||
|
export function extractOriginHeader(
|
||||||
|
requestHeaders: Record<string, string | string[] | undefined> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!requestHeaders) return undefined;
|
||||||
|
for (const [key, value] of Object.entries(requestHeaders)) {
|
||||||
|
if (key.toLowerCase() === 'origin') {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== v0.6.4 P4-4: HTML → Markdown 转换(web_fetch extract_mode='markdown') =====
|
// ===== v0.6.4 P4-4: HTML → Markdown 转换(web_fetch extract_mode='markdown') =====
|
||||||
//
|
//
|
||||||
// v0.6.4 收尾:私有 npm 凭据解锁后,按开发规范第一铁律把第一轮的临时自写实现
|
// v0.6.4 收尾:私有 npm 凭据解锁后,按开发规范第一铁律把第一轮的临时自写实现
|
||||||
@@ -285,11 +353,9 @@ const LIST_LINE = /^\s*(?:- |\d+\. )/;
|
|||||||
* 2. 仅当"空行两侧都是同一列表的条目行"时移除该空行(绝不吞条目、不影响段落间距)。
|
* 2. 仅当"空行两侧都是同一列表的条目行"时移除该空行(绝不吞条目、不影响段落间距)。
|
||||||
*/
|
*/
|
||||||
function collapseListGaps(markdown: string): string {
|
function collapseListGaps(markdown: string): string {
|
||||||
const lines = markdown.split('\n').map((line) =>
|
const lines = markdown
|
||||||
line
|
.split('\n')
|
||||||
.replace(/^(\s*)- {2,}/, '$1- ')
|
.map((line) => line.replace(/^(\s*)- {2,}/, '$1- ').replace(/^(\s*\d+\.)\s{2,}/, '$1 '));
|
||||||
.replace(/^(\s*\d+\.)\s{2,}/, '$1 '),
|
|
||||||
);
|
|
||||||
|
|
||||||
const isListItem = (l: string | undefined): boolean => (l ?? '').length > 0 && LIST_LINE.test(l!);
|
const isListItem = (l: string | undefined): boolean => (l ?? '').length > 0 && LIST_LINE.test(l!);
|
||||||
|
|
||||||
@@ -321,5 +387,7 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return collapseListGaps(md).replace(/\n{3,}/g, '\n\n').trim();
|
return collapseListGaps(md)
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* SSRF DNS Pinning Dispatcher(v0.7.3 P2-1)
|
||||||
|
*
|
||||||
|
* 关闭 M7 审查确认的 DNS rebinding 窗口:此前 validateSSRF 在校验阶段解析一次
|
||||||
|
* DNS,fetch 实际连接时 undici 再次解析 —— 两次解析之间攻击者可切换 DNS 记录
|
||||||
|
* (TTL=0)把连接导向内网。原注释断言"Node fetch 下无法彻底关闭",该结论只对
|
||||||
|
* 全局 fetch 成立;主进程已依赖 undici(network-proxy 的 setGlobalDispatcher),
|
||||||
|
* undici 的 Agent 支持 connect.lookup 自定义 —— 校验通过的 IP 集合可精确 pin 到
|
||||||
|
* 连接层,TLS SNI/证书校验仍基于原始域名(undici 将 servername 保持为 hostname)。
|
||||||
|
*
|
||||||
|
* 契约:
|
||||||
|
* - resolvePublicAddresses(ssrf-guard)是校验与地址解析的唯一事实来源,
|
||||||
|
* 本模块 pin 的就是它返回的那批 IP —— 校验与连接同源,无双解析窗口;
|
||||||
|
* - 代理激活(network-proxy.isProxyActive())时 pinning 不可实现(DNS 在代理端
|
||||||
|
* 解析)且 per-request dispatcher 会旁路用户代理 —— 退化为"仅入口校验",
|
||||||
|
* 走全局 dispatcher(保持既有语义与代理兼容);
|
||||||
|
* - fetchWithTimeoutPinned 合并外部 abort signal 与超时控制,语义对齐
|
||||||
|
* BaseAdapter.fetchWithTimeout(超时 → ETIMEDOUT 可重试;外部中断原样抛出);
|
||||||
|
* - 每次 pinned 请求构造一次性 Agent 并在 finally 中 close(连接池即用即毁,
|
||||||
|
* 防止把"上一请求的 pin 集合"泄漏给后续请求)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Agent, fetch as undiciFetch } from 'undici';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { resolvePublicAddresses } from './ssrf-guard';
|
||||||
|
import { fetchWithTimeout } from './network-utils';
|
||||||
|
import { isProxyActive } from '../../../utils/network-proxy';
|
||||||
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
/** 标准 dns.lookup 回调签名(undici connect.lookup 消费) */
|
||||||
|
export type LookupCallback = (
|
||||||
|
err: NodeJS.ErrnoException | null,
|
||||||
|
addresses?: Array<{ address: string; family: number }>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** undici connect.lookup 的函数签名形态 */
|
||||||
|
export type PinnedLookup = (hostname: string, options: unknown, callback: LookupCallback) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造"钉死 IP 集合"的 lookup 函数:无论传入什么 hostname,都只返回校验阶段
|
||||||
|
* 锁定的公网地址(过滤非法 family)。集合为空时返回 ENOTFOUND(防御性——
|
||||||
|
* 调用方在集合为空时不应构造 dispatcher)。
|
||||||
|
*/
|
||||||
|
export function createPinnedLookup(allowedIps: string[]): PinnedLookup {
|
||||||
|
return (_hostname, _options, callback) => {
|
||||||
|
process.nextTick(() => {
|
||||||
|
const addresses = allowedIps
|
||||||
|
.map((ip) => ({ address: ip, family: isIP(ip) }))
|
||||||
|
.filter((a): a is { address: string; family: number } => a.family === 4 || a.family === 6);
|
||||||
|
if (addresses.length === 0) {
|
||||||
|
const err: NodeJS.ErrnoException = new Error('pinned lookup: no allowed addresses');
|
||||||
|
err.code = 'ENOTFOUND';
|
||||||
|
callback(err, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, addresses);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 URL 并返回 pinning 用的公网 IP 集合。
|
||||||
|
* 校验失败原样抛出(调用方按 SSRF 阻断处理)。
|
||||||
|
*/
|
||||||
|
export async function resolvePinnedIps(url: string): Promise<string[]> {
|
||||||
|
return resolvePublicAddresses(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带 SSRF pinning 的 fetch(http_request / web_fetch Phase1 / 可达性预检共用)。
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* 1. 代理激活 → 退化为普通 fetchWithTimeout(仅入口校验语义,见模块注释);
|
||||||
|
* 2. 否则 → 解析并校验公网 IP → 一次性 undici Agent(pinned lookup)发起请求;
|
||||||
|
* 3. 超时/外部中断语义与 fetchWithTimeout 对齐;
|
||||||
|
* 4. 返回 Response 与全局 fetch 兼容(status/ok/headers/text/url/body)。
|
||||||
|
*
|
||||||
|
* @param url 目标 URL(调用方已保证 http/https;本函数再做一次全量 SSRF 校验)
|
||||||
|
* @param init RequestInit(redirect 等由调用方决定)
|
||||||
|
* @param timeoutMs 请求超时
|
||||||
|
* @param externalSignal 外部 abort 信号(引擎中断透传,可选)
|
||||||
|
*/
|
||||||
|
export async function ssrfPinnedFetch(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
timeoutMs: number,
|
||||||
|
externalSignal?: AbortSignal,
|
||||||
|
): Promise<Response> {
|
||||||
|
const ips = await resolvePublicAddresses(url);
|
||||||
|
|
||||||
|
// 代理激活:DNS 在代理端解析,pinning 不可实现;走全局 dispatcher 保持代理语义
|
||||||
|
if (isProxyActive()) {
|
||||||
|
return fetchWithTimeout(url, init, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 外部信号已中止 → 直接抛 AbortError(对齐 fetchWithTimeout 行为)
|
||||||
|
if (externalSignal?.aborted) {
|
||||||
|
const err = new Error('Aborted');
|
||||||
|
err.name = 'AbortError';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timedOut = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
controller.abort();
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const onExternalAbort = () => controller.abort();
|
||||||
|
if (externalSignal) {
|
||||||
|
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次性 pinned Agent(connect 超时对齐 network-proxy 的 15s 连接上限)
|
||||||
|
const dispatcher = new Agent({
|
||||||
|
connect: { timeout: 15_000, lookup: createPinnedLookup(ips) as never },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await undiciFetch(url, {
|
||||||
|
...(init as Record<string, unknown>),
|
||||||
|
signal: controller.signal,
|
||||||
|
dispatcher,
|
||||||
|
} as never);
|
||||||
|
return response as unknown as Response;
|
||||||
|
} catch (err) {
|
||||||
|
const externalAborted = externalSignal?.aborted === true;
|
||||||
|
if (timedOut && !externalAborted) {
|
||||||
|
const timeoutError = new Error(
|
||||||
|
`Request timed out after ${timeoutMs}ms (url=${String(url).slice(0, 120)})`,
|
||||||
|
);
|
||||||
|
(timeoutError as Error & { code: string }).code = 'ETIMEDOUT';
|
||||||
|
throw timeoutError;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (externalSignal) {
|
||||||
|
externalSignal.removeEventListener('abort', onExternalAbort);
|
||||||
|
}
|
||||||
|
// 一次性 dispatcher 用后即毁(连接池不跨请求复用,防止 pin 集合泄漏)
|
||||||
|
void dispatcher.close().catch((closeErr) => {
|
||||||
|
log.debug(`[SSRFDispatcher] dispatcher close failed: ${(closeErr as Error).message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析重定向目标(纯函数,表测锁定)。
|
||||||
|
*
|
||||||
|
* @returns 下一跳绝对 URL;非重定向状态/缺失/非法 Location 返回 null
|
||||||
|
* (表示当前响应即终态或无法跟随,由调用方按既有语义处理)。
|
||||||
|
* 相对 Location 以 currentUrl 为基解析(RFC 7231)。
|
||||||
|
*/
|
||||||
|
export function resolveRedirectTarget(
|
||||||
|
response: { status: number; headers: { get(name: string): string | null } },
|
||||||
|
currentUrl: string,
|
||||||
|
): string | null {
|
||||||
|
if (!REDIRECT_STATUS.has(response.status)) return null;
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location) return null;
|
||||||
|
try {
|
||||||
|
return new URL(location, currentUrl).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,16 +57,22 @@ export function isPrivateIP(ip: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSRF 校验 — 解析 URL 域名并校验 IP
|
* SSRF 校验 + 公网地址解析(单一事实来源)
|
||||||
|
*
|
||||||
|
* v0.7.3 P2-1 重构:resolvePublicAddresses 承载全部校验逻辑并返回解析出的
|
||||||
|
* 公网 IP 集合;validateSSRF 成为它的"只要不抛错"薄包装。这样 pinning 层
|
||||||
|
* (ssrf-dispatcher)能拿到与校验完全同一批 IP,避免"校验一次解析、连接
|
||||||
|
* 再解析一次"的双解析不一致。
|
||||||
*
|
*
|
||||||
* 1. 协议白名单:仅允许 http/https
|
* 1. 协议白名单:仅允许 http/https
|
||||||
* 2. hostname 为 IP 时直接检测
|
* 2. hostname 为 IP 时直接检测
|
||||||
* 3. 域名 — DNS 解析后检测所有 IP;任意一个 IP 为私有即拒绝
|
* 3. 域名 — DNS 解析后检测所有 IP;任意一个 IP 为私有即拒绝
|
||||||
* (防止 DNS rebinding 中只校验第一个 IP 的绕过)
|
* (防止 DNS rebinding 中只校验第一个 IP 的绕过)
|
||||||
*
|
*
|
||||||
|
* @returns 校验通过的全部公网 IP(供 DNS pinning 使用)
|
||||||
* @throws 如果 URL 指向私有/内网/回环地址或协议不被允许
|
* @throws 如果 URL 指向私有/内网/回环地址或协议不被允许
|
||||||
*/
|
*/
|
||||||
export async function validateSSRF(url: string): Promise<void> {
|
export async function resolvePublicAddresses(url: string): Promise<string[]> {
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(url);
|
parsed = new URL(url);
|
||||||
@@ -86,7 +92,7 @@ export async function validateSSRF(url: string): Promise<void> {
|
|||||||
if (isPrivateIP(hostname)) {
|
if (isPrivateIP(hostname)) {
|
||||||
throw new Error(`Blocked SSRF: ${hostname} is a private/loopback address`);
|
throw new Error(`Blocked SSRF: ${hostname} is a private/loopback address`);
|
||||||
}
|
}
|
||||||
return;
|
return [hostname];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 域名 — DNS 解析后检测所有 IP
|
// 域名 — DNS 解析后检测所有 IP
|
||||||
@@ -94,22 +100,39 @@ export async function validateSSRF(url: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
addresses = await lookup(hostname, { all: true });
|
addresses = await lookup(hostname, { all: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`);
|
throw new Error(
|
||||||
|
`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addresses.length === 0) {
|
if (addresses.length === 0) {
|
||||||
throw new Error(`Blocked SSRF: no DNS records for ${hostname}`);
|
throw new Error(`Blocked SSRF: no DNS records for ${hostname}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publicIps: string[] = [];
|
||||||
for (const { address } of addresses) {
|
for (const { address } of addresses) {
|
||||||
if (isPrivateIP(address)) {
|
if (isPrivateIP(address)) {
|
||||||
throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`);
|
throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`);
|
||||||
}
|
}
|
||||||
|
publicIps.push(address);
|
||||||
}
|
}
|
||||||
|
return publicIps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF 校验 — 解析 URL 域名并校验 IP(v0.7.3 起为 resolvePublicAddresses 的
|
||||||
|
* "仅校验不取值"包装,校验逻辑单一来源在后者)
|
||||||
|
*
|
||||||
|
* @throws 如果 URL 指向私有/内网/回环地址或协议不被允许
|
||||||
|
*/
|
||||||
|
export async function validateSSRF(url: string): Promise<void> {
|
||||||
|
await resolvePublicAddresses(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** validateSSRF 的不抛错包装:返回结构化结果供工具 execute 直接 return */
|
/** validateSSRF 的不抛错包装:返回结构化结果供工具 execute 直接 return */
|
||||||
export async function safeValidateSSRF(url: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
export async function safeValidateSSRF(
|
||||||
|
url: string,
|
||||||
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
try {
|
try {
|
||||||
await validateSSRF(url);
|
await validateSSRF(url);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
|||||||
import {
|
import {
|
||||||
fetchCache,
|
fetchCache,
|
||||||
buildAntiCrawlHeaders,
|
buildAntiCrawlHeaders,
|
||||||
fetchWithTimeout,
|
|
||||||
htmlToText,
|
htmlToText,
|
||||||
isInterceptedPage,
|
isInterceptedPage,
|
||||||
readBodyWithLimit,
|
readBodyWithLimit,
|
||||||
@@ -30,14 +29,21 @@ import { getBrowserManager } from './browser';
|
|||||||
// v0.6.4 P2-2 根治安全不对称:web_fetch 此前完全没有 SSRF 校验(仅协议检查)且
|
// v0.6.4 P2-2 根治安全不对称:web_fetch 此前完全没有 SSRF 校验(仅协议检查)且
|
||||||
// requiresPermission:false —— LLM 可直接抓取 http://127.0.0.1:<port>、
|
// requiresPermission:false —— LLM 可直接抓取 http://127.0.0.1:<port>、
|
||||||
// http://169.254.169.254/latest/meta-data 等内网/云元数据地址,浏览器回退通道
|
// http://169.254.169.254/latest/meta-data 等内网/云元数据地址,浏览器回退通道
|
||||||
// 同样可达内网。现复用共享 ssrf-guard 模块(与 http_request 同源同行为):
|
// 同样可达内网。现复用共享 ssrf-guard 模块(与 http_request 同源同行为)。
|
||||||
// 入口校验 + HTTP 重定向终态 URL 复检(堵 redirect:'follow' 绕道内网的口子)。
|
// v0.7.3 P2-1 根治: 抓取层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到
|
||||||
|
// 连接层(undici connect.lookup),关闭校验-连接之间的 DNS rebinding 窗口;
|
||||||
|
// 重定向改为逐跳手动跟随,每一跳都先校验后连接(原 redirect:'follow' 下
|
||||||
|
// 中间跳转在"终态复检"之前已真实发出,可触达内网)。
|
||||||
import { validateSSRF } from './ssrf-guard';
|
import { validateSSRF } from './ssrf-guard';
|
||||||
|
import { resolveRedirectTarget, ssrfPinnedFetch } from './ssrf-dispatcher';
|
||||||
|
|
||||||
// ===== 跳过重试的状态码 =====
|
// ===== 跳过重试的状态码 =====
|
||||||
|
|
||||||
const SKIP_RETRY_STATUS = new Set([403, 429, 502, 503]);
|
const SKIP_RETRY_STATUS = new Set([403, 429, 502, 503]);
|
||||||
|
|
||||||
|
/** v0.7.3 P2-1: 单次抓取允许的最大重定向跳数(每跳均经校验 + pinning) */
|
||||||
|
const MAX_REDIRECT_HOPS = 5;
|
||||||
|
|
||||||
// ===== WebFetchTool =====
|
// ===== WebFetchTool =====
|
||||||
|
|
||||||
export class WebFetchTool implements IMetonaTool {
|
export class WebFetchTool implements IMetonaTool {
|
||||||
@@ -188,25 +194,55 @@ export class WebFetchTool implements IMetonaTool {
|
|||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
const headers = buildAntiCrawlHeaders(url, attempt, mobileUA);
|
const headers = buildAntiCrawlHeaders(url, attempt, mobileUA);
|
||||||
const response = await fetchWithTimeout(url, { headers, redirect: 'follow' }, 20_000);
|
|
||||||
|
|
||||||
// v0.6.4 P2-2: 重定向终态复检 —— redirect:'follow' 下 fetch 可能跟随跳转
|
// ===== v0.7.3 P2-1 根治: 手动逐跳重定向 + 每跳 SSRF 校验 + DNS pinning =====
|
||||||
// 到与入口校验不同的目标;SSRF 校验 initial URL 后再对 response.url(终态)
|
// 原 redirect:'follow' 下 undici 在内核自动跟跳:跳转目标仅在"终态复检"
|
||||||
// 复检,堵住"外网跳内网"绕道。终态指向私有地址时按拦截处理转入浏览器通道
|
// 时被校验,中间跳转的请求已经真实发出(可触达内网/元数据地址)。
|
||||||
// 也会被浏览器侧域名校验拒绝。
|
// 现改为逐跳手动跟随:每跳由 ssrfPinnedFetch 发出(校验通过 IP pin 到
|
||||||
if (response.url && response.url !== url) {
|
// 连接层),Location 目标显式校验通过后才允许下一跳;私有地址/非法
|
||||||
|
// 协议目标按 blocked 语义立即终止(禁止重试与浏览器回退)。
|
||||||
|
let currentUrl = url;
|
||||||
|
let response: Response | null = null;
|
||||||
|
let redirectBlocked: string | null = null;
|
||||||
|
|
||||||
|
for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) {
|
||||||
|
response = await ssrfPinnedFetch(currentUrl, { headers, redirect: 'manual' }, 20_000);
|
||||||
|
const next = resolveRedirectTarget(response, currentUrl);
|
||||||
|
if (next === null) break; // 非重定向(或无/非法 Location)—— 当前响应即终态
|
||||||
|
if (hop === MAX_REDIRECT_HOPS) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
html: '',
|
||||||
|
text: '',
|
||||||
|
intercepted: false,
|
||||||
|
reason: `Too many redirects (>${MAX_REDIRECT_HOPS})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 下一跳目标显式校验(ssrfPinnedFetch 内部还会再次校验+pinning;
|
||||||
|
// 这里提前拦截以保证 blocked 语义:不重试、不进浏览器回退)
|
||||||
try {
|
try {
|
||||||
await validateSSRF(response.url);
|
await validateSSRF(next);
|
||||||
} catch (ssrfErr) {
|
} catch (ssrfErr) {
|
||||||
|
redirectBlocked = `Redirect target blocked by SSRF guard: ${(ssrfErr as Error).message}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentUrl = next;
|
||||||
|
response = null; // 丢弃中间跳转响应,下一跳重新抓取
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectBlocked) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
html: '',
|
html: '',
|
||||||
text: '',
|
text: '',
|
||||||
intercepted: false,
|
intercepted: false,
|
||||||
blocked: true,
|
blocked: true,
|
||||||
reason: `Redirect target blocked by SSRF guard: ${(ssrfErr as Error).message}`,
|
reason: redirectBlocked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (!response) {
|
||||||
|
// 防御性:循环正常结束必然携带终态响应
|
||||||
|
return { success: false, html: '', text: '', intercepted: false, reason: 'No response' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳过重试的状态码 → 直接进入浏览器回退
|
// 跳过重试的状态码 → 直接进入浏览器回退
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import {
|
|||||||
buildSearXNGAuthHeaders,
|
buildSearXNGAuthHeaders,
|
||||||
logTool,
|
logTool,
|
||||||
} from './network-utils';
|
} from './network-utils';
|
||||||
|
// v0.7.3 P2-1: 可达性预检经 SSRF 校验 + DNS pinning(结果 URL 是不可信外部输入)
|
||||||
|
import { safeValidateSSRF } from './ssrf-guard';
|
||||||
|
import { ssrfPinnedFetch } from './ssrf-dispatcher';
|
||||||
import type { WebFetchTool } from './web-fetch';
|
import type { WebFetchTool } from './web-fetch';
|
||||||
|
|
||||||
// ===== 类型定义 =====
|
// ===== 类型定义 =====
|
||||||
@@ -330,6 +333,10 @@ function parse360Regex(html: string): SearchResult[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 可达性预检 =====
|
// ===== 可达性预检 =====
|
||||||
|
// v0.7.3 P2-1: 可达性预检的 URL 来自不可信的搜索结果 —— HEAD 探测同样不得
|
||||||
|
// 触达内网/元数据地址。校验失败的 URL 直接标记不可达(不发起任何请求);
|
||||||
|
// 探测经 ssrfPinnedFetch(DNS pinning),重定向不自动跟随(3xx 即视为可达 ——
|
||||||
|
// 链接活性已证明,且跟跳目标不再绕过校验)。
|
||||||
|
|
||||||
async function checkReachability(urls: string[], concurrency = 5): Promise<Map<string, boolean>> {
|
async function checkReachability(urls: string[], concurrency = 5): Promise<Map<string, boolean>> {
|
||||||
const result = new Map<string, boolean>();
|
const result = new Map<string, boolean>();
|
||||||
@@ -337,8 +344,13 @@ async function checkReachability(urls: string[], concurrency = 5): Promise<Map<s
|
|||||||
const batch = urls.slice(i, i + concurrency);
|
const batch = urls.slice(i, i + concurrency);
|
||||||
const checks = batch.map(async (url) => {
|
const checks = batch.map(async (url) => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithTimeout(url, { method: 'HEAD', redirect: 'follow' }, 3_000);
|
const ssrf = await safeValidateSSRF(url);
|
||||||
result.set(url, resp.ok);
|
if (!ssrf.ok) {
|
||||||
|
result.set(url, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resp = await ssrfPinnedFetch(url, { method: 'HEAD', redirect: 'manual' }, 3_000);
|
||||||
|
result.set(url, resp.ok || (resp.status >= 300 && resp.status < 400));
|
||||||
} catch {
|
} catch {
|
||||||
result.set(url, false);
|
result.set(url, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export interface MetonaModelInfo {
|
|||||||
supportsToolCalling?: boolean;
|
supportsToolCalling?: boolean;
|
||||||
/** 是否支持 Thinking / Reasoning */
|
/** 是否支持 Thinking / Reasoning */
|
||||||
supportsThinking?: boolean;
|
supportsThinking?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否支持视觉(图片输入)— v0.7.3 P1-4。
|
||||||
|
* undefined 表示未知(调用方保守放行,保持可用性);
|
||||||
|
* Ollama /api/show capabilities 探测成功时为真实布尔值,
|
||||||
|
* 供前端上传入口拒绝不支持图片的本地语言模型。
|
||||||
|
*/
|
||||||
|
supportsVision?: boolean;
|
||||||
/** 模型描述 */
|
/** 模型描述 */
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ import type { MetonaMessage } from '../../harness/types';
|
|||||||
function makeEngineMock(overrides: Record<string, unknown> = {}) {
|
function makeEngineMock(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
runStream: vi.fn().mockResolvedValue({
|
runStream: vi.fn().mockResolvedValue({
|
||||||
finalAnswer: '这是最终回答',
|
// v0.7.3 P1-5: 固化门控要求回答 >= minChars(默认 200)—— mock 回答扩到阈值之上
|
||||||
|
finalAnswer: '这是最终回答' + '补充细节。'.repeat(60),
|
||||||
terminationReason: 'completed',
|
terminationReason: 'completed',
|
||||||
iterations: [
|
iterations: [
|
||||||
{
|
{
|
||||||
@@ -140,6 +141,8 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
|
|||||||
// v0.5.1: abortByParent 返回 taskId[](abortSession 据此清理 SubAgent pending 确认)
|
// v0.5.1: abortByParent 返回 taskId[](abortSession 据此清理 SubAgent pending 确认)
|
||||||
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => []) }),
|
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => []) }),
|
||||||
confirmationHook: { clearPending: vi.fn() },
|
confirmationHook: { clearPending: vi.fn() },
|
||||||
|
// v0.7.3 P4-1: 会话标题生成器接线(sendMessage 完成路径消费)
|
||||||
|
titleGenerator: { maybeGenerateTitle: vi.fn().mockResolvedValue(null) },
|
||||||
reloadAdapter: vi.fn(() => true),
|
reloadAdapter: vi.fn(() => true),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -266,10 +269,13 @@ describe('agent:sendMessage — 成功路径', () => {
|
|||||||
sessionId: 'sess_1',
|
sessionId: 'sess_1',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// 2. 引擎启动(每会话引擎)
|
// 2. 引擎启动(每会话引擎)—— v0.7.3 P1-1: 引擎收到带上下文前置块的消息副本
|
||||||
expect(ctxRaw.agentEngineManager.getEngine).toHaveBeenCalledWith('sess_1');
|
expect(ctxRaw.agentEngineManager.getEngine).toHaveBeenCalledWith('sess_1');
|
||||||
expect(engine.runStream).toHaveBeenCalledWith(
|
expect(engine.runStream).toHaveBeenCalledWith(
|
||||||
VALID_MESSAGE,
|
expect.objectContaining({
|
||||||
|
role: 'user',
|
||||||
|
content: expect.stringContaining(String(VALID_MESSAGE.content)),
|
||||||
|
}),
|
||||||
'sess_1',
|
'sess_1',
|
||||||
[],
|
[],
|
||||||
expect.objectContaining({ roleDefinition: 'role' }),
|
expect.objectContaining({ roleDefinition: 'role' }),
|
||||||
@@ -292,7 +298,10 @@ describe('agent:sendMessage — 成功路径', () => {
|
|||||||
);
|
);
|
||||||
expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled();
|
expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled();
|
||||||
// 7. 输出验证执行
|
// 7. 输出验证执行
|
||||||
expect(ctxRaw.outputValidator.validate).toHaveBeenCalledWith('这是最终回答', expect.anything());
|
expect(ctxRaw.outputValidator.validate).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('这是最终回答'),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
// 8. 摘要评估(异步触发)
|
// 8. 摘要评估(异步触发)
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
expect(ctxRaw.sessionSummaryService.maybeSummarize).toHaveBeenCalledWith('sess_1'),
|
expect(ctxRaw.sessionSummaryService.maybeSummarize).toHaveBeenCalledWith('sess_1'),
|
||||||
@@ -301,7 +310,7 @@ describe('agent:sendMessage — 成功路径', () => {
|
|||||||
await vi.waitFor(() => expect(ctxRaw.memoryConsolidator.consolidate).toHaveBeenCalled());
|
await vi.waitFor(() => expect(ctxRaw.memoryConsolidator.consolidate).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('注入相关记忆到 System Prompt 动态区', async () => {
|
it('注入相关记忆到用户消息上下文前置块(P1-1:system 保持缓存稳定)', async () => {
|
||||||
const { ctx, ctxRaw } = makeCtx({
|
const { ctx, ctxRaw } = makeCtx({
|
||||||
memoryManager: {
|
memoryManager: {
|
||||||
search: vi.fn(() => [
|
search: vi.fn(() => [
|
||||||
@@ -321,9 +330,16 @@ describe('agent:sendMessage — 成功路径', () => {
|
|||||||
|
|
||||||
await handler(null, VALID_MESSAGE, 'sess_1');
|
await handler(null, VALID_MESSAGE, 'sess_1');
|
||||||
expect(ctxRaw.memoryManager.search).toHaveBeenCalled();
|
expect(ctxRaw.memoryManager.search).toHaveBeenCalled();
|
||||||
// 引擎收到的 systemPrompt 应包含记忆块
|
// P1-1: 记忆注入迁移到首条 user 消息前置块(system 跨 run 字节稳定 → 缓存命中)
|
||||||
const prompt = engine_runStreamPrompt(ctxRaw);
|
const prompt = engine_runStreamPrompt(ctxRaw);
|
||||||
expect(prompt.dynamicReminders).toContain('用户偏好深色主题');
|
expect(prompt.dynamicReminders).not.toContain('用户偏好深色主题');
|
||||||
|
const userMessage = engine_runStreamUserMessage(ctxRaw);
|
||||||
|
expect(userMessage.content).toContain('[Contextual information for this message');
|
||||||
|
expect(userMessage.content).toContain('用户偏好深色主题');
|
||||||
|
// DB 持久化仍使用原始干净内容(前置块只存在于引擎副本)
|
||||||
|
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: VALID_MESSAGE.content }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('验证发现 warning 级问题时广播 VALIDATION 流事件', async () => {
|
it('验证发现 warning 级问题时广播 VALIDATION 流事件', async () => {
|
||||||
@@ -406,6 +422,14 @@ describe('agent:abortSession — 中断编排', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 从 runStream 调用参数中提取首条用户消息(P1-1 前置块断言用) */
|
||||||
|
function engine_runStreamUserMessage(ctxRaw: Record<string, unknown>): { content: string } {
|
||||||
|
const engine = (ctxRaw.agentEngineManager as unknown as { getEngine: Mock }).getEngine() as {
|
||||||
|
runStream: Mock;
|
||||||
|
};
|
||||||
|
return engine.runStream.mock.calls[0][0];
|
||||||
|
}
|
||||||
|
|
||||||
/** 从 runStream 调用参数中提取 systemPrompt */
|
/** 从 runStream 调用参数中提取 systemPrompt */
|
||||||
function engine_runStreamPrompt(ctxRaw: Record<string, unknown>): {
|
function engine_runStreamPrompt(ctxRaw: Record<string, unknown>): {
|
||||||
roleDefinition: string;
|
roleDefinition: string;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe('sessions 域 — 参数校验矩阵', () => {
|
|||||||
getMessages: vi.fn(() => []),
|
getMessages: vi.fn(() => []),
|
||||||
pin: vi.fn(() => true),
|
pin: vi.fn(() => true),
|
||||||
archive: vi.fn(() => true),
|
archive: vi.fn(() => true),
|
||||||
deleteMessage: vi.fn(() => true),
|
// v0.7.3 P1-3: sessions:deleteMessage 死通道已删除,service 方法一并移除
|
||||||
clearMessages: vi.fn(),
|
clearMessages: vi.fn(),
|
||||||
truncateMessagesAfter: vi.fn(() => true),
|
truncateMessagesAfter: vi.fn(() => true),
|
||||||
searchMessages: vi.fn(() => []),
|
searchMessages: vi.fn(() => []),
|
||||||
|
|||||||
+94
-48
@@ -19,8 +19,24 @@ import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
|||||||
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
|
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
|
||||||
import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
|
import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
|
||||||
import { OllamaAdapter } from '../harness/adapters/ollama.adapter';
|
import { OllamaAdapter } from '../harness/adapters/ollama.adapter';
|
||||||
|
// v0.7.3 P1-1: 用户上下文前置块(动态内容出 system,保 prompt cache 前缀稳定)
|
||||||
|
import { buildUserContextPrefix, withUserContextPrefix } from '../harness/prompts/user-context';
|
||||||
|
// v0.7.3 P1-5: 记忆固化触发决策(纯函数)
|
||||||
|
import { shouldConsolidate } from '../harness/memory/consolidation-policy';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
/** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */
|
||||||
|
function buildTimezoneLabel(): string {
|
||||||
|
try {
|
||||||
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
||||||
|
const offset = -new Date().getTimezoneOffset() / 60;
|
||||||
|
const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`;
|
||||||
|
return `${tz} (${offsetStr})`;
|
||||||
|
} catch {
|
||||||
|
return 'UTC';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 单会话的 text_delta 节流状态 */
|
/** 单会话的 text_delta 节流状态 */
|
||||||
interface ThrottleState {
|
interface ThrottleState {
|
||||||
buffer: string;
|
buffer: string;
|
||||||
@@ -56,12 +72,16 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
sessionSummaryService,
|
sessionSummaryService,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
confirmationHook,
|
confirmationHook,
|
||||||
|
titleGenerator,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
// ===== 常驻事件管道:text_delta 按会话节流(F8) =====
|
||||||
const throttleStates = new Map<string, ThrottleState>();
|
const throttleStates = new Map<string, ThrottleState>();
|
||||||
const iterationTraces = new Map<string, IterationTrace>();
|
const iterationTraces = new Map<string, IterationTrace>();
|
||||||
|
|
||||||
|
// v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方)
|
||||||
|
const lastConsolidationBySession = new Map<string, number>();
|
||||||
|
|
||||||
const flushThrottle = (sessionId: string): void => {
|
const flushThrottle = (sessionId: string): void => {
|
||||||
const st = throttleStates.get(sessionId);
|
const st = throttleStates.get(sessionId);
|
||||||
if (!st) return;
|
if (!st) return;
|
||||||
@@ -411,59 +431,46 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
|
// 检索与用户消息相关的记忆 + 附件元信息 → 构建用户消息上下文前置块。
|
||||||
|
// v0.7.3 P1-1 根治: 记忆注入与附件提示此前追加进 systemPrompt.dynamicReminders,
|
||||||
|
// 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入
|
||||||
|
// (LLM 语义等价),system prompt 保持跨 run 字节级稳定。
|
||||||
|
let userContextPrefix = '';
|
||||||
try {
|
try {
|
||||||
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
|
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
|
||||||
|
const attachments = (
|
||||||
|
userMessage as MetonaMessage & {
|
||||||
|
attachments?: Array<{ name: string; type: string; truncated?: boolean }>;
|
||||||
|
}
|
||||||
|
).attachments;
|
||||||
|
userContextPrefix = buildUserContextPrefix({
|
||||||
|
now: Date.now(),
|
||||||
|
memories,
|
||||||
|
attachments: Array.isArray(attachments) ? attachments : [],
|
||||||
|
timezoneLabel: buildTimezoneLabel(),
|
||||||
|
});
|
||||||
if (memories.length > 0) {
|
if (memories.length > 0) {
|
||||||
const memorySection = memories
|
log.debug(`[AGENT] Injected ${memories.length} memories into user context prefix`);
|
||||||
.map(
|
|
||||||
(m, i) =>
|
|
||||||
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`,
|
|
||||||
)
|
|
||||||
.join('\n');
|
|
||||||
const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`;
|
|
||||||
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
|
||||||
? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}`
|
|
||||||
: memoryBlock;
|
|
||||||
log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||||||
|
// 记忆检索失败时前置块退化为仅含日期时间(附件提示随之丢失可接受——
|
||||||
|
// 主进程附件提示是辅助语义,附件内容本体仍在消息中)
|
||||||
|
userContextPrefix = buildUserContextPrefix({
|
||||||
|
now: Date.now(),
|
||||||
|
memories: [],
|
||||||
|
attachments: [],
|
||||||
|
timezoneLabel: buildTimezoneLabel(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
|
// 构建发送给引擎的用户消息副本 —— 前置块只存在于该副本:
|
||||||
const attachments = (
|
// DB 持久化(上方 saveMessage 已用原始内容)、前端展示、记忆固化、
|
||||||
userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }
|
// 注入检测均使用原始干净内容,互不污染。
|
||||||
).attachments;
|
const engineUserMessage: MetonaMessage = {
|
||||||
if (Array.isArray(attachments) && attachments.length > 0) {
|
...userMessage,
|
||||||
const attachmentList = attachments
|
content: withUserContextPrefix(userContextPrefix, userMessage.content),
|
||||||
.map((att, i) => {
|
};
|
||||||
const typeLabel =
|
|
||||||
att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
|
|
||||||
// v0.7.2 A5: 文本附件被上传入口截断(512KB 上限)时,明确告知 LLM 内容不完整,
|
|
||||||
// 防止模型把残缺内容当作完整文件事实
|
|
||||||
const truncatedNote =
|
|
||||||
(att as { truncated?: boolean }).truncated === true
|
|
||||||
? ' (TRUNCATED — only the first 512KB is included; the full content is NOT available)'
|
|
||||||
: '';
|
|
||||||
const note =
|
|
||||||
att.type === 'image'
|
|
||||||
? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again'
|
|
||||||
: att.type === 'text'
|
|
||||||
? `content${truncatedNote} already inlined in the user message, do NOT search in workspace or read it again`
|
|
||||||
: 'uploaded directly by user, do NOT search in workspace';
|
|
||||||
return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`;
|
|
||||||
|
|
||||||
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
|
||||||
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
|
|
||||||
: attachmentBlock;
|
|
||||||
|
|
||||||
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 提示注入检测(安全模块)
|
// 提示注入检测(安全模块)
|
||||||
@@ -503,8 +510,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 启动 Agent Loop(P2-10: 每会话独立引擎)
|
// 启动 Agent Loop(P2-10: 每会话独立引擎)
|
||||||
|
// v0.7.3 P1-1: 传入带上下文前置块的消息副本 —— 原始 userMessage 保持干净
|
||||||
const engine = agentEngineManager.getEngine(sessionId);
|
const engine = agentEngineManager.getEngine(sessionId);
|
||||||
const output = await engine.runStream(userMessage, sessionId, history, systemPrompt);
|
const output = await engine.runStream(engineUserMessage, sessionId, history, systemPrompt);
|
||||||
|
|
||||||
// 输出验证(不阻塞响应,仅记录警告)
|
// 输出验证(不阻塞响应,仅记录警告)
|
||||||
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
|
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
|
||||||
@@ -624,11 +632,28 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
workspaceService.updateMemoryTimestamp();
|
workspaceService.updateMemoryTimestamp();
|
||||||
|
|
||||||
// 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md
|
// 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md
|
||||||
// 异步执行,不阻塞主流程返回;失败仅记录日志
|
// v0.7.3 P1-5 节流: 此前每次 run 无条件发起固化 LLM 请求,短寒暄同样触发。
|
||||||
|
// 现按 consolidation-policy 决策(总开关 + 内容门控 + 会话级频率窗口)触发;
|
||||||
|
// 异步执行不阻塞主流程返回;失败仅记录日志。
|
||||||
|
const lastConsolidationAt = lastConsolidationBySession.get(sessionId) ?? 0;
|
||||||
|
const hadSuccessfulToolCall = output.iterations.some((step) =>
|
||||||
|
(step.toolResults ?? []).some((r) => r.success),
|
||||||
|
);
|
||||||
|
const decision = shouldConsolidate({
|
||||||
|
enabled: configService.get<boolean>('memory.consolidationEnabled') !== false,
|
||||||
|
answerChars: output.finalAnswer?.length ?? 0,
|
||||||
|
minChars: configService.get<number>('memory.consolidationMinChars') ?? 200,
|
||||||
|
hadSuccessfulToolCall,
|
||||||
|
lastConsolidationAt,
|
||||||
|
now: Date.now(),
|
||||||
|
intervalMs: configService.get<number>('memory.consolidationIntervalMs') ?? 600_000,
|
||||||
|
});
|
||||||
|
if (decision.consolidate) {
|
||||||
memoryConsolidator
|
memoryConsolidator
|
||||||
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
|
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.appended > 0) {
|
if (result.appended > 0) {
|
||||||
|
lastConsolidationBySession.set(sessionId, Date.now());
|
||||||
log.info(
|
log.info(
|
||||||
`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`,
|
`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`,
|
||||||
);
|
);
|
||||||
@@ -641,6 +666,24 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
log.warn('[AGENT] Memory consolidation failed:', err);
|
log.warn('[AGENT] Memory consolidation failed:', err);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
|
||||||
|
if (output.terminationReason === 'completed') {
|
||||||
|
titleGenerator
|
||||||
|
.maybeGenerateTitle(sessionId, userMessage.content, output.finalAnswer)
|
||||||
|
.then((title) => {
|
||||||
|
if (title) {
|
||||||
|
// 广播重命名结果,前端 Sidebar 实时刷新标题
|
||||||
|
broadcast('config:changed', { key: `session.title.${sessionId}`, value: title });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 静默 —— 标题失败已有 debug 日志 */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TOOL 层:记录会话结束 / TRACE 层:停止录制
|
// TOOL 层:记录会话结束 / TRACE 层:停止录制
|
||||||
auditService.logSessionEnd({
|
auditService.logSessionEnd({
|
||||||
@@ -832,8 +875,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
// v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
|
// v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
|
||||||
confirmationHook.clearPending(sessionId);
|
confirmationHook.clearPending(sessionId);
|
||||||
// v0.5.1: 被中止 SubAgent 的 pending 确认一并拒绝(含 SubAgent 递归派生的孙任务)
|
// v0.5.1: 被中止 SubAgent 的 pending 确认一并拒绝(含 SubAgent 递归派生的孙任务)
|
||||||
|
// v0.7.3 P2-3: 被中止的 SubAgent 是会话终态 —— 用 forgetSession 连决策记忆一并清理
|
||||||
for (const taskId of abortedTaskIds) {
|
for (const taskId of abortedTaskIds) {
|
||||||
confirmationHook.clearPending(taskId);
|
confirmationHook.forgetSession(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOOL 层:记录中断
|
// TOOL 层:记录中断
|
||||||
@@ -880,6 +924,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
});
|
});
|
||||||
subTraces.delete(taskId);
|
subTraces.delete(taskId);
|
||||||
subMeta.delete(taskId);
|
subMeta.delete(taskId);
|
||||||
|
// v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏)
|
||||||
|
confirmationHook.forgetSession(taskId);
|
||||||
};
|
};
|
||||||
|
|
||||||
orchestrator.on(
|
orchestrator.on(
|
||||||
|
|||||||
@@ -34,6 +34,34 @@ export function registerAppHandlers(ctx: IPCContext): void {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)=====
|
||||||
|
ipcMain.handle('app:healthSnapshot', async () => {
|
||||||
|
try {
|
||||||
|
return { success: true, data: ctx.getHealthSnapshot() };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== v0.7.3 P3-3: JSONL 录制文件统计与清理(设置页展示 + 手动清理)=====
|
||||||
|
ipcMain.handle('logs:traceStats', async () => {
|
||||||
|
try {
|
||||||
|
return { success: true, data: ctx.sessionRecorder.getRecordingStats() };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('logs:pruneTraceFiles', async () => {
|
||||||
|
try {
|
||||||
|
const deleted = ctx.sessionRecorder.pruneOldRecordings();
|
||||||
|
log.info(`[LOGS] Manual trace prune: ${deleted} file(s) removed`);
|
||||||
|
return { success: true, data: { deleted } };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('app:openExternal', async (_event, url: unknown) => {
|
ipcMain.handle('app:openExternal', async (_event, url: unknown) => {
|
||||||
// M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议
|
// M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议
|
||||||
if (typeof url !== 'string' || !url) {
|
if (typeof url !== 'string' || !url) {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import type { ConfirmationHook } from '../harness/hooks/confirmation-hook';
|
|||||||
import type { MemoryConsolidator } from '../harness/memory/consolidator';
|
import type { MemoryConsolidator } from '../harness/memory/consolidator';
|
||||||
import type { TaskOrchestrator } from '../harness/orchestration/orchestrator';
|
import type { TaskOrchestrator } from '../harness/orchestration/orchestrator';
|
||||||
import type { SessionSummaryService } from '../services/session-summary.service';
|
import type { SessionSummaryService } from '../services/session-summary.service';
|
||||||
|
import type { TitleGenerator } from '../services/title-generator.service';
|
||||||
|
import type { HealthChecker } from '../utils/slo';
|
||||||
|
import type { SLOMonitor } from '../utils/slo';
|
||||||
|
|
||||||
/** 工具就绪状态(main.ts 在 MCP 初始化完成后更新,tools.ts 的 isReady 查询读取) */
|
/** 工具就绪状态(main.ts 在 MCP 初始化完成后更新,tools.ts 的 isReady 查询读取) */
|
||||||
export interface ToolsReadyRef {
|
export interface ToolsReadyRef {
|
||||||
@@ -30,6 +33,14 @@ export interface ToolsReadyRef {
|
|||||||
toolCount: number;
|
toolCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v0.7.3 P3-4: 健康快照载荷(app:healthSnapshot 返回) */
|
||||||
|
export interface HealthSnapshot {
|
||||||
|
slo: ReturnType<SLOMonitor['getStatus']>;
|
||||||
|
/** 最近一次健康检查报告;null 表示应用启动后尚未执行过检查(周期 60s) */
|
||||||
|
health: Awaited<ReturnType<HealthChecker['check']>> | null;
|
||||||
|
generatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPCContext {
|
export interface IPCContext {
|
||||||
mainWindow: BrowserWindow;
|
mainWindow: BrowserWindow;
|
||||||
sessionService: SessionService;
|
sessionService: SessionService;
|
||||||
@@ -49,6 +60,10 @@ export interface IPCContext {
|
|||||||
memoryConsolidator: MemoryConsolidator;
|
memoryConsolidator: MemoryConsolidator;
|
||||||
orchestrator: TaskOrchestrator;
|
orchestrator: TaskOrchestrator;
|
||||||
sessionSummaryService: SessionSummaryService;
|
sessionSummaryService: SessionSummaryService;
|
||||||
|
/** v0.7.3 P4-1: 会话标题生成器 */
|
||||||
|
titleGenerator: TitleGenerator;
|
||||||
|
/** v0.7.3 P3-4: 健康快照读取器(SLO + 最近健康检查报告) */
|
||||||
|
getHealthSnapshot: () => HealthSnapshot;
|
||||||
toolsReadyRef: ToolsReadyRef;
|
toolsReadyRef: ToolsReadyRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const isValidSessionId = (id: unknown): id is string =>
|
|||||||
typeof id === 'string' && id.length > 0 && id.length <= 200;
|
typeof id === 'string' && id.length > 0 && id.length <= 200;
|
||||||
|
|
||||||
export function registerSessionHandlers(ctx: IPCContext): void {
|
export function registerSessionHandlers(ctx: IPCContext): void {
|
||||||
const { sessionService } = ctx;
|
const { sessionService, confirmationHook } = ctx;
|
||||||
|
|
||||||
ipcMain.handle('sessions:list', async () => {
|
ipcMain.handle('sessions:list', async () => {
|
||||||
return sessionService.list();
|
return sessionService.list();
|
||||||
@@ -34,7 +34,13 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
|||||||
ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => {
|
ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => {
|
||||||
// M-34 修复: 校验 sessionId
|
// M-34 修复: 校验 sessionId
|
||||||
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
|
||||||
return { success: sessionService.delete(sessionId) };
|
const result = { success: sessionService.delete(sessionId) };
|
||||||
|
// v0.7.3 P2-3: 会话删除 = 会话终态 —— 决策记忆/pending 确认一并清理
|
||||||
|
// (防 rememberedDecisions 随会话数累积泄漏)
|
||||||
|
if (result.success) {
|
||||||
|
confirmationHook.forgetSession(sessionId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => {
|
ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => {
|
||||||
@@ -55,11 +61,9 @@ export function registerSessionHandlers(ctx: IPCContext): void {
|
|||||||
return { success: sessionService.archive(sessionId, archived) };
|
return { success: sessionService.archive(sessionId, archived) };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => {
|
// v0.7.3 P1-3: sessions:deleteMessage 已删除 —— 渲染层零调用方(死通道),
|
||||||
if (typeof messageId !== 'string' || !messageId)
|
// 且原实现不回减 sessions.message_count(计数漂移面)。消息删除语义由
|
||||||
return { success: false, error: 'Invalid messageId' };
|
// sessions:truncateAfter(编辑重发/重新生成)与会话删除级联完整覆盖。
|
||||||
return { success: sessionService.deleteMessage(messageId) };
|
|
||||||
});
|
|
||||||
|
|
||||||
// v0.7.2 A1 根治: 语义修正 —— "清空会话"的成功判定是"操作完成"而非"有行被删除"。
|
// v0.7.2 A1 根治: 语义修正 —— "清空会话"的成功判定是"操作完成"而非"有行被删除"。
|
||||||
// 原实现透传 DELETE 影响行数(changes > 0),空会话清空会返回 success:false,
|
// 原实现透传 DELETE 影响行数(changes > 0),空会话清空会返回 success:false,
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
|
|||||||
thinkingEffort: value as 'low' | 'medium' | 'high' | 'max',
|
thinkingEffort: value as 'low' | 'medium' | 'high' | 'max',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
// v0.7.3 P3-1: enableReflection 接线(此前为死配置)— 引擎 REFLECTING 状态开关
|
||||||
|
case 'agent.enableReflection':
|
||||||
|
agentEngineManager.updateConfigAll({ enableReflection: value === true });
|
||||||
|
orchestrator.updateDefaultConfig({ enableReflection: value === true });
|
||||||
|
break;
|
||||||
case 'agent.toolExecutionTimeoutMs':
|
case 'agent.toolExecutionTimeoutMs':
|
||||||
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
|
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
|
||||||
break;
|
break;
|
||||||
@@ -202,5 +207,11 @@ export async function applyConfigSideEffects(
|
|||||||
await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null);
|
await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连)
|
||||||
|
const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect');
|
||||||
|
if (autoReconnectEntry) {
|
||||||
|
ctx.mcpManager.setAutoReconnect(autoReconnectEntry.value !== false);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-36
@@ -32,6 +32,7 @@ import { WindowManager } from './services/window-manager.service';
|
|||||||
import { MCPManager } from './services/mcp-manager.service';
|
import { MCPManager } from './services/mcp-manager.service';
|
||||||
import { SessionSummaryService } from './services/session-summary.service';
|
import { SessionSummaryService } from './services/session-summary.service';
|
||||||
import { AgentEngineManager } from './services/agent-engine-manager.service';
|
import { AgentEngineManager } from './services/agent-engine-manager.service';
|
||||||
|
import { TitleGenerator } from './services/title-generator.service';
|
||||||
import { ContextBuilder } from './harness/prompts/context-builder';
|
import { ContextBuilder } from './harness/prompts/context-builder';
|
||||||
import { MemoryManager } from './harness/memory/manager';
|
import { MemoryManager } from './harness/memory/manager';
|
||||||
import { MemoryConsolidator } from './harness/memory/consolidator';
|
import { MemoryConsolidator } from './harness/memory/consolidator';
|
||||||
@@ -317,6 +318,8 @@ async function initialize(): Promise<void> {
|
|||||||
const traceEnabled = configService.get<boolean>('logging.traceEnabled') !== false;
|
const traceEnabled = configService.get<boolean>('logging.traceEnabled') !== false;
|
||||||
// SessionRecorder 录制总开关(false 时不写 TRACE JSONL 文件)
|
// SessionRecorder 录制总开关(false 时不写 TRACE JSONL 文件)
|
||||||
sessionRecorder.setEnabled(traceEnabled);
|
sessionRecorder.setEnabled(traceEnabled);
|
||||||
|
// v0.7.3 P3-3: 启动期清理旧 JSONL 录制文件(按 mtime 保留最近 200 个)
|
||||||
|
void sessionRecorder.pruneOldRecordings();
|
||||||
|
|
||||||
// ===== v0.2.0: 安全模块(必须在工具注册之前) =====
|
// ===== v0.2.0: 安全模块(必须在工具注册之前) =====
|
||||||
const policyEngine = new PolicyEngine();
|
const policyEngine = new PolicyEngine();
|
||||||
@@ -426,6 +429,9 @@ async function initialize(): Promise<void> {
|
|||||||
| 'max'
|
| 'max'
|
||||||
| null) ?? 'high',
|
| null) ?? 'high',
|
||||||
toolExecutionTimeoutMs: configService.get<number>('agent.toolExecutionTimeoutMs') ?? 120_000,
|
toolExecutionTimeoutMs: configService.get<number>('agent.toolExecutionTimeoutMs') ?? 120_000,
|
||||||
|
// v0.7.3 P3-1 接线: agent.enableReflection 此前为死配置(引擎读取但全链路
|
||||||
|
// 无置 true 路径)。现注入引擎基线配置,REFLECTING 状态由此开关真实驱动。
|
||||||
|
enableReflection: configService.get<boolean>('agent.enableReflection') === true,
|
||||||
// F-8 接通: llm.temperature / llm.maxTokens 此前为死配置(引擎硬编码 0.0/63488)
|
// F-8 接通: llm.temperature / llm.maxTokens 此前为死配置(引擎硬编码 0.0/63488)
|
||||||
temperature: configService.get<number>('llm.temperature') ?? 0.0,
|
temperature: configService.get<number>('llm.temperature') ?? 0.0,
|
||||||
maxTokens: configService.get<number>('llm.maxTokens') ?? 63488,
|
maxTokens: configService.get<number>('llm.maxTokens') ?? 63488,
|
||||||
@@ -490,6 +496,8 @@ async function initialize(): Promise<void> {
|
|||||||
mcpManager.setOnToolsChanged(() => {
|
mcpManager.setOnToolsChanged(() => {
|
||||||
agentEngineManager.setToolsAll(toolRegistry.listTools());
|
agentEngineManager.setToolsAll(toolRegistry.listTools());
|
||||||
});
|
});
|
||||||
|
// v0.7.3 P4-2: MCP 自动重连开关(默认开启;配置变更经 shared.ts applyConfigSideEffects 联动)
|
||||||
|
mcpManager.setAutoReconnect(configService.get<boolean>('mcp.autoReconnect') !== false);
|
||||||
const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 };
|
const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 };
|
||||||
mcpManager
|
mcpManager
|
||||||
.initialize()
|
.initialize()
|
||||||
@@ -542,6 +550,8 @@ async function initialize(): Promise<void> {
|
|||||||
| null) ?? 'high',
|
| null) ?? 'high',
|
||||||
contextLength: ollamaNumCtx ?? undefined,
|
contextLength: ollamaNumCtx ?? undefined,
|
||||||
contextWindow: buildAdapter().getContextWindow(),
|
contextWindow: buildAdapter().getContextWindow(),
|
||||||
|
// v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection
|
||||||
|
enableReflection: configService.get<boolean>('agent.enableReflection') === true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
|
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
|
||||||
@@ -647,6 +657,56 @@ async function initialize(): Promise<void> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== P1-12 + v0.7.3 P3-4: SLO 健康监控接入(提前至窗口创建前,
|
||||||
|
// 使 IPC 侧 getHealthSnapshot 闭包可安全引用 sloMonitor/healthChecker) =====
|
||||||
|
const healthChecker = new HealthChecker(
|
||||||
|
() => db,
|
||||||
|
join(workspaceInfo.path, '.metona', 'agent.db'),
|
||||||
|
);
|
||||||
|
const sloMonitor = new SLOMonitor();
|
||||||
|
agentEngineManager.on('complete', (data: { durationMs: number; terminationReason?: string }) => {
|
||||||
|
sloMonitor.recordRequest(data.durationMs, data.terminationReason === 'completed');
|
||||||
|
});
|
||||||
|
/** 最近一次健康检查报告(供 IPC app:healthSnapshot 展示;初始 null = 尚未检查) */
|
||||||
|
let lastHealthReport: Awaited<ReturnType<HealthChecker['check']>> | null = null;
|
||||||
|
const healthTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
// v0.7.3 P3-1 接线: cleanupExpired 此前零调用方(且 expires_at 无写入方)双重死路。
|
||||||
|
// 现挂入健康检查周期,让过期记忆(episodic_memories.expires_at)真正可回收,
|
||||||
|
// 为后续记忆 TTL 语义预留真实管道;无过期条目时为零开销(一条带索引的 DELETE)。
|
||||||
|
const expired = memoryManager.cleanupExpired();
|
||||||
|
if (expired > 0) {
|
||||||
|
log.info(`[Health] Expired episodic memories purged: ${expired}`);
|
||||||
|
}
|
||||||
|
const report = await healthChecker.check();
|
||||||
|
lastHealthReport = report;
|
||||||
|
if (!report.healthy) {
|
||||||
|
const failed = report.checks
|
||||||
|
.filter((c) => !c.healthy)
|
||||||
|
.map((c) => c.name)
|
||||||
|
.join(', ');
|
||||||
|
log.warn(`[Health] Unhealthy checks: ${failed}`);
|
||||||
|
trayManager?.setStatus('error');
|
||||||
|
} else {
|
||||||
|
// 恢复 idle(运行中状态会被后续 stateChange 覆盖)
|
||||||
|
trayManager?.setStatus('idle');
|
||||||
|
}
|
||||||
|
const slo = sloMonitor.getStatus();
|
||||||
|
if (slo.violated) {
|
||||||
|
log.warn(
|
||||||
|
`[SLO] Burn rate ${slo.burnRate.toFixed(2)} exceeds budget (errorRate=${(slo.errorRate * 100).toFixed(1)}%, ` +
|
||||||
|
`P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('[Health] Check failed:', err);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
healthTimer.unref?.();
|
||||||
|
|
||||||
|
// ===== v0.7.3 P4-1: 会话标题生成器(首轮 run 完成后 LLM 生成精炼标题) =====
|
||||||
|
const titleGenerator = new TitleGenerator(() => agentEngineManager.getAdapter(), sessionService);
|
||||||
|
|
||||||
// ===== 窗口管理 =====
|
// ===== 窗口管理 =====
|
||||||
windowManager = new WindowManager();
|
windowManager = new WindowManager();
|
||||||
// 局部 const 捕获:闭包内 TS 无法对模块级可空变量做流收窄
|
// 局部 const 捕获:闭包内 TS 无法对模块级可空变量做流收窄
|
||||||
@@ -682,6 +742,12 @@ async function initialize(): Promise<void> {
|
|||||||
memoryConsolidator,
|
memoryConsolidator,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
sessionSummaryService,
|
sessionSummaryService,
|
||||||
|
titleGenerator,
|
||||||
|
getHealthSnapshot: () => ({
|
||||||
|
slo: sloMonitor.getStatus(),
|
||||||
|
health: lastHealthReport,
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
}),
|
||||||
toolsReadyRef,
|
toolsReadyRef,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -733,42 +799,6 @@ async function initialize(): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== P1-12: SLO 健康监控接入(原为死代码,现真实运行) =====
|
|
||||||
const healthChecker = new HealthChecker(
|
|
||||||
() => db,
|
|
||||||
join(workspaceInfo.path, '.metona', 'agent.db'),
|
|
||||||
);
|
|
||||||
const sloMonitor = new SLOMonitor();
|
|
||||||
agentEngineManager.on('complete', (data: { durationMs: number; terminationReason?: string }) => {
|
|
||||||
sloMonitor.recordRequest(data.durationMs, data.terminationReason === 'completed');
|
|
||||||
});
|
|
||||||
const healthTimer = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const report = await healthChecker.check();
|
|
||||||
if (!report.healthy) {
|
|
||||||
const failed = report.checks
|
|
||||||
.filter((c) => !c.healthy)
|
|
||||||
.map((c) => c.name)
|
|
||||||
.join(', ');
|
|
||||||
log.warn(`[Health] Unhealthy checks: ${failed}`);
|
|
||||||
trayManager?.setStatus('error');
|
|
||||||
} else {
|
|
||||||
// 恢复 idle(运行中状态会被后续 stateChange 覆盖)
|
|
||||||
trayManager?.setStatus('idle');
|
|
||||||
}
|
|
||||||
const slo = sloMonitor.getStatus();
|
|
||||||
if (slo.violated) {
|
|
||||||
log.warn(
|
|
||||||
`[SLO] Burn rate ${slo.burnRate.toFixed(2)} exceeds budget (errorRate=${(slo.errorRate * 100).toFixed(1)}%, ` +
|
|
||||||
`P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log.warn('[Health] Check failed:', err);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
healthTimer.unref?.();
|
|
||||||
|
|
||||||
// ===== 应用生命周期 =====
|
// ===== 应用生命周期 =====
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
// macOS: 保持应用运行(托盘模式)
|
// macOS: 保持应用运行(托盘模式)
|
||||||
|
|||||||
+46
-1
@@ -57,7 +57,9 @@ const metonaAPI = {
|
|||||||
ipcRenderer.invoke('sessions:pin', sessionId, pinned),
|
ipcRenderer.invoke('sessions:pin', sessionId, pinned),
|
||||||
archive: (sessionId: string, archived: boolean) =>
|
archive: (sessionId: string, archived: boolean) =>
|
||||||
ipcRenderer.invoke('sessions:archive', sessionId, archived),
|
ipcRenderer.invoke('sessions:archive', sessionId, archived),
|
||||||
deleteMessage: (messageId: string) => ipcRenderer.invoke('sessions:deleteMessage', messageId),
|
// v0.7.3 P1-3: sessions:deleteMessage 死通道已删除 —— 渲染层零调用方,
|
||||||
|
// 且其实现不回减 message_count(计数漂移面)。消息删除语义由
|
||||||
|
// sessions:truncateAfter(编辑重发/重新生成/会话删除级联)完整覆盖。
|
||||||
clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId),
|
clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId),
|
||||||
/** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
|
/** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
|
||||||
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
|
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
|
||||||
@@ -232,6 +234,49 @@ const metonaAPI = {
|
|||||||
// #49 修复: 前端错误上报通道(主进程可注册 'error:report' handler 记录到 electron-log/审计日志)
|
// #49 修复: 前端错误上报通道(主进程可注册 'error:report' handler 记录到 electron-log/审计日志)
|
||||||
// 使用 send(单向)而非 invoke,即使主进程未注册 handler 也不会 reject
|
// 使用 send(单向)而非 invoke,即使主进程未注册 handler 也不会 reject
|
||||||
reportError: (payload: unknown) => ipcRenderer.send('error:report', payload),
|
reportError: (payload: unknown) => ipcRenderer.send('error:report', payload),
|
||||||
|
// v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)
|
||||||
|
// 类型以内联形态声明(本文件在 tsconfig.node 域,不引用渲染端 global.d.ts)
|
||||||
|
getHealthSnapshot: () =>
|
||||||
|
ipcRenderer.invoke('app:healthSnapshot') as Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: {
|
||||||
|
slo: {
|
||||||
|
errorRate: number;
|
||||||
|
throughput: number;
|
||||||
|
avgLatencyMs: number;
|
||||||
|
percentiles: Record<string, number>;
|
||||||
|
burnRate: number;
|
||||||
|
target: number;
|
||||||
|
violated: boolean;
|
||||||
|
totalRequests: number;
|
||||||
|
errorRequests: number;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
health: {
|
||||||
|
healthy: boolean;
|
||||||
|
checks: Array<{ name: string; healthy: boolean; latencyMs?: number; error?: string }>;
|
||||||
|
timestamp: string;
|
||||||
|
} | null;
|
||||||
|
generatedAt: number;
|
||||||
|
};
|
||||||
|
}>,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== v0.7.3 P3-3: JSONL 录制文件生命周期(设置页展示/清理) =====
|
||||||
|
logs: {
|
||||||
|
traceStats: () =>
|
||||||
|
ipcRenderer.invoke('logs:traceStats') as Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: { count: number; totalBytes: number };
|
||||||
|
}>,
|
||||||
|
pruneTraceFiles: () =>
|
||||||
|
ipcRenderer.invoke('logs:pruneTraceFiles') as Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: { deleted: number };
|
||||||
|
}>,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== 工作空间管理 =====
|
// ===== 工作空间管理 =====
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* MCP 自动重连测试(v0.7.3 P4-2)
|
||||||
|
*
|
||||||
|
* R1 nextRetryDelayMs 退避序列(5s/15s/60s,越界钳制);
|
||||||
|
* R2 连接失败 → 进入 reconnecting 并按退避排程;fake timers 推进后真实重试;
|
||||||
|
* R3 重试耗尽(3 次)→ 停留 error 且不再排程;
|
||||||
|
* R4 disconnectServer(用户显式断开)取消重连排程;
|
||||||
|
* R5 setAutoReconnect(false) 取消全部排程且后续失败不再排程;
|
||||||
|
* R6 连接成功清零计数与排程(connectServer 成功路径经 spy 模拟)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('electron-log', () => ({
|
||||||
|
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MCPManager, MAX_RECONNECT_ATTEMPTS, nextRetryDelayMs } from '../mcp-manager.service';
|
||||||
|
import type { MCPServerConfig } from '../mcp-manager.service';
|
||||||
|
import type { ToolRegistry } from '../../harness/tools/registry';
|
||||||
|
|
||||||
|
function makeManager(): MCPManager {
|
||||||
|
const fakeDB = {
|
||||||
|
prepare: () => ({ run: () => ({ changes: 0 }) }),
|
||||||
|
} as unknown as ConstructorParameters<typeof MCPManager>[0] extends () => infer D ? D : never;
|
||||||
|
const registry = {
|
||||||
|
registerMCP: vi.fn(() => true),
|
||||||
|
unregisterMCPTools: vi.fn(),
|
||||||
|
} as unknown as ToolRegistry;
|
||||||
|
return new MCPManager(() => fakeDB, registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAILING_CONFIG: MCPServerConfig = {
|
||||||
|
id: 'mcp_test',
|
||||||
|
name: 'broken-server',
|
||||||
|
// stdio 但缺 command —— connectServer 在构造 transport 前显式抛错(不触网、不 spawn)
|
||||||
|
transport: 'stdio',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('nextRetryDelayMs', () => {
|
||||||
|
it('R1: 退避序列 5s/15s/60s,越界钳制', () => {
|
||||||
|
expect(nextRetryDelayMs(1)).toBe(5_000);
|
||||||
|
expect(nextRetryDelayMs(2)).toBe(15_000);
|
||||||
|
expect(nextRetryDelayMs(3)).toBe(60_000);
|
||||||
|
expect(nextRetryDelayMs(4)).toBe(60_000); // 越界钳制到最后一档
|
||||||
|
expect(nextRetryDelayMs(0)).toBe(5_000); // 下界钳制
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MCPManager 自动重连', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('R2: 连接失败 → reconnecting 状态 + 定时排程;推进后真实重试', async () => {
|
||||||
|
const manager = makeManager();
|
||||||
|
const connectSpy = vi.spyOn(manager, 'connectServer');
|
||||||
|
|
||||||
|
await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow();
|
||||||
|
// 第一次失败后:计数 1,状态 reconnecting,已排程
|
||||||
|
expect(manager.getReconnectInfo('broken-server')).toMatchObject({
|
||||||
|
attempts: 1,
|
||||||
|
scheduled: true,
|
||||||
|
});
|
||||||
|
expect(manager.getServerState('broken-server')?.status).toBe('reconnecting');
|
||||||
|
|
||||||
|
// 推进 5s → 第二次真实重试(同样失败)→ 计数 2
|
||||||
|
connectSpy.mockClear();
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(connectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(manager.getReconnectInfo('broken-server')).toMatchObject({
|
||||||
|
attempts: 2,
|
||||||
|
scheduled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('R3: 重试耗尽(3 次)→ 停留 error 且不再排程', async () => {
|
||||||
|
const manager = makeManager();
|
||||||
|
await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow();
|
||||||
|
|
||||||
|
// 第 1 次失败后 + 3 次定时重试(每次再失败)
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(15_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
|
||||||
|
const info = manager.getReconnectInfo('broken-server');
|
||||||
|
// 耗尽后:排程清空(计数清零后 getReconnectInfo 返回 null 或 scheduled=false)、状态停留 error
|
||||||
|
expect(info?.scheduled ?? false).toBe(false);
|
||||||
|
expect(manager.getServerState('broken-server')?.status).toBe('error');
|
||||||
|
// 再推进也不再有动作
|
||||||
|
const spy = vi.spyOn(manager, 'connectServer');
|
||||||
|
await vi.advanceTimersByTimeAsync(600_000);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('R4: disconnectServer(用户显式断开)取消重连排程', async () => {
|
||||||
|
const manager = makeManager();
|
||||||
|
await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow();
|
||||||
|
expect(manager.getReconnectInfo('broken-server')?.scheduled).toBe(true);
|
||||||
|
|
||||||
|
await manager.disconnectServer('broken-server');
|
||||||
|
// 簿记清空 → getReconnectInfo 返回 null(等价于无排程)
|
||||||
|
expect(manager.getReconnectInfo('broken-server')?.scheduled ?? false).toBe(false);
|
||||||
|
// 状态回落 disconnected
|
||||||
|
expect(manager.getServerState('broken-server')?.status).toBe('disconnected');
|
||||||
|
// 推进时间不再触发重试
|
||||||
|
const spy = vi.spyOn(manager, 'connectServer');
|
||||||
|
await vi.advanceTimersByTimeAsync(600_000);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('R5: setAutoReconnect(false) 取消全部排程,后续失败不再排程', async () => {
|
||||||
|
const manager = makeManager();
|
||||||
|
await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow();
|
||||||
|
expect(manager.getReconnectInfo('broken-server')?.scheduled).toBe(true);
|
||||||
|
|
||||||
|
manager.setAutoReconnect(false);
|
||||||
|
expect(manager.getReconnectInfo('broken-server')?.scheduled ?? false).toBe(false);
|
||||||
|
|
||||||
|
// 关闭后再次失败 → 不排程(簿记清空 → getReconnectInfo 返回 null)
|
||||||
|
await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow();
|
||||||
|
expect(manager.getReconnectInfo('broken-server')?.scheduled ?? false).toBe(false);
|
||||||
|
expect(manager.getServerState('broken-server')?.status).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('R6: setAutoReconnect(true)(默认)开启语义 —— 失败必排程', async () => {
|
||||||
|
const manager = makeManager();
|
||||||
|
expect(manager.getReconnectInfo('x')).toBeNull();
|
||||||
|
await expect(manager.connectServer(FAILING_CONFIG)).rejects.toThrow();
|
||||||
|
expect(manager.getReconnectInfo('broken-server')?.scheduled).toBe(true);
|
||||||
|
expect(MAX_RECONNECT_ATTEMPTS).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -249,3 +249,61 @@ describe('SessionRecorder — 总开关与缓冲上限', () => {
|
|||||||
expect(recorder.getFilePath('s1')).toBeNull();
|
expect(recorder.getFilePath('s1')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== v0.7.3 P3-3: JSONL 录制文件生命周期(stats + prune) =====
|
||||||
|
|
||||||
|
describe('SessionRecorder — 录制文件统计与清理(P3-3)', () => {
|
||||||
|
const writeRecording = async (name: string, ageHours: number): Promise<void> => {
|
||||||
|
const fs = await import('node:fs');
|
||||||
|
const logsDir = join(wsRoot, 'logs');
|
||||||
|
if (!existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
const filePath = join(logsDir, name);
|
||||||
|
fs.writeFileSync(filePath, '{"event":"session_start"}\n', 'utf-8');
|
||||||
|
const mtime = new Date(Date.now() - ageHours * 3600_000);
|
||||||
|
fs.utimesSync(filePath, mtime, mtime);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('getRecordingStats:统计 count 与 totalBytes;目录不存在返回零值', async () => {
|
||||||
|
expect(recorder.getRecordingStats()).toEqual({ count: 0, totalBytes: 0 });
|
||||||
|
|
||||||
|
await writeRecording('session_s1_2026-01-01.jsonl', 1);
|
||||||
|
await writeRecording('session_s2_2026-01-02.jsonl', 2);
|
||||||
|
// 非 session_*.jsonl 命名的文件不受治理(用户自放文件)
|
||||||
|
await writeRecording('user-notes.txt', 3);
|
||||||
|
|
||||||
|
const stats = recorder.getRecordingStats();
|
||||||
|
expect(stats.count).toBe(2);
|
||||||
|
expect(stats.totalBytes).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pruneOldRecordings:按 mtime 保留最近 N 个,删除其余', async () => {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
await writeRecording(`session_s${i}_f.jsonl`, i + 1); // s0 最旧
|
||||||
|
}
|
||||||
|
const deleted = recorder.pruneOldRecordings(3);
|
||||||
|
expect(deleted).toBe(3);
|
||||||
|
|
||||||
|
const remaining = (readdirSync(join(wsRoot, 'logs')) as string[]).filter((f) =>
|
||||||
|
f.startsWith('session_'),
|
||||||
|
);
|
||||||
|
expect(remaining).toHaveLength(3);
|
||||||
|
// 保留的应是最新的 3 个(s0/s1/s2 —— 年龄 1/2/3 小时,s0 最新)
|
||||||
|
for (const keep of ['session_s0_f.jsonl', 'session_s1_f.jsonl', 'session_s2_f.jsonl']) {
|
||||||
|
expect(remaining).toContain(keep);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pruneOldRecordings:仅治理 session_*.jsonl 命名,用户文件不受影响', async () => {
|
||||||
|
await writeRecording('session_a.jsonl', 100);
|
||||||
|
await writeRecording('my-data.jsonl', 100);
|
||||||
|
const deleted = recorder.pruneOldRecordings(0);
|
||||||
|
expect(deleted).toBe(1);
|
||||||
|
expect(existsSync(join(wsRoot, 'logs', 'my-data.jsonl'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pruneOldRecordings:未超限返回 0 且不删除任何文件', async () => {
|
||||||
|
await writeRecording('session_x.jsonl', 1);
|
||||||
|
expect(recorder.pruneOldRecordings(200)).toBe(0);
|
||||||
|
expect(existsSync(join(wsRoot, 'logs', 'session_x.jsonl'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* truncateMessagesAfter × TRACE metadata 联动测试(v0.7.3 P1-2)
|
||||||
|
*
|
||||||
|
* 根治契约:编辑重发/重新生成截断消息时,sessions.metadata 中的 traceSteps
|
||||||
|
* 必须按 startedAt <= 锚点消息 created_at 同步过滤,否则 Trace 面板出现
|
||||||
|
* "幽灵步骤"(v0.7.2 A1 只修了 /clear 路径的 metadata 残留)。
|
||||||
|
*
|
||||||
|
* 运行要求:better-sqlite3 为 Electron ABI 构建,需 test:electron 模式执行;
|
||||||
|
* 系统 Node 下自动跳过。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('electron-log', () => ({
|
||||||
|
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
let dbAvailable = true;
|
||||||
|
let Database: typeof import('better-sqlite3');
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
Database = require('better-sqlite3');
|
||||||
|
const probe = new Database(':memory:');
|
||||||
|
probe.close();
|
||||||
|
} catch {
|
||||||
|
dbAvailable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!dbAvailable)('truncateMessagesAfter × TRACE metadata 过滤(P1-2)', () => {
|
||||||
|
let db: import('better-sqlite3').Database;
|
||||||
|
// 测试域宽松类型:SessionService 依赖 Electron ABI 的 better-sqlite3 实例,
|
||||||
|
// 直接持有真实实例即可(同 session-summary.test.ts 的既有写法)
|
||||||
|
// eslint(next-line 无需禁用:测试文件未启用 no-explicit-any)
|
||||||
|
let sessionService: any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { SessionService } = await import('../session.service');
|
||||||
|
db = new Database(':memory:');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT DEFAULT '新会话',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
message_count INTEGER DEFAULT 0,
|
||||||
|
pinned INTEGER DEFAULT 0,
|
||||||
|
archived INTEGER DEFAULT 0,
|
||||||
|
metadata TEXT DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
reasoning_content TEXT,
|
||||||
|
tool_calls TEXT,
|
||||||
|
tool_result TEXT,
|
||||||
|
attachments TEXT,
|
||||||
|
iteration INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE session_summaries (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
summarized_until_rowid INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES (?, ?, ?, 0)',
|
||||||
|
).run('s_trace', Date.now(), Date.now());
|
||||||
|
sessionService = new SessionService(() => db);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
try {
|
||||||
|
db?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertMessage = (id: string, content: string, createdAt: number): number => {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, 'user', ?, ?)`,
|
||||||
|
).run(id, 's_trace', content, createdAt);
|
||||||
|
const row = db.prepare('SELECT rowid AS rid FROM messages WHERE id = ?').get(id) as {
|
||||||
|
rid: number;
|
||||||
|
};
|
||||||
|
return row.rid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMetadata = (steps: Array<{ runId?: string; startedAt: number }>): void => {
|
||||||
|
db.prepare('UPDATE sessions SET metadata = ? WHERE id = ?').run(
|
||||||
|
JSON.stringify({ traceSteps: steps, tokenUsage: { totalTokens: 10 } }),
|
||||||
|
's_trace',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMetadataSteps = (): Array<{ runId?: string; startedAt: number }> => {
|
||||||
|
const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get('s_trace') as {
|
||||||
|
metadata: string;
|
||||||
|
};
|
||||||
|
const parsed = JSON.parse(row.metadata) as {
|
||||||
|
traceSteps?: Array<{ runId?: string; startedAt: number }>;
|
||||||
|
};
|
||||||
|
return parsed.traceSteps ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
it('截断后:晚于锚点消息的 traceSteps 被过滤,早于/等于的保留', () => {
|
||||||
|
const anchorTs = 1_000_000;
|
||||||
|
const anchorId = 'm_anchor';
|
||||||
|
insertMessage('m_before', '更早的消息', anchorTs - 10_000);
|
||||||
|
insertMessage(anchorId, '锚点消息', anchorTs);
|
||||||
|
insertMessage('m_after', '锚点后的消息', anchorTs + 10_000);
|
||||||
|
|
||||||
|
saveMetadata([
|
||||||
|
{ runId: 'r_before', startedAt: anchorTs - 9_000 },
|
||||||
|
{ runId: 'r_anchor', startedAt: anchorTs }, // 锚点触发的 run(将被截)
|
||||||
|
{ runId: 'r_after', startedAt: anchorTs + 11_000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const truncated = sessionService.truncateMessagesAfter('s_trace', anchorId, true);
|
||||||
|
expect(truncated).toBe(true);
|
||||||
|
|
||||||
|
const kept = loadMetadataSteps();
|
||||||
|
expect(kept.map((s) => s.runId)).toEqual(['r_before']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('metadata 缺失/损坏时不阻断截断主流程', () => {
|
||||||
|
db.prepare("UPDATE sessions SET metadata = 'not-json' WHERE id = ?").run('s_trace');
|
||||||
|
const anchorTs = 2_000_000;
|
||||||
|
const anchorId = 'm2';
|
||||||
|
insertMessage(anchorId, '锚点消息 2', anchorTs);
|
||||||
|
insertMessage('m2_after', '锚点 2 之后的消息', anchorTs + 1000);
|
||||||
|
|
||||||
|
expect(() => sessionService.truncateMessagesAfter('s_trace', anchorId, true)).not.toThrow();
|
||||||
|
expect(sessionService.truncateMessagesAfter('s_trace', anchorId, true)).toBe(false); // 已被上一句删过,无行可删
|
||||||
|
});
|
||||||
|
|
||||||
|
it('锚点不存在时返回 false 且不触碰 metadata', () => {
|
||||||
|
saveMetadata([{ runId: 'r_keep', startedAt: Date.now() }]);
|
||||||
|
expect(sessionService.truncateMessagesAfter('s_trace', 'm_missing', true)).toBe(false);
|
||||||
|
expect(loadMetadataSteps()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* TitleGenerator 测试(v0.7.3 P4-1)
|
||||||
|
*
|
||||||
|
* T1 sanitizeTitle 清洗规则矩阵(围栏/引号/自述前缀/空白/截断/空值);
|
||||||
|
* T2 maybeGenerateTitle 幂等(每会话仅一次);
|
||||||
|
* T3 已有自定义标题的会话不覆盖;
|
||||||
|
* T4 LLM 失败/超时静默回退(不抛错,返回 null);
|
||||||
|
* T5 空输入门控。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('electron-log', () => ({
|
||||||
|
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TitleGenerator, sanitizeTitle } from '../title-generator.service';
|
||||||
|
import type { IMetonaProviderAdapter, MetonaResponse } from '../../harness/types';
|
||||||
|
|
||||||
|
describe('sanitizeTitle', () => {
|
||||||
|
it('T1a: 干净文本原样通过', () => {
|
||||||
|
expect(sanitizeTitle('修复登录超时')).toBe('修复登录超时');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T1b: markdown 围栏与列表标记剥离', () => {
|
||||||
|
expect(sanitizeTitle('```json\n"标题"\n```')).toBe('标题');
|
||||||
|
expect(sanitizeTitle('- 修复登录超时')).toBe('修复登录超时');
|
||||||
|
expect(sanitizeTitle('# 修复登录超时')).toBe('修复登录超时');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T1c: 成对包裹引号剥离', () => {
|
||||||
|
expect(sanitizeTitle('"修复登录超时"')).toBe('修复登录超时');
|
||||||
|
expect(sanitizeTitle('“修复登录超时”')).toBe('修复登录超时');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T1d: 自述前缀剥离(标题:/Title:)', () => {
|
||||||
|
expect(sanitizeTitle('标题: 修复登录超时')).toBe('修复登录超时');
|
||||||
|
expect(sanitizeTitle('Title: Fix login timeout')).toBe('Fix login timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T1e: 换行折叠为空格、首尾空白去除', () => {
|
||||||
|
expect(sanitizeTitle('修复\n登录 超时\n')).toBe('修复 登录 超时');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T1f: 超长截断到 maxLen', () => {
|
||||||
|
const out = sanitizeTitle('a'.repeat(100), 40);
|
||||||
|
expect(out?.length).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T1g: 空值/纯符号 → null', () => {
|
||||||
|
expect(sanitizeTitle('')).toBeNull();
|
||||||
|
expect(sanitizeTitle(' ')).toBeNull();
|
||||||
|
expect(sanitizeTitle('"""')).toBeNull();
|
||||||
|
expect(sanitizeTitle('###')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeAdapter(content: string | Error): IMetonaProviderAdapter {
|
||||||
|
return {
|
||||||
|
providerId: 'mock',
|
||||||
|
supportedModels: ['m'],
|
||||||
|
supportsToolCalling: true,
|
||||||
|
supportsThinking: false,
|
||||||
|
getContextWindow: () => 128_000,
|
||||||
|
send: vi.fn(async (): Promise<MetonaResponse> => {
|
||||||
|
if (content instanceof Error) throw content;
|
||||||
|
return {
|
||||||
|
meta: { requestId: 'r', provider: 'mock', model: 'm', latencyMs: 1, timestamp: Date.now() },
|
||||||
|
content,
|
||||||
|
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||||
|
finishReason: 'stop' as never,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
sendStream: vi.fn(),
|
||||||
|
setAbortSignal: vi.fn(),
|
||||||
|
healthCheck: async () => true,
|
||||||
|
} as unknown as IMetonaProviderAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSessionService(existing: Array<{ id: string; title: string }> = []) {
|
||||||
|
// 注意:真实 SessionService.list() 是同步方法(better-sqlite3),
|
||||||
|
// mock 必须保持同步返回,否则 generate 内的 .find() 拿到 Promise
|
||||||
|
return {
|
||||||
|
list: vi.fn(() => existing) as unknown as () => Array<{ id: string; title: string }>,
|
||||||
|
rename: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TitleGenerator.maybeGenerateTitle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T2: 首个 run 生成标题并调用 rename;同会话幂等', async () => {
|
||||||
|
const svc = makeSessionService([{ id: 's1', title: '新会话' }]);
|
||||||
|
const gen = new TitleGenerator(() => makeAdapter('修复登录超时'), svc as never);
|
||||||
|
const title = await gen.maybeGenerateTitle(
|
||||||
|
's1',
|
||||||
|
'帮我看看为什么登录会超时',
|
||||||
|
'已定位为 token 过期…',
|
||||||
|
);
|
||||||
|
expect(title).toBe('修复登录超时');
|
||||||
|
expect(svc.rename).toHaveBeenCalledWith('s1', '修复登录超时');
|
||||||
|
|
||||||
|
// 第二次调用(同会话)幂等 —— 不再调用 LLM
|
||||||
|
const second = await gen.maybeGenerateTitle('s1', '再问一次', '再答一次');
|
||||||
|
expect(second).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T3: 已有自定义标题的会话不覆盖', async () => {
|
||||||
|
const svc = makeSessionService([{ id: 's1', title: '用户手动命名' }]);
|
||||||
|
const adapterSend = vi.fn();
|
||||||
|
const adapter = makeAdapter('不应被使用');
|
||||||
|
(adapter.send as ReturnType<typeof vi.fn>).mockImplementation(async (...args: unknown[]) => {
|
||||||
|
adapterSend(...args);
|
||||||
|
throw new Error('should not be called');
|
||||||
|
});
|
||||||
|
const gen = new TitleGenerator(() => adapter, svc as never);
|
||||||
|
const title = await gen.maybeGenerateTitle('s1', '内容', '回答');
|
||||||
|
expect(title).toBeNull();
|
||||||
|
expect(adapterSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T4: LLM 失败静默回退(返回 null,不抛错),且同会话不重试', async () => {
|
||||||
|
const svc = makeSessionService([{ id: 's1', title: '新会话' }]);
|
||||||
|
const gen = new TitleGenerator(() => makeAdapter(new Error('network down')), svc as never);
|
||||||
|
await expect(gen.maybeGenerateTitle('s1', 'q', 'a')).resolves.toBeNull();
|
||||||
|
await expect(gen.maybeGenerateTitle('s1', 'q2', 'a2')).resolves.toBeNull();
|
||||||
|
expect(svc.rename).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T5: 空 sessionId / 双向空内容 → 不生成', async () => {
|
||||||
|
const svc = makeSessionService();
|
||||||
|
const adapter = makeAdapter('x');
|
||||||
|
const gen = new TitleGenerator(() => adapter, svc as never);
|
||||||
|
expect(await gen.maybeGenerateTitle('', 'q', 'a')).toBeNull();
|
||||||
|
expect(await gen.maybeGenerateTitle('s1', ' ', ' ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('T6: 并发重入复用同一 Promise(不重复调用 LLM)', async () => {
|
||||||
|
const svc = makeSessionService([{ id: 's1', title: '新会话' }]);
|
||||||
|
const gen = new TitleGenerator(() => makeAdapter('并发标题'), svc as never);
|
||||||
|
// 使用真实定时器场景下并发触发
|
||||||
|
vi.useRealTimers();
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
gen.maybeGenerateTitle('s2', 'q', 'a'),
|
||||||
|
gen.maybeGenerateTitle('s2', 'q', 'a'),
|
||||||
|
]);
|
||||||
|
expect(a).toBe('并发标题');
|
||||||
|
expect(b).toBe('并发标题');
|
||||||
|
// rename 仅一次(并发重入复用)
|
||||||
|
expect(svc.rename).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,6 +70,14 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
|
|||||||
// F-8 接通: promptInjectionDefense 由 main.ts(SecurityScanHook)与 ipc/agent.ts(用户消息检测)消费
|
// F-8 接通: promptInjectionDefense 由 main.ts(SecurityScanHook)与 ipc/agent.ts(用户消息检测)消费
|
||||||
{ key: 'security.promptInjectionDefense', value: true, category: 'security' },
|
{ key: 'security.promptInjectionDefense', value: true, category: 'security' },
|
||||||
|
|
||||||
|
// v0.7.3 P1-5: 记忆固化节流(consolidation-policy 消费)
|
||||||
|
{ key: 'memory.consolidationEnabled', value: true, category: 'memory' },
|
||||||
|
{ key: 'memory.consolidationMinChars', value: 200, category: 'memory' },
|
||||||
|
{ key: 'memory.consolidationIntervalMs', value: 600000, category: 'memory' },
|
||||||
|
|
||||||
|
// v0.7.3 P4-2: MCP 自动重连开关(mcp-manager.service 消费;断连后指数退避重试)
|
||||||
|
{ key: 'mcp.autoReconnect', value: true, category: 'mcp' },
|
||||||
|
|
||||||
// UI 配置
|
// UI 配置
|
||||||
// F-8 清理: 移除死配置 ui.fontSize / ui.animationMode(无消费者;主题走 localStorage)
|
// F-8 清理: 移除死配置 ui.fontSize / ui.animationMode(无消费者;主题走 localStorage)
|
||||||
{ key: 'ui.theme', value: 'auto', category: 'ui' },
|
{ key: 'ui.theme', value: 'auto', category: 'ui' },
|
||||||
@@ -106,7 +114,6 @@ export class DatabaseService {
|
|||||||
*/
|
*/
|
||||||
static readonly SCHEMA_VERSION = 1;
|
static readonly SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
|
||||||
constructor(workspacePath?: string) {
|
constructor(workspacePath?: string) {
|
||||||
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
|
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
|
||||||
const metonaDir = join(baseDir, '.metona');
|
const metonaDir = join(baseDir, '.metona');
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ import { join } from 'path';
|
|||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
import { CONFIG_DEFAULTS } from './database.service';
|
import { CONFIG_DEFAULTS } from './database.service';
|
||||||
import { decryptConfigValue, encryptConfigValue, isSensitiveConfigKey } from '../utils/secure-config';
|
import {
|
||||||
|
decryptConfigValue,
|
||||||
|
encryptConfigValue,
|
||||||
|
isSensitiveConfigKey,
|
||||||
|
} from '../utils/secure-config';
|
||||||
|
|
||||||
/** 全局配置文件路径(userData 下,与工作空间无关) */
|
/** 全局配置文件路径(userData 下,与工作空间无关) */
|
||||||
const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
|
const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
|
||||||
@@ -43,6 +47,8 @@ const GLOBAL_KEY_PREFIXES = [
|
|||||||
'openai.',
|
'openai.',
|
||||||
'anthropic.',
|
'anthropic.',
|
||||||
'onboarding.',
|
'onboarding.',
|
||||||
|
// v0.7.3 P1-5: 记忆固化节流(机器级策略,跨工作空间一致)
|
||||||
|
'memory.',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 判断 key 是否属于全局配置 */
|
/** 判断 key 是否属于全局配置 */
|
||||||
@@ -104,7 +110,9 @@ export class GlobalConfigService {
|
|||||||
if (existsSync(GLOBAL_CONFIG_FILE)) {
|
if (existsSync(GLOBAL_CONFIG_FILE)) {
|
||||||
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
|
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
|
||||||
this.data = JSON.parse(raw) as GlobalConfigData;
|
this.data = JSON.parse(raw) as GlobalConfigData;
|
||||||
log.info(`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`);
|
log.info(
|
||||||
|
`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 确保父目录存在
|
// 确保父目录存在
|
||||||
const dir = join(GLOBAL_CONFIG_FILE, '..');
|
const dir = join(GLOBAL_CONFIG_FILE, '..');
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import type { ToolRegistry } from '../harness/tools/registry';
|
|||||||
import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool';
|
import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../harness/types';
|
import type { MetonaToolDef } from '../harness/types';
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types';
|
||||||
|
// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源(与 run_command 共用)
|
||||||
|
import { buildSafeChildEnv } from '../utils/safe-env';
|
||||||
|
|
||||||
// v0.3.0 修复: 安全解析 JSON args,防止数据库中存储了非法 JSON 导致初始化崩溃
|
// v0.3.0 修复: 安全解析 JSON args,防止数据库中存储了非法 JSON 导致初始化崩溃
|
||||||
/** @visibleForTesting 纯函数,供安全表测直接断言 */
|
/** @visibleForTesting 纯函数,供安全表测直接断言 */
|
||||||
@@ -97,44 +99,14 @@ export function validateMcpCommand(command: string, args: string[]): void {
|
|||||||
/**
|
/**
|
||||||
* #6 修复 + 审查修复: 构建安全的子进程环境变量
|
* #6 修复 + 审查修复: 构建安全的子进程环境变量
|
||||||
*
|
*
|
||||||
* 审查修复: 原白名单方案过于激进,剥离了 MCP Server 运行所需的 npm_config_*、代理变量等,
|
* v0.7.3 P3-2: 实现收敛到 utils/safe-env.ts(buildSafeChildEnv)——与
|
||||||
* 导致 MCP Server 无法启动。改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
|
* run_command 共用同一黑名单(历史双实现已漂移)。MCP 侧无运行时差异注入。
|
||||||
*
|
* 注意: GITHUB_TOKEN / SLACK_BOT_TOKEN 等含 _TOKEN 后缀的变量会被过滤;
|
||||||
* 注意: GITHUB_TOKEN / SLACK_BOT_TOKEN 等含 _TOKEN 后缀的变量也会被过滤。
|
|
||||||
* 如果 MCP Server 需要这些凭证,应通过 MCP Server 配置文件传递,而非环境变量。
|
* 如果 MCP Server 需要这些凭证,应通过 MCP Server 配置文件传递,而非环境变量。
|
||||||
*/
|
*/
|
||||||
/** @visibleForTesting 纯函数,供安全表测直接断言 */
|
/** @visibleForTesting 纯函数,供安全表测直接断言 */
|
||||||
export function buildSafeEnv(): Record<string, string> {
|
export function buildSafeEnv(): Record<string, string> {
|
||||||
// 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程
|
return buildSafeChildEnv();
|
||||||
const SENSITIVE_SUFFIXES = [
|
|
||||||
'_API_KEY',
|
|
||||||
'_TOKEN',
|
|
||||||
'_SECRET',
|
|
||||||
'_PASSWORD',
|
|
||||||
'_PASSWD',
|
|
||||||
'_CREDENTIAL',
|
|
||||||
'_CREDENTIALS',
|
|
||||||
'_PRIVATE_KEY',
|
|
||||||
];
|
|
||||||
// 敏感变量名黑名单(精确匹配)
|
|
||||||
const SENSITIVE_KEYS = new Set([
|
|
||||||
'DEEPSEEK_API_KEY',
|
|
||||||
'AGNES_API_KEY',
|
|
||||||
'MIMO_API_KEY',
|
|
||||||
'GITEA_PASSWORD',
|
|
||||||
'DATABASE_PASSWORD',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [key, val] of Object.entries(process.env)) {
|
|
||||||
if (!val) continue;
|
|
||||||
// 跳过敏感变量名
|
|
||||||
if (SENSITIVE_KEYS.has(key)) continue;
|
|
||||||
// 跳过敏感后缀变量
|
|
||||||
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
|
|
||||||
env[key] = val;
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,7 +143,29 @@ export function safeParseHeaders(
|
|||||||
|
|
||||||
// ===== 类型定义 =====
|
// ===== 类型定义 =====
|
||||||
|
|
||||||
export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
export type MCPServerStatus =
|
||||||
|
| 'connecting'
|
||||||
|
| 'connected'
|
||||||
|
| 'disconnected'
|
||||||
|
| 'error'
|
||||||
|
| 'reconnecting';
|
||||||
|
|
||||||
|
// ===== v0.7.3 P4-2: 自动重连策略常量 =====
|
||||||
|
|
||||||
|
/** 最大自动重连次数(超过后停留 error 态,等待用户手动 toggle) */
|
||||||
|
export const MAX_RECONNECT_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重连退避间隔(毫秒):5s / 15s / 60s。
|
||||||
|
* 纯函数 nextRetryDelayMs 消费,表测锁定(vitest fake timers 场景)。
|
||||||
|
*/
|
||||||
|
export const RECONNECT_DELAYS_MS = [5_000, 15_000, 60_000] as const;
|
||||||
|
|
||||||
|
/** @visibleForTesting 纯函数 —— 第 attempt 次(1-based)重试前的等待毫秒数 */
|
||||||
|
export function nextRetryDelayMs(attempt: number): number {
|
||||||
|
const idx = Math.min(Math.max(attempt, 1), RECONNECT_DELAYS_MS.length) - 1;
|
||||||
|
return RECONNECT_DELAYS_MS[idx];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MCPServerConfig {
|
export interface MCPServerConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -261,6 +255,17 @@ class MCPToolAdapter implements IMetonaTool {
|
|||||||
|
|
||||||
export class MCPManager {
|
export class MCPManager {
|
||||||
private servers = new Map<string, MCPServerState>();
|
private servers = new Map<string, MCPServerState>();
|
||||||
|
|
||||||
|
// ===== v0.7.3 P4-2: 自动重连状态 =====
|
||||||
|
/** 总开关(mcp.autoReconnect,默认 true;main.ts 启动时注入,配置变更联动) */
|
||||||
|
private autoReconnect = true;
|
||||||
|
/** 各 server 的重连定时器(disconnect/shutdown 时必须清理) */
|
||||||
|
private reconnectTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
/** 各 server 已尝试的自动重连次数(成功连接后清零) */
|
||||||
|
private reconnectAttempts = new Map<string, number>();
|
||||||
|
/** 待重连的配置快照(重连时从原配置重建连接,避免读 DB 中间态) */
|
||||||
|
private reconnectConfigs = new Map<string, MCPServerConfig>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具集合变更回调(v0.5.3)
|
* 工具集合变更回调(v0.5.3)
|
||||||
*
|
*
|
||||||
@@ -277,6 +282,93 @@ export class MCPManager {
|
|||||||
private toolRegistry: ToolRegistry,
|
private toolRegistry: ToolRegistry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// ===== v0.7.3 P4-2: 自动重连 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置自动重连开关(main.ts 启动时按 mcp.autoReconnect 注入;
|
||||||
|
* 配置变更经 shared.ts applyConfigSideEffects 联动)。
|
||||||
|
* 关闭时立即取消所有已排程的重连并清零计数(用户显式意图优先)。
|
||||||
|
*/
|
||||||
|
setAutoReconnect(enabled: boolean): void {
|
||||||
|
this.autoReconnect = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
this.cancelAllReconnects();
|
||||||
|
}
|
||||||
|
log.debug(`[MCPManager] autoReconnect = ${enabled}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询某 server 的重连状态(测试与诊断用) */
|
||||||
|
getReconnectInfo(name: string): { attempts: number; scheduled: boolean } | null {
|
||||||
|
const attempts = this.reconnectAttempts.get(name);
|
||||||
|
const scheduled = this.reconnectTimers.has(name);
|
||||||
|
if (attempts === undefined && !scheduled) return null;
|
||||||
|
return { attempts: attempts ?? 0, scheduled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消某 server 的重连排程(用户显式断开/移除时调用) */
|
||||||
|
private cancelReconnect(name: string): void {
|
||||||
|
const timer = this.reconnectTimers.get(name);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.reconnectTimers.delete(name);
|
||||||
|
}
|
||||||
|
this.reconnectAttempts.delete(name);
|
||||||
|
this.reconnectConfigs.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消全部重连排程(shutdown / 开关关闭时调用) */
|
||||||
|
private cancelAllReconnects(): void {
|
||||||
|
for (const timer of this.reconnectTimers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
this.reconnectTimers.clear();
|
||||||
|
this.reconnectAttempts.clear();
|
||||||
|
this.reconnectConfigs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接失败后排程指数退避重连(5s/15s/60s,最多 3 次)。
|
||||||
|
* 状态机进入 'reconnecting'(设置页可见);重试耗尽停留 'error'。
|
||||||
|
* 仅记住传入配置快照 —— 重连时按原配置重建,不读 DB 中间态。
|
||||||
|
*/
|
||||||
|
private scheduleReconnect(name: string, config: MCPServerConfig): void {
|
||||||
|
if (!this.autoReconnect) return;
|
||||||
|
|
||||||
|
const attempts = (this.reconnectAttempts.get(name) ?? 0) + 1;
|
||||||
|
if (attempts > MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
log.warn(
|
||||||
|
`[MCPManager] "${name}" reconnect exhausted (${MAX_RECONNECT_ATTEMPTS} attempts) — staying in error state`,
|
||||||
|
);
|
||||||
|
this.reconnectAttempts.delete(name);
|
||||||
|
this.reconnectConfigs.delete(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts.set(name, attempts);
|
||||||
|
this.reconnectConfigs.set(name, config);
|
||||||
|
const state = this.servers.get(name);
|
||||||
|
if (state) state.status = 'reconnecting';
|
||||||
|
|
||||||
|
const delay = nextRetryDelayMs(attempts);
|
||||||
|
log.info(
|
||||||
|
`[MCPManager] "${name}" reconnect scheduled in ${delay / 1000}s (attempt ${attempts}/${MAX_RECONNECT_ATTEMPTS})`,
|
||||||
|
);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.reconnectTimers.delete(name);
|
||||||
|
const snapshot = this.reconnectConfigs.get(name);
|
||||||
|
if (!snapshot) return;
|
||||||
|
log.info(
|
||||||
|
`[MCPManager] "${name}" reconnecting (attempt ${attempts}/${MAX_RECONNECT_ATTEMPTS})`,
|
||||||
|
);
|
||||||
|
void this.connectServer(snapshot).catch(() => {
|
||||||
|
/* connectServer 失败路径已自行 scheduleReconnect / 记录状态 */
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
// 定时器不阻塞应用退出
|
||||||
|
timer.unref?.();
|
||||||
|
this.reconnectTimers.set(name, timer);
|
||||||
|
}
|
||||||
|
|
||||||
/** 注册工具集合变更回调(main.ts 在 AgentEngineManager 创建后注入) */
|
/** 注册工具集合变更回调(main.ts 在 AgentEngineManager 创建后注入) */
|
||||||
setOnToolsChanged(callback: () => void): void {
|
setOnToolsChanged(callback: () => void): void {
|
||||||
this.toolsChangedCallback = callback;
|
this.toolsChangedCallback = callback;
|
||||||
@@ -356,10 +448,9 @@ export class MCPManager {
|
|||||||
async connectServer(config: MCPServerConfig): Promise<void> {
|
async connectServer(config: MCPServerConfig): Promise<void> {
|
||||||
const { name } = config;
|
const { name } = config;
|
||||||
|
|
||||||
// 断开已有连接
|
// 断开已有连接(内部拆除 —— 保留重连簿记,否则重试计数被清零、
|
||||||
if (this.servers.has(name)) {
|
// 退避序列永远停在第 1 次;用户显式断开走 disconnectServer)
|
||||||
await this.disconnectServer(name);
|
await this.teardownConnection(name);
|
||||||
}
|
|
||||||
|
|
||||||
this.servers.set(name, {
|
this.servers.set(name, {
|
||||||
config,
|
config,
|
||||||
@@ -441,6 +532,15 @@ export class MCPManager {
|
|||||||
state.connectedAt = Date.now();
|
state.connectedAt = Date.now();
|
||||||
state.error = undefined;
|
state.error = undefined;
|
||||||
|
|
||||||
|
// v0.7.3 P4-2: 连接成功 —— 清零重连计数并取消排程
|
||||||
|
this.reconnectAttempts.delete(name);
|
||||||
|
this.reconnectConfigs.delete(name);
|
||||||
|
const pendingTimer = this.reconnectTimers.get(name);
|
||||||
|
if (pendingTimer) {
|
||||||
|
clearTimeout(pendingTimer);
|
||||||
|
this.reconnectTimers.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
// 更新数据库
|
// 更新数据库
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -468,6 +568,9 @@ export class MCPManager {
|
|||||||
`,
|
`,
|
||||||
).run((error as Error).message, name);
|
).run((error as Error).message, name);
|
||||||
|
|
||||||
|
// v0.7.3 P4-2: 失败后排程指数退避自动重连(开关关闭时 no-op)
|
||||||
|
this.scheduleReconnect(name, config);
|
||||||
|
|
||||||
log.error(`MCP server "${name}" connection failed:`, error);
|
log.error(`MCP server "${name}" connection failed:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -476,14 +579,15 @@ export class MCPManager {
|
|||||||
/**
|
/**
|
||||||
* 断开 MCP Server
|
* 断开 MCP Server
|
||||||
*/
|
*/
|
||||||
async disconnectServer(name: string): Promise<void> {
|
/**
|
||||||
|
* 内部连接拆除(保留重连簿记)—— connectServer 重连前的清理动作。
|
||||||
|
* 与 disconnectServer 的区别:不清 reconnectAttempts/Timers/Configs,
|
||||||
|
* 否则自动重连的每次重试都会把自己的计数清零(退避序列永远停在第 1 次)。
|
||||||
|
*/
|
||||||
|
private async teardownConnection(name: string): Promise<void> {
|
||||||
const state = this.servers.get(name);
|
const state = this.servers.get(name);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
// 从 ToolRegistry 注销
|
|
||||||
this.toolRegistry.unregisterMCPTools(name);
|
this.toolRegistry.unregisterMCPTools(name);
|
||||||
|
|
||||||
// 关闭客户端
|
|
||||||
if (state.client) {
|
if (state.client) {
|
||||||
try {
|
try {
|
||||||
await state.client.close();
|
await state.client.close();
|
||||||
@@ -491,13 +595,24 @@ export class MCPManager {
|
|||||||
// 忽略关闭错误
|
// 忽略关闭错误
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.status = 'disconnected';
|
|
||||||
state.client = null;
|
state.client = null;
|
||||||
state.tools = [];
|
state.tools = [];
|
||||||
|
|
||||||
// v0.5.3: 工具集合已变化 — 通知调用方同步引擎(已有引擎需移除失效工具定义)
|
|
||||||
this.notifyToolsChanged();
|
this.notifyToolsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开 MCP Server(用户显式语义:取消重连排程 + 拆除连接)
|
||||||
|
*/
|
||||||
|
async disconnectServer(name: string): Promise<void> {
|
||||||
|
// v0.7.3 P4-2: 用户显式断开/移除 —— 取消重连排程(用户意图优先于自动重试)。
|
||||||
|
// 无论是否存在连接态(error/reconnecting 态的 server 也可能被移除)都执行。
|
||||||
|
this.cancelReconnect(name);
|
||||||
|
await this.teardownConnection(name);
|
||||||
|
|
||||||
|
const state = this.servers.get(name);
|
||||||
|
if (state) {
|
||||||
|
state.status = 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
log.info(`MCP server "${name}" disconnected`);
|
log.info(`MCP server "${name}" disconnected`);
|
||||||
}
|
}
|
||||||
@@ -601,12 +716,16 @@ export class MCPManager {
|
|||||||
status: MCPServerStatus;
|
status: MCPServerStatus;
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** v0.7.3 P4-2: reconnecting 状态下的已尝试次数(第 N/3 次排程) */
|
||||||
|
reconnectAttempt?: number;
|
||||||
}> {
|
}> {
|
||||||
return Array.from(this.servers.values()).map((s) => ({
|
return Array.from(this.servers.values()).map((s) => ({
|
||||||
name: s.config.name,
|
name: s.config.name,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
toolCount: s.tools.length,
|
toolCount: s.tools.length,
|
||||||
error: s.error,
|
error: s.error,
|
||||||
|
reconnectAttempt:
|
||||||
|
s.status === 'reconnecting' ? this.reconnectAttempts.get(s.config.name) : undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +737,7 @@ export class MCPManager {
|
|||||||
status: MCPServerStatus;
|
status: MCPServerStatus;
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
reconnectAttempt?: number;
|
||||||
} | null {
|
} | null {
|
||||||
const state = this.servers.get(name);
|
const state = this.servers.get(name);
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
@@ -626,6 +746,8 @@ export class MCPManager {
|
|||||||
status: state.status,
|
status: state.status,
|
||||||
toolCount: state.tools.length,
|
toolCount: state.tools.length,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
|
reconnectAttempt:
|
||||||
|
state.status === 'reconnecting' ? this.reconnectAttempts.get(state.config.name) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,6 +755,8 @@ export class MCPManager {
|
|||||||
* 关闭所有连接
|
* 关闭所有连接
|
||||||
*/
|
*/
|
||||||
async shutdown(): Promise<void> {
|
async shutdown(): Promise<void> {
|
||||||
|
// v0.7.3 P4-2: 退出前取消全部重连排程(timer 已 unref,此处幂等清理)
|
||||||
|
this.cancelAllReconnects();
|
||||||
const names = Array.from(this.servers.keys());
|
const names = Array.from(this.servers.keys());
|
||||||
await Promise.allSettled(names.map((n) => this.disconnectServer(n)));
|
await Promise.allSettled(names.map((n) => this.disconnectServer(n)));
|
||||||
log.info('MCP Manager shut down');
|
log.info('MCP Manager shut down');
|
||||||
|
|||||||
@@ -18,7 +18,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { appendFileSync, existsSync, mkdirSync, promises } from 'fs';
|
import {
|
||||||
|
appendFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
promises,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
unlinkSync,
|
||||||
|
} from 'fs';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
|
||||||
// ===== 事件类型 =====
|
// ===== 事件类型 =====
|
||||||
@@ -265,6 +273,82 @@ export class SessionRecorder {
|
|||||||
return first?.filePath ?? null;
|
return first?.filePath ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== v0.7.3 P3-3: JSONL 录制文件生命周期治理 =====
|
||||||
|
|
||||||
|
/** JSONL 录制文件名模式(仅治理本服务产出的文件) */
|
||||||
|
private static readonly RECORDING_NAME = /^session_.+\.jsonl$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计录制目录中的 JSONL 文件(设置页展示 + 清理前置确认用)。
|
||||||
|
* 目录不存在 / 统计失败返回零值(不抛错)。
|
||||||
|
*/
|
||||||
|
getRecordingStats(): { count: number; totalBytes: number } {
|
||||||
|
try {
|
||||||
|
const logsDir = join(this.workspacePath, 'logs');
|
||||||
|
if (!existsSync(logsDir)) return { count: 0, totalBytes: 0 };
|
||||||
|
const names = readdirSync(logsDir).filter((n) => SessionRecorder.RECORDING_NAME.test(n));
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (const name of names) {
|
||||||
|
try {
|
||||||
|
totalBytes += statSync(join(logsDir, name)).size;
|
||||||
|
} catch {
|
||||||
|
/* 单文件统计失败跳过 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { count: names.length, totalBytes };
|
||||||
|
} catch {
|
||||||
|
return { count: 0, totalBytes: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理旧录制文件(按修改时间保留最近 maxFiles 个,默认 200)。
|
||||||
|
*
|
||||||
|
* 背景:workspace/logs/session_*.jsonl 随使用无限累积无任何清理路径。
|
||||||
|
* 清理策略:mtime 降序保留前 maxFiles 个,其余删除;仅匹配本服务的
|
||||||
|
* session_*.jsonl 命名(用户自放文件不受影响)。启动时(main.ts)与
|
||||||
|
* 设置页手动清理共用本方法。
|
||||||
|
*
|
||||||
|
* @returns 实际删除的文件数
|
||||||
|
*/
|
||||||
|
pruneOldRecordings(maxFiles = 200): number {
|
||||||
|
try {
|
||||||
|
const logsDir = join(this.workspacePath, 'logs');
|
||||||
|
if (!existsSync(logsDir)) return 0;
|
||||||
|
const entries = readdirSync(logsDir)
|
||||||
|
.filter((n) => SessionRecorder.RECORDING_NAME.test(n))
|
||||||
|
.map((name) => {
|
||||||
|
try {
|
||||||
|
return { name, mtime: statSync(join(logsDir, name)).mtimeMs };
|
||||||
|
} catch {
|
||||||
|
return { name, mtime: 0 };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
|
if (entries.length <= maxFiles) return 0;
|
||||||
|
const toDelete = entries.slice(maxFiles);
|
||||||
|
let deleted = 0;
|
||||||
|
for (const entry of toDelete) {
|
||||||
|
try {
|
||||||
|
unlinkSync(join(logsDir, entry.name));
|
||||||
|
deleted++;
|
||||||
|
} catch {
|
||||||
|
/* 单文件删除失败(占用中)跳过 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info(
|
||||||
|
`[SessionRecorder] Pruned ${deleted} old recording file(s) (kept ${Math.min(maxFiles, entries.length)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('[SessionRecorder] pruneOldRecordings failed:', err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 私有方法 =====
|
// ===== 私有方法 =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -247,10 +247,12 @@ export class SessionService {
|
|||||||
const db = this.getDBFn();
|
const db = this.getDBFn();
|
||||||
const op = inclusive ? '>=' : '>';
|
const op = inclusive ? '>=' : '>';
|
||||||
|
|
||||||
// 先查锚点 rowid(删除后无法再定位)
|
// 先查锚点 rowid 与时间戳(删除后无法再定位)
|
||||||
const anchor = db
|
const anchor = db
|
||||||
.prepare('SELECT rowid AS rid FROM messages WHERE session_id = ? AND id = ?')
|
.prepare(
|
||||||
.get(sessionId, messageId) as { rid: number } | undefined;
|
'SELECT rowid AS rid, created_at AS ts FROM messages WHERE session_id = ? AND id = ?',
|
||||||
|
)
|
||||||
|
.get(sessionId, messageId) as { rid: number; ts: number } | undefined;
|
||||||
if (!anchor) return false;
|
if (!anchor) return false;
|
||||||
|
|
||||||
const result = db
|
const result = db
|
||||||
@@ -270,6 +272,40 @@ export class SessionService {
|
|||||||
'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?',
|
'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?',
|
||||||
).run(sessionId, anchor.rid + (inclusive ? 0 : 1));
|
).run(sessionId, anchor.rid + (inclusive ? 0 : 1));
|
||||||
|
|
||||||
|
// v0.7.3 P1-2 根治: 同步截断 metadata 中的 TRACE 步骤。
|
||||||
|
// 此前编辑重发/重新生成只删消息 —— traceSteps 残留,Trace 面板出现
|
||||||
|
// "幽灵步骤"(与 /clear 的 metadata 残留同类,v0.7.2 A1 只修了 clear 路径)。
|
||||||
|
// 截断语义:锚点消息(inclusive 时含锚点)触发的 run 及其之后全部作废 ——
|
||||||
|
// 按 startedAt < 锚点消息 created_at 过滤保留更早 run 的步骤(严格小于:
|
||||||
|
// 锚点消息触发的 run 与消息同毫秒落库,等值属于"锚点侧"必须丢弃 ——
|
||||||
|
// 宁可多删一个边界步骤也不留幽灵步骤)。tokenUsage 为最近一次 run 的
|
||||||
|
// 累计展示值,紧随其后的重发 run 会重写,无需修正。
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as
|
||||||
|
| { metadata: string }
|
||||||
|
| undefined;
|
||||||
|
if (row?.metadata) {
|
||||||
|
const data = JSON.parse(row.metadata) as {
|
||||||
|
traceSteps?: Array<{ startedAt?: number }>;
|
||||||
|
tokenUsage?: unknown;
|
||||||
|
};
|
||||||
|
if (Array.isArray(data.traceSteps)) {
|
||||||
|
const kept = data.traceSteps.filter(
|
||||||
|
(s) => typeof s?.startedAt !== 'number' || s.startedAt < anchor.ts,
|
||||||
|
);
|
||||||
|
if (kept.length !== data.traceSteps.length) {
|
||||||
|
data.traceSteps = kept;
|
||||||
|
db.prepare('UPDATE sessions SET metadata = ? WHERE id = ?').run(
|
||||||
|
JSON.stringify(data),
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// metadata 解析失败不阻断截断主流程(与截断语义无耦合)
|
||||||
|
}
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`,
|
`Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`,
|
||||||
);
|
);
|
||||||
@@ -355,12 +391,11 @@ export class SessionService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除一条消息
|
* 删除一条消息
|
||||||
|
*
|
||||||
|
* v0.7.3 P1-3: 随 sessions:deleteMessage 死通道一并移除 —— 该方法不回减
|
||||||
|
* sessions.message_count(saveMessage 加、删除不加的计数漂移面),且渲染层
|
||||||
|
* 从未有调用方。消息删除语义由 truncateMessagesAfter(含计数修正)覆盖。
|
||||||
*/
|
*/
|
||||||
deleteMessage(messageId: string): boolean {
|
|
||||||
const db = this.getDBFn();
|
|
||||||
const result = db.prepare('DELETE FROM messages WHERE id = ?').run(messageId);
|
|
||||||
return result.changes > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空会话所有消息
|
* 清空会话所有消息
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Title Generator — 会话标题 LLM 自动生成(v0.7.3 P4-1)
|
||||||
|
*
|
||||||
|
* 背景:首轮消息后前端仅以"用户首条消息截前 30 字符"作为会话标题
|
||||||
|
* (agent-store.sendMessage),中文长句体验差且语义压缩生硬。
|
||||||
|
*
|
||||||
|
* 本服务在会话首个完成的 run 之后(terminationReason === 'completed')用主
|
||||||
|
* Provider adapter 发起一次极小的非流式请求(maxTokens 32 / temperature 0.3 /
|
||||||
|
* thinking 关闭),生成 ≤16 字的精炼标题并写回 sessions 表。生成失败(网络 /
|
||||||
|
* 配额 / 解析失败)静默回退——前端首 30 字符截断标题保持有效,无损可用性。
|
||||||
|
*
|
||||||
|
* 契约:
|
||||||
|
* - 每个会话生命周期内仅生成一次(内存 Set 幂等,进程重启后自然重置——
|
||||||
|
* 已有非默认标题的会话通过 hasCustomTitle 判定跳过,不会重复生成);
|
||||||
|
* - 标题经 sanitizeTitle 清洗:剥离 markdown/引号/换行/前后缀冒号,
|
||||||
|
* 折叠空白,超长截断,空结果返回 null(调用方保持原标题不变)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import log from 'electron-log';
|
||||||
|
import type { IMetonaProviderAdapter, MetonaRequest } from '../harness/types';
|
||||||
|
import type { SessionService } from './session.service';
|
||||||
|
|
||||||
|
/** 生成标题的最大长度(字符)——超过即截断 */
|
||||||
|
const MAX_TITLE_LENGTH = 40;
|
||||||
|
|
||||||
|
/** 传给 LLM 的用户消息 / 回答摘录长度 */
|
||||||
|
const EXCERPT_LENGTH = 600;
|
||||||
|
|
||||||
|
/** LLM 调用超时(标题生成不应阻塞任何主流程) */
|
||||||
|
const TITLE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清洗 LLM 返回的标题文本。
|
||||||
|
*
|
||||||
|
* 规则(按序应用):
|
||||||
|
* 1. 剥离 markdown 代码围栏与首尾 `#`/`-`/`*` 列表标记;
|
||||||
|
* 2. 剥离成对包裹引号(中英文单双引号);
|
||||||
|
* 3. 剥离 "标题:"/"Title:" 这类自述前缀;
|
||||||
|
* 4. 折叠全部空白(含换行)为单个空格并 trim;
|
||||||
|
* 5. 超过 maxLen 截断;
|
||||||
|
* 6. 空结果返回 null(调用方保持原标题)。
|
||||||
|
*/
|
||||||
|
export function sanitizeTitle(raw: string, maxLen: number = MAX_TITLE_LENGTH): string | null {
|
||||||
|
if (!raw || typeof raw !== 'string') return null;
|
||||||
|
|
||||||
|
let title = raw.trim();
|
||||||
|
// 1/2. markdown 围栏、列表标记与包裹引号 —— 循环应用直至稳定(处理嵌套包装
|
||||||
|
// 如 ```"标题"``` / 多层引号;剥除全部首尾引号字符而非仅成对项,
|
||||||
|
// 使 '"""' 这类纯符号输入收敛为空 → null)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const before = title;
|
||||||
|
title = title
|
||||||
|
.replace(/^```(?:[a-z]*)\s*/i, '')
|
||||||
|
.replace(/\s*```$/i, '')
|
||||||
|
.replace(/^[#\-*>]+\s*/, '')
|
||||||
|
.replace(/^["'“”『』「"]+/, '')
|
||||||
|
.replace(/["'“”『』「"]+$/, '');
|
||||||
|
if (title === before) break;
|
||||||
|
}
|
||||||
|
// 3. 自述前缀(标题:/Title:/会话标题: 等)
|
||||||
|
title = title.replace(/^(?:标题|会话标题|题目|title)\s*[::]\s*/i, '');
|
||||||
|
// 4. 折叠空白(换行合并——LLM 偶发多行输出时取首行语义)
|
||||||
|
title = title.replace(/\s+/g, ' ').trim();
|
||||||
|
// 5. 截断
|
||||||
|
if (title.length > maxLen) title = title.slice(0, maxLen).trimEnd();
|
||||||
|
// 6. 空结果
|
||||||
|
return title.length > 0 ? title : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TitleGenerator {
|
||||||
|
/** 已生成过标题的会话(进程级幂等) */
|
||||||
|
private generated = new Set<string>();
|
||||||
|
/** 进行中的生成任务(防并发重复调用) */
|
||||||
|
private running = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private getAdapter: () => IMetonaProviderAdapter,
|
||||||
|
private sessionService: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为会话生成标题(fire-and-forget 调用;失败静默)。
|
||||||
|
*
|
||||||
|
* @param sessionId 会话 ID
|
||||||
|
* @param userMessage 用户原始消息(干净版本,不含注入前缀)
|
||||||
|
* @param assistantAnswer Agent 最终回答
|
||||||
|
* @returns 生成的标题(未生成/失败返回 null)
|
||||||
|
*/
|
||||||
|
async maybeGenerateTitle(
|
||||||
|
sessionId: string,
|
||||||
|
userMessage: string,
|
||||||
|
assistantAnswer: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!sessionId || typeof sessionId !== 'string') return null;
|
||||||
|
// 输入门控:双向内容均为空无生成意义
|
||||||
|
if (!userMessage?.trim() && !assistantAnswer?.trim()) return null;
|
||||||
|
|
||||||
|
// 并发去重先于幂等短路 —— 同一会话进行中的生成必须复用同一 Promise,
|
||||||
|
// 而不能被"已占位"的幂等判断吞掉(否则并发第二调用拿到 null)
|
||||||
|
const existing = this.running.get(sessionId);
|
||||||
|
if (existing) return existing;
|
||||||
|
// 幂等:每会话仅一次(占位同步完成,先于任何 await)
|
||||||
|
if (this.generated.has(sessionId)) return null;
|
||||||
|
|
||||||
|
this.generated.add(sessionId);
|
||||||
|
const task = this.generate(sessionId, userMessage, assistantAnswer).finally(() => {
|
||||||
|
this.running.delete(sessionId);
|
||||||
|
});
|
||||||
|
this.running.set(sessionId, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generate(
|
||||||
|
sessionId: string,
|
||||||
|
userMessage: string,
|
||||||
|
assistantAnswer: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const adapter = this.getAdapter();
|
||||||
|
if (!adapter) return null;
|
||||||
|
|
||||||
|
// 已有自定义标题(用户手动重命名 / 前端首条截断标题)时不覆盖,
|
||||||
|
// 避免用户主动命名被 LLM 标题冲掉
|
||||||
|
const sessions = this.sessionService.list();
|
||||||
|
const current = sessions.find((s) => s.id === sessionId);
|
||||||
|
if (current && current.title !== '新会话') {
|
||||||
|
log.debug(`[TitleGenerator] session ${sessionId} already titled "${current.title}" — skip`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: MetonaRequest = {
|
||||||
|
meta: {
|
||||||
|
sessionId: 'title-generation',
|
||||||
|
iteration: 0,
|
||||||
|
requestId: `tg_${nanoid(12)}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
agentVersion: '1.0.0',
|
||||||
|
},
|
||||||
|
systemPrompt: {
|
||||||
|
roleDefinition:
|
||||||
|
'You generate concise conversation titles for an AI assistant desktop app.',
|
||||||
|
outputConstraints:
|
||||||
|
'Given the first user message and the assistant reply, output ONE title of at most 16 characters ' +
|
||||||
|
'in the same language as the user message. The title must capture the core topic or task. ' +
|
||||||
|
'No quotes, no markdown, no ending punctuation, no explanations — output the title text ONLY.',
|
||||||
|
safetyGuidelines:
|
||||||
|
'Do not include sensitive data (passwords, keys, personal info) in the title.',
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
`User message: ${userMessage.slice(0, EXCERPT_LENGTH)}\n\n` +
|
||||||
|
`Assistant reply: ${assistantAnswer.slice(0, EXCERPT_LENGTH)}\n\n` +
|
||||||
|
`Output the title only.`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {
|
||||||
|
maxTokens: 32,
|
||||||
|
temperature: 0.3,
|
||||||
|
stream: false,
|
||||||
|
thinkingEnabled: false,
|
||||||
|
thinkingEffort: 'low',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 超时保护:标题生成绝不能拖慢会话收尾
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error('title generation timeout')), TITLE_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
const response = await Promise.race([adapter.send(request), timeoutPromise]);
|
||||||
|
const title = sanitizeTitle(response.content);
|
||||||
|
if (!title) {
|
||||||
|
log.debug(
|
||||||
|
`[TitleGenerator] session ${sessionId}: empty/unsanitizable title, keeping fallback`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const renamed = this.sessionService.rename(sessionId, title);
|
||||||
|
if (renamed) {
|
||||||
|
log.info(`[TitleGenerator] session ${sessionId} titled: "${title}"`);
|
||||||
|
}
|
||||||
|
return renamed ? title : null;
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 静默回退:标题失败不影响会话可用性(前端首条截断标题仍在)
|
||||||
|
log.debug(
|
||||||
|
`[TitleGenerator] session ${sessionId} title generation failed:`,
|
||||||
|
(err as Error).message,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 子进程环境净化单源测试(v0.7.3 P3-2)
|
||||||
|
*
|
||||||
|
* buildSafeChildEnv 是 run_command 与 MCP stdio 启动的唯一净化实现:
|
||||||
|
* E1 敏感后缀剔除;E2 敏感精确名单剔除;E3 非敏感变量保留(黑名单不误伤);
|
||||||
|
* E4 runtime 注入覆盖;E5 空值剔除。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildSafeChildEnv } from '../safe-env';
|
||||||
|
|
||||||
|
const SAMPLE_ENV: Record<string, string | undefined> = {
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
LANG: 'zh_CN.UTF-8',
|
||||||
|
DEEPSEEK_API_KEY: 'sk-secret',
|
||||||
|
MY_SERVICE_TOKEN: 't',
|
||||||
|
APP_PASSWORD: 'p',
|
||||||
|
GITEA_PASSWORD: 'g',
|
||||||
|
GITHUB_TOKEN: 'gh',
|
||||||
|
HOME: '/home/u',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildSafeChildEnv', () => {
|
||||||
|
it('E1: 敏感后缀变量全部剔除(_API_KEY/_TOKEN/_PASSWORD/_SECRET/...)', () => {
|
||||||
|
const env = buildSafeChildEnv({ source: SAMPLE_ENV });
|
||||||
|
expect(env['DEEPSEEK_API_KEY']).toBeUndefined();
|
||||||
|
expect(env['MY_SERVICE_TOKEN']).toBeUndefined();
|
||||||
|
expect(env['APP_PASSWORD']).toBeUndefined();
|
||||||
|
expect(env['GITHUB_TOKEN']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('E2: 精确名单剔除(大小写不敏感后缀 + 精确 key)', () => {
|
||||||
|
const env = buildSafeChildEnv({ source: { GITEA_PASSWORD: 'g', DATABASE_PASSWORD: 'd' } });
|
||||||
|
expect(env['GITEA_PASSWORD']).toBeUndefined();
|
||||||
|
expect(env['DATABASE_PASSWORD']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('E3: 非敏感变量保留(黑名单不误伤 GIT_*/代理/路径类变量)', () => {
|
||||||
|
const env = buildSafeChildEnv({
|
||||||
|
source: { ...SAMPLE_ENV, GIT_AUTHOR_NAME: 'metona', HTTPS_PROXY: 'http://p:7890' },
|
||||||
|
});
|
||||||
|
expect(env['PATH']).toBe('/usr/bin');
|
||||||
|
expect(env['LANG']).toBe('zh_CN.UTF-8');
|
||||||
|
expect(env['HOME']).toBe('/home/u');
|
||||||
|
expect(env['GIT_AUTHOR_NAME']).toBe('metona');
|
||||||
|
expect(env['HTTPS_PROXY']).toBe('http://p:7890');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('E4: runtime 注入覆盖净化结果(调用方运行时差异显式可控)', () => {
|
||||||
|
const env = buildSafeChildEnv({
|
||||||
|
source: SAMPLE_ENV,
|
||||||
|
runtime: { NODE_ENV: 'production', LANG: 'C.utf8' },
|
||||||
|
});
|
||||||
|
expect(env['NODE_ENV']).toBe('production');
|
||||||
|
// runtime 覆盖同名的净化保留值
|
||||||
|
expect(env['LANG']).toBe('C.utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('E5: 空值/undefined 变量剔除', () => {
|
||||||
|
const env = buildSafeChildEnv({ source: { EMPTY: '', UNSET: undefined, KEEP: 'x' } });
|
||||||
|
expect('EMPTY' in env).toBe(false);
|
||||||
|
expect('UNSET' in env).toBe(false);
|
||||||
|
expect(env['KEEP']).toBe('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('E6: 无 source 时回退 process.env(运行路径契约;至少保留 PATH 类基本变量)', () => {
|
||||||
|
const env = buildSafeChildEnv({ runtime: { NODE_ENV: 'production' } });
|
||||||
|
expect(env['NODE_ENV']).toBe('production');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,20 +27,37 @@ import log from 'electron-log';
|
|||||||
|
|
||||||
const AGENT_BROWSER_PARTITION = 'persist:metona-agent-browser';
|
const AGENT_BROWSER_PARTITION = 'persist:metona-agent-browser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.3 P2-1: 代理激活标志(模块级状态,由 applySessionProxy 维护)。
|
||||||
|
*
|
||||||
|
* SSRF DNS pinning(ssrf-dispatcher)在此标志为 true 时必须退化为"仅入口校验":
|
||||||
|
* 代理模式下 DNS 解析发生在代理服务端,本地无法感知/约束最终连接的 IP,
|
||||||
|
* per-request dispatcher 也会旁路用户配置的 ProxyAgent(破坏代理语义)。
|
||||||
|
* 已知边界与 M7 结论一致,显式暴露而非隐藏。
|
||||||
|
*/
|
||||||
|
let proxyActive = false;
|
||||||
|
|
||||||
|
export function isProxyActive(): boolean {
|
||||||
|
return proxyActive;
|
||||||
|
}
|
||||||
|
|
||||||
export async function applySessionProxy(proxyUrl: string | null | undefined): Promise<void> {
|
export async function applySessionProxy(proxyUrl: string | null | undefined): Promise<void> {
|
||||||
const envProxy = process.env['HTTPS_PROXY'] || process.env['HTTP_PROXY'] || '';
|
const envProxy = process.env['HTTPS_PROXY'] || process.env['HTTP_PROXY'] || '';
|
||||||
const rules = (typeof proxyUrl === 'string' ? proxyUrl.trim() : '') || envProxy;
|
const rules = (typeof proxyUrl === 'string' ? proxyUrl.trim() : '') || envProxy;
|
||||||
|
proxyActive = rules !== '';
|
||||||
|
|
||||||
// ===== 通道一:Chromium sessions =====
|
// ===== 通道一:Chromium sessions =====
|
||||||
try {
|
try {
|
||||||
const config = rules !== '' ? { proxyRules: rules } : ({ mode: 'direct' as const });
|
const config = rules !== '' ? { proxyRules: rules } : { mode: 'direct' as const };
|
||||||
await session.defaultSession.setProxy(config);
|
await session.defaultSession.setProxy(config);
|
||||||
await session.fromPartition(AGENT_BROWSER_PARTITION).setProxy(config);
|
await session.fromPartition(AGENT_BROWSER_PARTITION).setProxy(config);
|
||||||
log.info(
|
log.info(
|
||||||
`[Network] Chromium sessions proxy ${rules === '' ? 'set to direct (no proxy)' : `rules: ${rules}`}`,
|
`[Network] Chromium sessions proxy ${rules === '' ? 'set to direct (no proxy)' : `rules: ${rules}`}`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(`[Network] Failed to apply Chromium session proxy "${rules}": ${(err as Error).message}`);
|
log.warn(
|
||||||
|
`[Network] Failed to apply Chromium session proxy "${rules}": ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 通道二:主进程 Node fetch(undici 全局 dispatcher)=====
|
// ===== 通道二:主进程 Node fetch(undici 全局 dispatcher)=====
|
||||||
@@ -58,7 +75,9 @@ export async function applySessionProxy(proxyUrl: string | null | undefined): Pr
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 理论仅在 undici 原生绑定异常时发生;fetch 将保持默认直连行为
|
// 理论仅在 undici 原生绑定异常时发生;fetch 将保持默认直连行为
|
||||||
log.warn(`[Network] Failed to set undici dispatcher for proxy "${rules}": ${(err as Error).message}`);
|
log.warn(
|
||||||
|
`[Network] Failed to set undici dispatcher for proxy "${rules}": ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Safe Child Environment — 子进程环境变量净化(v0.7.3 P3-2 单源收敛)
|
||||||
|
*
|
||||||
|
* 背景:run_command(command.ts buildSafeCommandEnv)与 MCP stdio 启动
|
||||||
|
* (mcp-manager.service.ts buildSafeEnv)各持有一份几乎相同的"敏感变量黑名单
|
||||||
|
* + 全量剔除"实现,任何新增敏感 key 都要改两处(历史上已经漂移过一次:
|
||||||
|
* command 版多注入了 Windows 运行时变量)。本模块是唯一实现,两个调用方
|
||||||
|
* 按需声明运行时差异。
|
||||||
|
*
|
||||||
|
* 净化策略(黑名单方案,#9 修复 + 审查修复的延续):
|
||||||
|
* - 敏感变量名黑名单(精确匹配)一律剔除;
|
||||||
|
* - 敏感后缀(_API_KEY/_TOKEN/_SECRET/...)一律剔除;
|
||||||
|
* - 其余变量保留(白名单方案会破坏 GIT_* 与 PYTHONPATH、代理变量等子进程必需项,
|
||||||
|
* 见 mcp-manager 历史注释);
|
||||||
|
* - 调用方通过 `runtime` 注入必需的运行时变量(后者覆盖前者,显式可控)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 敏感变量后缀黑名单(大小写不敏感后缀匹配) */
|
||||||
|
const SENSITIVE_SUFFIXES = [
|
||||||
|
'_API_KEY',
|
||||||
|
'_TOKEN',
|
||||||
|
'_SECRET',
|
||||||
|
'_PASSWORD',
|
||||||
|
'_PASSWD',
|
||||||
|
'_CREDENTIAL',
|
||||||
|
'_CREDENTIALS',
|
||||||
|
'_PRIVATE_KEY',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 敏感变量名黑名单(精确匹配) */
|
||||||
|
const SENSITIVE_KEYS = new Set([
|
||||||
|
'DEEPSEEK_API_KEY',
|
||||||
|
'AGNES_API_KEY',
|
||||||
|
'MIMO_API_KEY',
|
||||||
|
'GITEA_PASSWORD',
|
||||||
|
'DATABASE_PASSWORD',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface SafeChildEnvOptions {
|
||||||
|
/**
|
||||||
|
* 调用方必需的运行时变量(如 NODE_ENV / PYTHONIOENCODING / LANG)。
|
||||||
|
* 在净化后的 process.env 之上覆盖写入。
|
||||||
|
*/
|
||||||
|
runtime?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* 变量来源(默认 process.env;测试可注入受控快照)。
|
||||||
|
*/
|
||||||
|
source?: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建净化后的子进程环境变量。
|
||||||
|
* @see 模块注释 —— 黑名单方案的设计原因与历史漂移教训
|
||||||
|
*/
|
||||||
|
export function buildSafeChildEnv(options: SafeChildEnvOptions = {}): Record<string, string> {
|
||||||
|
const source = options.source ?? process.env;
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(source)) {
|
||||||
|
if (!val) continue;
|
||||||
|
if (SENSITIVE_KEYS.has(key)) continue;
|
||||||
|
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
|
||||||
|
env[key] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行时必需变量(显式注入,覆盖净化结果)
|
||||||
|
if (options.runtime) {
|
||||||
|
Object.assign(env, options.runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"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.7.2",
|
"version": "0.7.3",
|
||||||
"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",
|
||||||
|
|||||||
+19
-1
@@ -91,6 +91,8 @@ export default function App(): React.JSX.Element {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* 读取失败保持默认 zh-CN */
|
/* 读取失败保持默认 zh-CN */
|
||||||
});
|
});
|
||||||
|
// v0.7.3 P1-4: 启动即拉取模型能力探测缓存(Ollama vision 门控数据源)
|
||||||
|
useAgentStore.getState().refreshModelCapabilities();
|
||||||
// M-25 修复: 改用 Promise.allSettled,单个配置读取失败不影响另一个
|
// M-25 修复: 改用 Promise.allSettled,单个配置读取失败不影响另一个
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
window.metona.config.get('llm.provider'),
|
window.metona.config.get('llm.provider'),
|
||||||
@@ -128,7 +130,23 @@ export default function App(): React.JSX.Element {
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[App] tools.isReady query failed:', err);
|
console.error('[App] tools.isReady query failed:', err);
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
const offTools = unsubscribe;
|
||||||
|
|
||||||
|
// v0.7.3 P1-4: LLM 配置变更 / Ollama 模型下载完成后刷新能力探测缓存
|
||||||
|
const offConfig = window.metona.config?.onChanged?.((data) => {
|
||||||
|
if (data.key === 'llm.provider' || data.key === 'llm.model') {
|
||||||
|
void useAgentStore.getState().refreshModelCapabilities();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const offPull = window.metona.llm?.onOllamaPullEnded?.(() => {
|
||||||
|
void useAgentStore.getState().refreshModelCapabilities();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
offTools();
|
||||||
|
offConfig?.();
|
||||||
|
offPull?.();
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// 无 IPC 可用时直接标记为就绪(避免永久禁用)
|
// 无 IPC 可用时直接标记为就绪(避免永久禁用)
|
||||||
useAgentStore.getState().setToolsReady(true);
|
useAgentStore.getState().setToolsReady(true);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 模型能力门控测试(v0.7.3 P1-4)
|
||||||
|
*
|
||||||
|
* 锁定 supportsImageUpload 三道判定链:
|
||||||
|
* M1 总开关;M2 DeepSeek 非 vision 命名拒绝;M3 Ollama 能力探测
|
||||||
|
* (supportsVision=false 拒绝 / undefined 保守放行 / 探测缓存缺失放行);
|
||||||
|
* 其余 Provider 默认放行。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { supportsImageUpload } from '../lib/model-capabilities';
|
||||||
|
|
||||||
|
const BASE = { multimodalEnabled: true, provider: 'deepseek', model: 'deepseek-v4-pro' };
|
||||||
|
|
||||||
|
describe('supportsImageUpload', () => {
|
||||||
|
it('M1: 总开关关闭 → disabled(即使模型支持)', () => {
|
||||||
|
const r = supportsImageUpload({
|
||||||
|
...BASE,
|
||||||
|
model: 'deepseek-v4-flash-vision-exp',
|
||||||
|
multimodalEnabled: false,
|
||||||
|
});
|
||||||
|
expect(r).toEqual({ allowed: false, reason: 'disabled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('M2: DeepSeek 非 vision 命名 → provider-vision', () => {
|
||||||
|
expect(supportsImageUpload(BASE)).toEqual({ allowed: false, reason: 'provider-vision' });
|
||||||
|
expect(supportsImageUpload({ ...BASE, model: '' })).toEqual({
|
||||||
|
allowed: false,
|
||||||
|
reason: 'provider-vision',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('M2b: DeepSeek vision 命名放行', () => {
|
||||||
|
const r = supportsImageUpload({
|
||||||
|
...BASE,
|
||||||
|
model: 'deepseek-v4-flash-vision-exp',
|
||||||
|
});
|
||||||
|
expect(r.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('M3a: Ollama 探测 supportsVision=false → probed-no-vision', () => {
|
||||||
|
const r = supportsImageUpload({
|
||||||
|
multimodalEnabled: true,
|
||||||
|
provider: 'ollama',
|
||||||
|
model: 'qwen3:latest',
|
||||||
|
visionCaps: { 'qwen3:latest': false, 'llava:latest': true },
|
||||||
|
});
|
||||||
|
expect(r).toEqual({ allowed: false, reason: 'probed-no-vision' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('M3b: Ollama 探测 true / undefined(未知)→ 保守放行', () => {
|
||||||
|
const base = { multimodalEnabled: true, provider: 'ollama', model: 'llava:latest' };
|
||||||
|
expect(supportsImageUpload({ ...base, visionCaps: { 'llava:latest': true } }).allowed).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(supportsImageUpload({ ...base, visionCaps: { 'other:model': false } }).allowed).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// 探测支持为 true 的模型放行
|
||||||
|
});
|
||||||
|
|
||||||
|
it('M3c: Ollama 探测缓存缺失(未加载列表)→ 放行(fail-open 保持可用性)', () => {
|
||||||
|
const r = supportsImageUpload({
|
||||||
|
multimodalEnabled: true,
|
||||||
|
provider: 'ollama',
|
||||||
|
model: 'qwen3:latest',
|
||||||
|
visionCaps: {},
|
||||||
|
});
|
||||||
|
expect(r.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('M4: 其余 Provider(openai/anthropic/agnes/mimo)默认放行', () => {
|
||||||
|
for (const provider of ['openai', 'anthropic', 'agnes', 'mimo']) {
|
||||||
|
expect(
|
||||||
|
supportsImageUpload({ multimodalEnabled: true, provider, model: 'any-model' }).allowed,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Trace 生命周期测试(v0.7.3 P3-3)
|
||||||
|
*
|
||||||
|
* L1 keepRecentRuns —— 按 runId 分组保留最近 N 组、首次出现顺序稳定、
|
||||||
|
* 无 runId 的 legacy 步骤逐条参与淘汰、未超限原样返回(引用不变)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { keepRecentRuns, MAX_TRACE_RUNS } from '../lib/trace-lifecycle';
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
runId?: string;
|
||||||
|
iteration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSteps(spec: Array<[runId: string | undefined, count: number]>): Step[] {
|
||||||
|
const steps: Step[] = [];
|
||||||
|
for (const [runId, count] of spec) {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
steps.push(
|
||||||
|
runId === undefined ? { iteration: steps.length } : { runId, iteration: steps.length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('keepRecentRuns', () => {
|
||||||
|
it('L1a: 未超限时原样返回(引用不变,零开销路径)', () => {
|
||||||
|
const steps = makeSteps([
|
||||||
|
['runA', 2],
|
||||||
|
['runB', 3],
|
||||||
|
]);
|
||||||
|
expect(keepRecentRuns(steps)).toBe(steps);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('L1b: 超限时丢弃最旧 run 组,保留最近 N 组', () => {
|
||||||
|
const spec: Array<[string | undefined, number]> = [];
|
||||||
|
for (let i = 0; i < MAX_TRACE_RUNS + 3; i++) spec.push([`run${i}`, 2]);
|
||||||
|
const steps = makeSteps(spec);
|
||||||
|
|
||||||
|
const kept = keepRecentRuns(steps);
|
||||||
|
// 丢掉前 3 个 run 组(6 条),保留后 20 组(40 条)
|
||||||
|
expect(kept).toHaveLength(MAX_TRACE_RUNS * 2);
|
||||||
|
expect(kept.every((s) => Number(s.runId?.slice(3)) >= 3)).toBe(true);
|
||||||
|
// 顺序保持首次出现顺序(run3 → run22)
|
||||||
|
expect(kept[0].runId).toBe('run3');
|
||||||
|
expect(kept[kept.length - 1].runId).toBe(`run${MAX_TRACE_RUNS + 2}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('L1c: 无 runId 的 legacy 步骤逐条参与淘汰(不整组豁免)', () => {
|
||||||
|
const spec: Array<[string | undefined, number]> = [];
|
||||||
|
for (let i = 0; i < MAX_TRACE_RUNS; i++) spec.push([`run${i}`, 1]);
|
||||||
|
spec.push([undefined, 3]); // 3 条 legacy
|
||||||
|
const steps = makeSteps(spec);
|
||||||
|
|
||||||
|
const kept = keepRecentRuns(steps, 5);
|
||||||
|
// 共 23 组(20 run + 3 legacy 伪组,legacy 按插入序排在最后),
|
||||||
|
// 保留最近 5 组 = run18 + run19 + 3 条 legacy
|
||||||
|
expect(kept).toHaveLength(5);
|
||||||
|
const runIds = kept.filter((s) => s.runId).map((s) => s.runId);
|
||||||
|
expect(runIds).toEqual(['run18', 'run19']);
|
||||||
|
expect(kept.filter((s) => !s.runId)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('L1d: 空数组安全', () => {
|
||||||
|
expect(keepRecentRuns([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('L1e: maxRuns 自定义生效', () => {
|
||||||
|
const steps = makeSteps([
|
||||||
|
['a', 1],
|
||||||
|
['b', 1],
|
||||||
|
['c', 1],
|
||||||
|
]);
|
||||||
|
const kept = keepRecentRuns(steps, 2);
|
||||||
|
expect(kept.map((s) => s.runId)).toEqual(['b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 编辑重发/重新生成的 Trace 截断测试(v0.7.3 P1-2)
|
||||||
|
*
|
||||||
|
* 前端侧:trimTraceStepsByAnchor 按 startedAt 过滤(legacy 无时间戳保守保留)。
|
||||||
|
* DB 侧(truncateMessagesAfter 过滤 metadata.traceSteps)在
|
||||||
|
* electron/services/__tests__/session-truncate-trace.test.ts 以真实 SQLite 覆盖。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { trimTraceStepsByAnchor } from '../stores/agent-store';
|
||||||
|
|
||||||
|
describe('trimTraceStepsByAnchor(P1-2)', () => {
|
||||||
|
const anchorTs = 1_000_000;
|
||||||
|
|
||||||
|
it('仅保留 startedAt < 锚点时间戳的步骤(严格小于:等值属于锚点侧必须丢弃)', () => {
|
||||||
|
const steps = [
|
||||||
|
{ runId: 'r1', startedAt: 900_000 },
|
||||||
|
{ runId: 'r2', startedAt: anchorTs }, // 恰好等于锚点 —— 锚点消息触发的 run,同毫秒落库必须判废
|
||||||
|
{ runId: 'r3', startedAt: anchorTs + 1 },
|
||||||
|
{ runId: 'r4', startedAt: anchorTs + 5000 },
|
||||||
|
];
|
||||||
|
const kept = trimTraceStepsByAnchor(steps, anchorTs);
|
||||||
|
expect(kept.map((s) => s.runId)).toEqual(['r1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy 步骤缺 startedAt → 保守保留(无时间依据可判废)', () => {
|
||||||
|
const steps = [
|
||||||
|
{ iteration: 1 } as { runId?: string; startedAt: number; iteration: number },
|
||||||
|
{ runId: 'r2', startedAt: anchorTs + 1 },
|
||||||
|
];
|
||||||
|
const kept = trimTraceStepsByAnchor(steps, anchorTs);
|
||||||
|
// legacy(无 startedAt)保守保留;r2 startedAt 晚于锚点 → 丢弃
|
||||||
|
expect(kept).toHaveLength(1);
|
||||||
|
expect(kept[0].runId).toBeUndefined();
|
||||||
|
expect(kept[0].startedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空数组安全', () => {
|
||||||
|
expect(trimTraceStepsByAnchor([], anchorTs)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,12 +26,19 @@ import { useAgentStore } from '@renderer/stores/agent-store';
|
|||||||
import { useSessionStore } from '@renderer/stores/session-store';
|
import { useSessionStore } from '@renderer/stores/session-store';
|
||||||
import { useUIStore } from '@renderer/stores/ui-store';
|
import { useUIStore } from '@renderer/stores/ui-store';
|
||||||
import { formatFileSize } from '@renderer/lib/formatters';
|
import { formatFileSize } from '@renderer/lib/formatters';
|
||||||
|
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||||
|
// v0.7.3 P1-4: 图片上传门控纯函数(总开关 × DeepSeek 命名防线 × Ollama 能力探测)
|
||||||
|
import { supportsImageUpload } from '@renderer/lib/model-capabilities';
|
||||||
|
// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||||
|
import { t } from '@renderer/lib/i18n';
|
||||||
|
import '@renderer/lib/i18n-strings';
|
||||||
|
|
||||||
const SLASH_COMMANDS = [
|
// v0.7.3 P4-3: 描述文案渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体)
|
||||||
{ id: 'tool', label: '/tool', description: '选择工具' },
|
const SLASH_COMMANDS: Array<{ id: string; label: string; description: () => string }> = [
|
||||||
{ id: 'memory', label: '/memory', description: '搜索记忆' },
|
{ id: 'tool', label: '/tool', description: () => t('input.slash.tool') },
|
||||||
{ id: 'clear', label: '/clear', description: '清空会话' },
|
{ id: 'memory', label: '/memory', description: () => t('input.slash.memory') },
|
||||||
{ id: 'export', label: '/export', description: '导出 Markdown' },
|
{ id: 'clear', label: '/clear', description: () => t('input.slash.clear') },
|
||||||
|
{ id: 'export', label: '/export', description: () => t('input.slash.export') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||||
@@ -106,15 +113,14 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
const model = useAgentStore((s) => s.model);
|
const model = useAgentStore((s) => s.model);
|
||||||
// v0.5.4: 多模态总开关(llm.multimodalEnabled,设置/引导向导中配置)
|
// v0.5.4: 多模态总开关(llm.multimodalEnabled,设置/引导向导中配置)
|
||||||
const multimodalEnabled = useAgentStore((s) => s.multimodalEnabled);
|
const multimodalEnabled = useAgentStore((s) => s.multimodalEnabled);
|
||||||
|
const modelVisionCaps = useAgentStore((s) => s.modelVisionCaps);
|
||||||
/**
|
const imageGate = supportsImageUpload({
|
||||||
* v0.5.4: 图片上传双重判断 — 总开关 × 模型能力
|
multimodalEnabled,
|
||||||
* - 开关关闭:即使模型支持多模态也不能上传(显式控制)
|
provider,
|
||||||
* - 模型能力:DeepSeek 仅 vision 系列支持;其他五家 Provider 均支持
|
model,
|
||||||
*/
|
visionCaps: modelVisionCaps,
|
||||||
const modelSupportsImages =
|
});
|
||||||
provider !== 'deepseek' || (model.length > 0 && model.includes('vision'));
|
const supportsImages = imageGate.allowed;
|
||||||
const supportsImages = multimodalEnabled && modelSupportsImages;
|
|
||||||
|
|
||||||
// 草稿自动保存
|
// 草稿自动保存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -163,11 +169,12 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
reader.onerror = () => reject(new Error('文本文件读取失败'));
|
reader.onerror = () => reject(new Error('文本文件读取失败'));
|
||||||
reader.readAsText(headBlob);
|
reader.readAsText(headBlob);
|
||||||
});
|
});
|
||||||
attachment.textContent += `\n\n[... 文件内容已截断:原始大小 ${formatFileSize(file.size)},仅包含前 ${formatFileSize(MAX_TEXT_ATTACHMENT_BYTES)}。如需完整内容请分段处理 ...]`;
|
attachment.textContent += t('input.text.truncatedNote', {
|
||||||
|
size: formatFileSize(file.size),
|
||||||
|
limit: formatFileSize(MAX_TEXT_ATTACHMENT_BYTES),
|
||||||
|
});
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) =>
|
.then((mod) => mod.default.warning(t('input.text.truncated', { name: file.name })))
|
||||||
mod.default.warning(`文本附件 "${file.name}" 超过 512KB,已截断为前 512KB`),
|
|
||||||
)
|
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
attachment.textContent = await new Promise<string>((resolve, reject) => {
|
attachment.textContent = await new Promise<string>((resolve, reject) => {
|
||||||
@@ -196,9 +203,15 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
if (filtered.length < fileArray.length && !supportsImages) {
|
if (filtered.length < fileArray.length && !supportsImages) {
|
||||||
const skipped = fileArray.length - filtered.length;
|
const skipped = fileArray.length - filtered.length;
|
||||||
// v0.5.4: 区分拒绝原因 — 开关未开启 vs 当前模型不支持
|
// v0.5.4: 区分拒绝原因 — 开关未开启 vs 当前模型不支持
|
||||||
const reason = multimodalEnabled
|
const reason =
|
||||||
? `当前模型不支持图片(${provider} 需多模态模型),已跳过 ${skipped} 个图片文件`
|
imageGate.reason === 'disabled'
|
||||||
: `多模态未开启(设置 → LLM 配置),已跳过 ${skipped} 个图片文件`;
|
? t('input.image.skipped.toggle', { count: skipped })
|
||||||
|
: imageGate.reason === 'probed-no-vision'
|
||||||
|
? t('input.image.skipped.probe', { count: skipped })
|
||||||
|
: t('input.image.skipped.model', {
|
||||||
|
provider: PROVIDER_LABELS[provider] ?? provider,
|
||||||
|
count: skipped,
|
||||||
|
});
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => {
|
.then((mod) => {
|
||||||
mod.default.warning(reason);
|
mod.default.warning(reason);
|
||||||
@@ -218,7 +231,7 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
if (successful.length === 0) {
|
if (successful.length === 0) {
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => {
|
.then((mod) => {
|
||||||
mod.default.error('文件读取失败,请检查文件是否损坏或被锁定');
|
mod.default.error(t('input.file.readFailed'));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -227,7 +240,7 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
const failedCount = filtered.length - successful.length;
|
const failedCount = filtered.length - successful.length;
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => {
|
.then((mod) => {
|
||||||
mod.default.warning(`${failedCount} 个文件读取失败,已跳过`);
|
mod.default.warning(t('input.file.partialFailed', { count: failedCount }));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -522,7 +535,7 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
</span>
|
</span>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{cmd.label}</span>
|
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{cmd.label}</span>
|
||||||
<span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>
|
<span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>
|
||||||
{cmd.description}
|
{cmd.description()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -554,9 +567,9 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
placeholder={
|
placeholder={
|
||||||
configLoaded
|
configLoaded
|
||||||
? toolsReady
|
? toolsReady
|
||||||
? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)'
|
? t('input.placeholder.ready')
|
||||||
: '工具加载中...'
|
: t('input.placeholder.toolsLoading')
|
||||||
: '正在加载配置...'
|
: t('input.placeholder.configLoading')
|
||||||
}
|
}
|
||||||
disabled={isStreaming || !configLoaded || !toolsReady}
|
disabled={isStreaming || !configLoaded || !toolsReady}
|
||||||
multiline
|
multiline
|
||||||
@@ -608,7 +621,7 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
onClick={handleAbort}
|
onClick={handleAbort}
|
||||||
sx={{ height: 28, fontSize: 12 }}
|
sx={{ height: 28, fontSize: 12 }}
|
||||||
>
|
>
|
||||||
<Square size={12} style={{ marginRight: 6 }} /> 中断
|
<Square size={12} style={{ marginRight: 6 }} /> {t('input.abort')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -627,7 +640,7 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
: 0.5,
|
: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Send size={12} style={{ marginRight: 6 }} /> 发送
|
<Send size={12} style={{ marginRight: 6 }} /> {t('input.send')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ export function Sidebar(): React.JSX.Element {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// v0.7.3 P4-1: 主进程 LLM 标题生成完成后经 config:changed 广播
|
||||||
|
// (合成 key:session.title.<sessionId>),此处消费并刷新侧栏标题
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.metona?.config?.onChanged) return;
|
||||||
|
const unsubscribe = window.metona.config.onChanged((data) => {
|
||||||
|
const match = /^session\.title\.(.+)$/.exec(data.key);
|
||||||
|
if (match && typeof data.value === 'string' && data.value) {
|
||||||
|
useSessionStore.getState().updateSession(match[1], { title: data.value });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// v0.5.0: 搜索词变化时防抖查询会话内容(FTS5 全文搜索,300ms 防抖减少 IPC 频率)
|
// v0.5.0: 搜索词变化时防抖查询会话内容(FTS5 全文搜索,300ms 防抖减少 IPC 频率)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchQuery.trim();
|
const q = searchQuery.trim();
|
||||||
|
|||||||
@@ -7,13 +7,25 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, Typography, Stack, Accordion, AccordionSummary, AccordionDetails,
|
Box,
|
||||||
IconButton, TextField, Chip, Alert, InputAdornment,
|
Typography,
|
||||||
|
Stack,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Chip,
|
||||||
|
Alert,
|
||||||
|
InputAdornment,
|
||||||
} 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 { useUIStore } from '@renderer/stores/ui-store';
|
||||||
import { formatTime, truncate } from '@renderer/lib/formatters';
|
import { formatTime, truncate } from '@renderer/lib/formatters';
|
||||||
|
// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||||
|
import { t } from '@renderer/lib/i18n';
|
||||||
|
import '@renderer/lib/i18n-strings';
|
||||||
|
|
||||||
// ===== 类型 =====
|
// ===== 类型 =====
|
||||||
|
|
||||||
@@ -43,11 +55,13 @@ interface SearchResult {
|
|||||||
|
|
||||||
const MEMORY_TYPES: MemoryType[] = ['episodic', 'semantic', 'working'];
|
const MEMORY_TYPES: MemoryType[] = ['episodic', 'semantic', 'working'];
|
||||||
|
|
||||||
const MEMORY_TYPE_LABELS: Record<MemoryType, string> = {
|
// v0.7.3 P4-3: 类型标签渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体)
|
||||||
episodic: '情景记忆',
|
const MEMORY_TYPE_LABEL_KEYS: Record<MemoryType, string> = {
|
||||||
semantic: '语义记忆',
|
episodic: 'memory.type.episodic',
|
||||||
working: '工作记忆',
|
semantic: 'memory.type.semantic',
|
||||||
|
working: 'memory.type.working',
|
||||||
};
|
};
|
||||||
|
const memoryTypeLabel = (type: MemoryType): string => t(MEMORY_TYPE_LABEL_KEYS[type]);
|
||||||
|
|
||||||
const MEMORY_TYPE_COLORS: Record<MemoryType, string> = {
|
const MEMORY_TYPE_COLORS: Record<MemoryType, string> = {
|
||||||
episodic: '#22d3ee',
|
episodic: '#22d3ee',
|
||||||
@@ -73,7 +87,9 @@ function getId(item: MemoryItem): string {
|
|||||||
|
|
||||||
export function MemoryViewer(): React.JSX.Element {
|
export function MemoryViewer(): React.JSX.Element {
|
||||||
const [memories, setMemories] = useState<Record<MemoryType, MemoryItem[]>>({
|
const [memories, setMemories] = useState<Record<MemoryType, MemoryItem[]>>({
|
||||||
episodic: [], semantic: [], working: [],
|
episodic: [],
|
||||||
|
semantic: [],
|
||||||
|
working: [],
|
||||||
});
|
});
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||||
@@ -108,7 +124,7 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
|
// 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果
|
||||||
if (loadReqIdRef.current !== reqId) return;
|
if (loadReqIdRef.current !== reqId) return;
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.error ?? '加载记忆失败');
|
setError(res.error ?? t('memory.loadFailed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = res.data ?? {};
|
const data = res.data ?? {};
|
||||||
@@ -119,7 +135,7 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (loadReqIdRef.current !== reqId) return;
|
if (loadReqIdRef.current !== reqId) return;
|
||||||
setError((err as Error).message ?? '加载记忆失败');
|
setError((err as Error).message ?? t('memory.loadFailed'));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -127,7 +143,9 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMemories();
|
loadMemories();
|
||||||
// cleanup: 使当前请求失效(防止卸载后 setState)
|
// cleanup: 使当前请求失效(防止卸载后 setState)
|
||||||
return () => { loadReqIdRef.current++; };
|
return () => {
|
||||||
|
loadReqIdRef.current++;
|
||||||
|
};
|
||||||
}, [loadMemories]);
|
}, [loadMemories]);
|
||||||
|
|
||||||
// Agent 完成自动刷新
|
// Agent 完成自动刷新
|
||||||
@@ -161,7 +179,7 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (searchReqIdRef.current !== reqId) return;
|
if (searchReqIdRef.current !== reqId) return;
|
||||||
setError((err as Error).message ?? '搜索失败');
|
setError((err as Error).message ?? t('memory.loadFailed'));
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
// 审计补充修复: 无条件清理 searching 状态,避免被 loadMemories 取代时卡死
|
// 审计补充修复: 无条件清理 searching 状态,避免被 loadMemories 取代时卡死
|
||||||
@@ -181,29 +199,42 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
const res = await window.metona.memory.delete(type, id);
|
const res = await window.metona.memory.delete(type, id);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
// 用户主动操作失败应用 toast(与项目惯例一致),不污染加载/搜索的 Alert
|
// 用户主动操作失败应用 toast(与项目惯例一致),不污染加载/搜索的 Alert
|
||||||
import('@metona-team/metona-toast').then((mod) => mod.default.error(res.error ?? '删除失败')).catch(() => {});
|
import('@metona-team/metona-toast')
|
||||||
|
.then((mod) => mod.default.error(res.error ?? t('memory.deleteFailed')))
|
||||||
|
.catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadMemories();
|
await loadMemories();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
import('@metona-team/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 ?? t('memory.deleteFailed')))
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalCount =
|
const totalCount = memories.episodic.length + memories.semantic.length + memories.working.length;
|
||||||
memories.episodic.length + memories.semantic.length + memories.working.length;
|
|
||||||
|
|
||||||
// 搜索结果视图
|
// 搜索结果视图
|
||||||
if (searchResults !== null) {
|
if (searchResults !== null) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
<Box
|
||||||
|
sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}
|
||||||
|
>
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||||||
<Brain size={14} style={{ color: '#a855f7' }} />
|
<Brain size={14} style={{ color: '#a855f7' }} />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
<Typography
|
||||||
记忆搜索
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('memory.search.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||||
{searchResults.length} 条结果
|
{t('memory.results', { count: searchResults.length })}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -212,9 +243,15 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
fullWidth
|
fullWidth
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
onKeyDown={(e) => {
|
||||||
placeholder="搜索记忆..."
|
if (e.key === 'Enter') handleSearch();
|
||||||
sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }}
|
}}
|
||||||
|
placeholder={t('memory.search.placeholder')}
|
||||||
|
sx={{
|
||||||
|
mb: 1.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 },
|
||||||
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -223,24 +260,49 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
endAdornment: searchQuery ? (
|
endAdornment: searchQuery ? (
|
||||||
<InputAdornment position="end" sx={{ cursor: 'pointer' }} onClick={handleClearSearch}>
|
<InputAdornment
|
||||||
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.secondary' }}>清除</Typography>
|
position="end"
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.secondary' }}>
|
||||||
|
{t('memory.search.clear')}
|
||||||
|
</Typography>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
) : null,
|
) : null,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 0.5, minHeight: 0 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.5,
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
|
<Typography
|
||||||
搜索中...
|
variant="caption"
|
||||||
|
sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}
|
||||||
|
>
|
||||||
|
{t('memory.searching')}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : searchResults.length === 0 ? (
|
) : searchResults.length === 0 ? (
|
||||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}>
|
<Typography
|
||||||
无匹配结果
|
variant="caption"
|
||||||
|
sx={{ textAlign: 'center', py: 2, color: 'text.disabled' }}
|
||||||
|
>
|
||||||
|
{t('memory.search.empty')}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
searchResults.map((r) => (
|
searchResults.map((r) => (
|
||||||
@@ -254,14 +316,24 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
|
|
||||||
// 全量列表视图
|
// 全量列表视图
|
||||||
return (
|
return (
|
||||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
<Box
|
||||||
|
sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}
|
||||||
|
>
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||||||
<Brain size={14} style={{ color: '#a855f7' }} />
|
<Brain size={14} style={{ color: '#a855f7' }} />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
<Typography
|
||||||
记忆库
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('memory.list.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||||||
{totalCount} 条
|
{t('memory.total', { count: totalCount })}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -270,9 +342,15 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
fullWidth
|
fullWidth
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
onKeyDown={(e) => {
|
||||||
placeholder="搜索记忆..."
|
if (e.key === 'Enter') handleSearch();
|
||||||
sx={{ mb: 1.5, flexShrink: 0, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 } }}
|
}}
|
||||||
|
placeholder={t('memory.search.placeholder')}
|
||||||
|
sx={{
|
||||||
|
mb: 1.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1.5 },
|
||||||
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -284,11 +362,18 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>{error}</Alert>}
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 1, py: 0.5, fontSize: 11, flexShrink: 0 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
{totalCount === 0 ? (
|
{totalCount === 0 ? (
|
||||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}
|
||||||
|
>
|
||||||
暂无记忆数据
|
暂无记忆数据
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
@@ -320,16 +405,31 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<AccordionSummary expandIcon={<ChevronDown size={14} />}>
|
<AccordionSummary expandIcon={<ChevronDown size={14} />}>
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', width: '100%' }}>
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', width: '100%' }}>
|
||||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: MEMORY_TYPE_COLORS[type], flexShrink: 0 }} />
|
<Box
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: 11, color: 'text.primary' }}>
|
sx={{
|
||||||
{MEMORY_TYPE_LABELS[type]}
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: MEMORY_TYPE_COLORS[type],
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 600, fontSize: 11, color: 'text.primary' }}
|
||||||
|
>
|
||||||
|
{memoryTypeLabel(type)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
label={items.length}
|
label={items.length}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
ml: 'auto', mr: 0.5, height: 16, fontSize: 10,
|
ml: 'auto',
|
||||||
bgcolor: 'action.hover', color: 'text.secondary',
|
mr: 0.5,
|
||||||
|
height: 16,
|
||||||
|
fontSize: 10,
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
color: 'text.secondary',
|
||||||
'& .MuiChip-label': { px: 0.5 },
|
'& .MuiChip-label': { px: 0.5 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -366,17 +466,22 @@ function MemoryItemRow({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 1, py: 0.75, borderRadius: 1,
|
px: 1,
|
||||||
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
|
py: 0.75,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
'&:hover .delete-btn': { opacity: 1 },
|
'&:hover .delete-btn': { opacity: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
|
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={MEMORY_TYPE_LABELS[type]}
|
label={memoryTypeLabel(type)}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
height: 14, fontSize: 9,
|
height: 14,
|
||||||
|
fontSize: 9,
|
||||||
bgcolor: MEMORY_TYPE_COLORS[type] + '22',
|
bgcolor: MEMORY_TYPE_COLORS[type] + '22',
|
||||||
color: MEMORY_TYPE_COLORS[type],
|
color: MEMORY_TYPE_COLORS[type],
|
||||||
'& .MuiChip-label': { px: 0.5 },
|
'& .MuiChip-label': { px: 0.5 },
|
||||||
@@ -387,7 +492,8 @@ function MemoryItemRow({
|
|||||||
label={`I ${(item.importance ?? 0).toFixed(2)}`}
|
label={`I ${(item.importance ?? 0).toFixed(2)}`}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
height: 14, fontSize: 9,
|
height: 14,
|
||||||
|
fontSize: 9,
|
||||||
bgcolor: importanceColor(item.importance) + '22',
|
bgcolor: importanceColor(item.importance) + '22',
|
||||||
color: importanceColor(item.importance),
|
color: importanceColor(item.importance),
|
||||||
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
||||||
@@ -406,12 +512,26 @@ function MemoryItemRow({
|
|||||||
className="delete-btn"
|
className="delete-btn"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => onDelete(type, getId(item))}
|
onClick={() => onDelete(type, getId(item))}
|
||||||
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
|
sx={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 150ms',
|
||||||
|
p: 0.25,
|
||||||
|
'&:hover': { color: 'error.main' },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={11} />
|
<Trash2 size={11} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'text.primary',
|
||||||
|
display: 'block',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{truncate(item.content, 100)}
|
{truncate(item.content, 100)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -428,17 +548,22 @@ function MemorySearchItem({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 1, py: 0.75, borderRadius: 1,
|
px: 1,
|
||||||
bgcolor: 'background.default', border: '1px solid', borderColor: 'divider',
|
py: 0.75,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
'&:hover .delete-btn': { opacity: 1 },
|
'&:hover .delete-btn': { opacity: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
|
<Stack direction="row" spacing={0.5} sx={{ mb: 0.25, alignItems: 'center' }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={MEMORY_TYPE_LABELS[item.type]}
|
label={memoryTypeLabel(item.type)}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
height: 14, fontSize: 9,
|
height: 14,
|
||||||
|
fontSize: 9,
|
||||||
bgcolor: MEMORY_TYPE_COLORS[item.type] + '22',
|
bgcolor: MEMORY_TYPE_COLORS[item.type] + '22',
|
||||||
color: MEMORY_TYPE_COLORS[item.type],
|
color: MEMORY_TYPE_COLORS[item.type],
|
||||||
'& .MuiChip-label': { px: 0.5 },
|
'& .MuiChip-label': { px: 0.5 },
|
||||||
@@ -448,8 +573,10 @@ function MemorySearchItem({
|
|||||||
label={`R ${(item.relevanceScore ?? 0).toFixed(2)}`}
|
label={`R ${(item.relevanceScore ?? 0).toFixed(2)}`}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
height: 14, fontSize: 9,
|
height: 14,
|
||||||
bgcolor: '#22d3ee22', color: '#22d3ee',
|
fontSize: 9,
|
||||||
|
bgcolor: '#22d3ee22',
|
||||||
|
color: '#22d3ee',
|
||||||
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -457,14 +584,18 @@ function MemorySearchItem({
|
|||||||
label={`I ${(item.importance ?? 0).toFixed(2)}`}
|
label={`I ${(item.importance ?? 0).toFixed(2)}`}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
height: 14, fontSize: 9,
|
height: 14,
|
||||||
|
fontSize: 9,
|
||||||
bgcolor: importanceColor(item.importance) + '22',
|
bgcolor: importanceColor(item.importance) + '22',
|
||||||
color: importanceColor(item.importance),
|
color: importanceColor(item.importance),
|
||||||
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
'& .MuiChip-label': { px: 0.5, fontFamily: 'monospace' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{item.createdAt > 0 && (
|
{item.createdAt > 0 && (
|
||||||
<Typography variant="caption" sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ ml: 'auto', fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
{formatTime(item.createdAt)}
|
{formatTime(item.createdAt)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
@@ -473,12 +604,26 @@ function MemorySearchItem({
|
|||||||
size="small"
|
size="small"
|
||||||
aria-label="删除该记忆"
|
aria-label="删除该记忆"
|
||||||
onClick={() => onDelete(item.type, item.id)}
|
onClick={() => onDelete(item.type, item.id)}
|
||||||
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, '&:hover': { color: 'error.main' } }}
|
sx={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 150ms',
|
||||||
|
p: 0.25,
|
||||||
|
'&:hover': { color: 'error.main' },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={11} />
|
<Trash2 size={11} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="caption" sx={{ fontSize: 11, color: 'text.primary', display: 'block', lineHeight: 1.4, wordBreak: 'break-word' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'text.primary',
|
||||||
|
display: 'block',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{truncate(item.content, 100)}
|
{truncate(item.content, 100)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -27,8 +27,19 @@ import { ArrowRight, ArrowLeft, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
|||||||
import { useUIStore } from '@renderer/stores/ui-store';
|
import { useUIStore } from '@renderer/stores/ui-store';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||||
import { PROVIDER_URLS } from '@renderer/components/settings/useConfig';
|
import { PROVIDER_URLS } from '@renderer/components/settings/useConfig';
|
||||||
|
// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||||
|
import { t } from '@renderer/lib/i18n';
|
||||||
|
import '@renderer/lib/i18n-strings';
|
||||||
|
|
||||||
const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用'];
|
// v0.7.3 P4-3: 步骤文案渲染时求值(i18next 字典注册是异步的,模块顶层固化会拿到 key 本体)
|
||||||
|
const STEP_KEYS = [
|
||||||
|
'onboarding.step.welcome',
|
||||||
|
'onboarding.step.llm',
|
||||||
|
'onboarding.step.agent',
|
||||||
|
'onboarding.step.workspace',
|
||||||
|
'onboarding.step.done',
|
||||||
|
] as const;
|
||||||
|
const STEPS = STEP_KEYS.map((k) => k); // 供长度/索引判断
|
||||||
|
|
||||||
// #48 修复: Onboarding 进度持久化 key
|
// #48 修复: Onboarding 进度持久化 key
|
||||||
const ONBOARDING_PROGRESS_KEY = 'onboarding_progress';
|
const ONBOARDING_PROGRESS_KEY = 'onboarding_progress';
|
||||||
@@ -152,7 +163,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
if (r && !r.success) {
|
if (r && !r.success) {
|
||||||
console.error('[OnboardingWizard]', 'Batch config save failed:', r.error);
|
console.error('[OnboardingWizard]', 'Batch config save failed:', r.error);
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试'))
|
.then((mod) => mod.default.error(r.error ?? t('onboarding.toast.saveFailed')))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -177,7 +188,11 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
|
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
|
||||||
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
|
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`))
|
.then((mod) =>
|
||||||
|
mod.default.error(
|
||||||
|
t('onboarding.toast.saveFailedWithReason', { message: (err as Error).message }),
|
||||||
|
),
|
||||||
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -194,9 +209,9 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
alternativeLabel
|
alternativeLabel
|
||||||
sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}
|
sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}
|
||||||
>
|
>
|
||||||
{STEPS.map((s) => (
|
{STEP_KEYS.map((key) => (
|
||||||
<Step key={s}>
|
<Step key={key}>
|
||||||
<StepLabel>{s}</StepLabel>
|
<StepLabel>{t(key)}</StepLabel>
|
||||||
</Step>
|
</Step>
|
||||||
))}
|
))}
|
||||||
</Stepper>
|
</Stepper>
|
||||||
@@ -219,21 +234,21 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }}
|
sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
欢迎使用 MetonaAI Desktop
|
{t('onboarding.welcome.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||||
生产级通用 AI Agent 智能体桌面应用,支持多轮对话、工具调用、记忆系统和 MCP 协议集成。
|
{t('onboarding.welcome.body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption">让我们花 1 分钟完成初始配置。</Typography>
|
<Typography variant="caption">{t('onboarding.welcome.hint')}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<Box sx={{ width: '100%' }}>
|
<Box sx={{ width: '100%' }}>
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
配置 LLM Provider
|
{t('onboarding.llm.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||||
选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。
|
{t('onboarding.llm.body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<FormControl size="small">
|
<FormControl size="small">
|
||||||
@@ -279,11 +294,11 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="API Key"
|
label={t('llm.apiKey.label')}
|
||||||
type={showKey ? 'text' : 'password'}
|
type={showKey ? 'text' : 'password'}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
placeholder="sk-...(本地模型可留空)"
|
placeholder={t('onboarding.llm.apiKey.placeholder')}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
@@ -299,7 +314,9 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label={
|
label={
|
||||||
provider === 'ollama' ? '上下文长度 (num_ctx)' : '上下文窗口 (contextWindow)'
|
provider === 'ollama'
|
||||||
|
? t('onboarding.llm.ctx.ollama')
|
||||||
|
: t('onboarding.llm.ctx.window')
|
||||||
}
|
}
|
||||||
type="number"
|
type="number"
|
||||||
value={contextWindow ?? ''}
|
value={contextWindow ?? ''}
|
||||||
@@ -309,17 +326,17 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
}}
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
provider === 'ollama'
|
provider === 'ollama'
|
||||||
? '默认由模型决定(如 2048、4096、128000)'
|
? t('onboarding.llm.ctx.ollamaPlaceholder')
|
||||||
: '如 64000、128000、1000000'
|
: t('onboarding.llm.ctx.placeholder')
|
||||||
}
|
}
|
||||||
slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }}
|
slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }}
|
||||||
error={ctxError}
|
error={ctxError}
|
||||||
helperText={
|
helperText={
|
||||||
ctxError
|
ctxError
|
||||||
? `最小值为 ${ctxMin}`
|
? t('onboarding.llm.ctx.minError', { min: ctxMin })
|
||||||
: provider === 'ollama'
|
: provider === 'ollama'
|
||||||
? ' '
|
? ' '
|
||||||
: '用于上下文压缩判断,不传给 API'
|
: t('onboarding.llm.ctx.helper')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
|
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
|
||||||
@@ -333,9 +350,9 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Stack>
|
<Stack>
|
||||||
<Typography variant="body2">启用多模态(图片输入)</Typography>
|
<Typography variant="body2">{t('onboarding.llm.multimodal.label')}</Typography>
|
||||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
开启后可在输入框上传图片;DeepSeek 需 vision 系列模型
|
{t('onboarding.llm.multimodal.helper')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
@@ -347,10 +364,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<Box sx={{ width: '100%' }}>
|
<Box sx={{ width: '100%' }}>
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
自定义 Agent
|
{t('onboarding.agent.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||||
编辑工作空间中的 SOUL.md 文件来定义 Agent 的身份和性格。
|
{t('onboarding.agent.body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -365,11 +382,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ color: '#e1e4ed' }}>SOUL.md</strong> — 定义 Agent
|
<strong style={{ color: '#e1e4ed' }}>{t('onboarding.agent.soulLabel')}</strong>
|
||||||
的身份、性格、核心价值观
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>
|
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>
|
||||||
此步骤可稍后在工作空间目录中完成。
|
{t('onboarding.agent.soulHint')}
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -377,17 +393,17 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<Box sx={{ width: '100%' }}>
|
<Box sx={{ width: '100%' }}>
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
工作空间
|
{t('onboarding.workspace.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||||
选择工作空间目录,或使用默认路径。
|
{t('onboarding.workspace.body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}>
|
<Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
value={workspacePath}
|
value={workspacePath}
|
||||||
onChange={(e) => setWorkspacePath(e.target.value)}
|
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||||
placeholder="~/MetonaWorkspaces/default/"
|
placeholder={t('onboarding.workspace.placeholder')}
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -401,17 +417,17 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[OnboardingWizard]', err);
|
console.error('[OnboardingWizard]', err);
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.error('选择文件夹失败'))
|
.then((mod) => mod.default.error(t('onboarding.toast.folderFailed')))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
选择文件夹
|
{t('onboarding.workspace.pick')}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
包含 SOUL.md、MEMORY.md 两个必需文件,首次打开时自动创建。
|
{t('onboarding.workspace.helper')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -419,12 +435,12 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
<Box sx={{ textAlign: 'center' }}>
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
<CheckCircle size={48} style={{ color: '#34d399', margin: '0 auto 16px' }} />
|
<CheckCircle size={48} style={{ color: '#34d399', margin: '0 auto 16px' }} />
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
配置完成!
|
{t('onboarding.done.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
|
||||||
MetonaAI Desktop 已准备就绪。开始与你的 AI Agent 对话吧!
|
{t('onboarding.done.body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption">按 Ctrl+Enter 发送消息,输入 / 查看命令列表</Typography>
|
<Typography variant="caption">{t('onboarding.done.hint')}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -445,7 +461,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
size="small"
|
size="small"
|
||||||
sx={{ color: 'text.secondary' }}
|
sx={{ color: 'text.secondary' }}
|
||||||
>
|
>
|
||||||
上一步
|
{t('onboarding.prev')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -453,7 +469,7 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{step === STEPS.length - 1 ? '开始使用' : '下一步'}
|
{step === STEPS.length - 1 ? t('onboarding.finish') : t('onboarding.next')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function AgentSettings() {
|
|||||||
const [timeout, setTimeout_] = useConfig('agent.totalTimeoutMs', 600000);
|
const [timeout, setTimeout_] = useConfig('agent.totalTimeoutMs', 600000);
|
||||||
const [thinking, setThinking] = useConfig('agent.enableThinking', true);
|
const [thinking, setThinking] = useConfig('agent.enableThinking', true);
|
||||||
const [thinkingEffort, setThinkingEffort] = useConfig('agent.thinkingEffort', 'high');
|
const [thinkingEffort, setThinkingEffort] = useConfig('agent.thinkingEffort', 'high');
|
||||||
|
// v0.7.3 P3-1: REFLECTING 状态开关(引擎侧 agent.enableReflection,默认关闭)
|
||||||
|
const [reflection, setReflection] = useConfig('agent.enableReflection', false);
|
||||||
const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000);
|
const [confirmTimeout, setConfirmTimeout] = useConfig('agent.confirmationTimeoutMs', 120000);
|
||||||
const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000);
|
const [toolExecTimeout, setToolExecTimeout] = useConfig('agent.toolExecutionTimeoutMs', 120000);
|
||||||
|
|
||||||
@@ -107,6 +109,27 @@ export function AgentSettings() {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
|
{/* v0.7.3 P3-1: enableReflection 接线(此前为死配置)— 引擎 REFLECTING 状态开关 */}
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={reflection}
|
||||||
|
onChange={(e) => setReflection(e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="body2">
|
||||||
|
启用反思阶段(REFLECTING)
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ display: 'block', color: 'text.disabled', fontSize: 10 }}
|
||||||
|
>
|
||||||
|
每轮工具执行后进入反思状态,存在失败结果时记录告警(不阻断执行)
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import {
|
|||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||||
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
import { PROVIDER_LABELS } from '@renderer/lib/constants';
|
||||||
|
// v0.7.3 P4-3: 文案出层(字典含注册副作用,须在 t() 使用前 import)
|
||||||
|
import { t } from '@renderer/lib/i18n';
|
||||||
|
import '@renderer/lib/i18n-strings';
|
||||||
import { PROVIDER_URLS } from './useConfig';
|
import { PROVIDER_URLS } from './useConfig';
|
||||||
|
|
||||||
/** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */
|
/** v0.7.2 P3-9: 动态模型条目(渲染端子集,与 global.d.ts MetonaModelInfoLite 对齐) */
|
||||||
@@ -99,9 +102,11 @@ export function LLMSettings() {
|
|||||||
if (r.success && r.data) {
|
if (r.success && r.data) {
|
||||||
setModelOptions(r.data);
|
setModelOptions(r.data);
|
||||||
setModelsLoaded(true);
|
setModelsLoaded(true);
|
||||||
|
// v0.7.3 P1-4: 同步能力探测缓存(Ollama supportsVision → 上传门控数据源)
|
||||||
|
useAgentStore.getState().applyModelCapabilities(r.data);
|
||||||
} else {
|
} else {
|
||||||
setModelOptions([]);
|
setModelOptions([]);
|
||||||
setModelsError(r.error ?? '获取模型列表失败');
|
setModelsError(r.error ?? t('llm.models.failed'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setModelOptions([]);
|
setModelOptions([]);
|
||||||
@@ -115,21 +120,21 @@ export function LLMSettings() {
|
|||||||
const name = pullModelName.trim();
|
const name = pullModelName.trim();
|
||||||
if (!name || pulling || !window.metona?.llm?.pullModel) return;
|
if (!name || pulling || !window.metona?.llm?.pullModel) return;
|
||||||
setPulling(true);
|
setPulling(true);
|
||||||
setPullStatus({ label: '连接 Ollama 服务…', percent: null });
|
setPullStatus({ label: t('llm.pull.title'), percent: null });
|
||||||
try {
|
try {
|
||||||
const r = await window.metona.llm.pullModel(name);
|
const r = await window.metona.llm.pullModel(name);
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.success(`模型 ${name} 下载完成`))
|
.then((mod) => mod.default.success(t('llm.pull.done', { name })))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} else if (!r.aborted) {
|
} else if (!r.aborted) {
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.error(`模型下载失败:${r.error ?? '未知错误'}`))
|
.then((mod) => mod.default.error(t('llm.pull.failed', { message: r.error ?? 'unknown' })))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.error(`模型下载失败:${(err as Error).message}`))
|
.then((mod) => mod.default.error(t('llm.pull.failed', { message: (err as Error).message })))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} finally {
|
} finally {
|
||||||
// onOllamaPullEnded 也会复位(时序竞态下双保险),此处兜底同步复位
|
// onOllamaPullEnded 也会复位(时序竞态下双保险),此处兜底同步复位
|
||||||
@@ -182,7 +187,7 @@ export function LLMSettings() {
|
|||||||
setBalance(r.data);
|
setBalance(r.data);
|
||||||
} else {
|
} else {
|
||||||
setBalance(null);
|
setBalance(null);
|
||||||
setBalanceError(r.error ?? '查询失败');
|
setBalanceError(r.error ?? t('llm.balance.queryFailed'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setBalance(null);
|
setBalance(null);
|
||||||
@@ -344,7 +349,7 @@ export function LLMSettings() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (hasBlockingError) {
|
if (hasBlockingError) {
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.error('请修正表单中的错误后再保存'))
|
.then((mod) => mod.default.error(t('llm.save.blockedToast')))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -353,7 +358,7 @@ export function LLMSettings() {
|
|||||||
const setBatch = window.metona?.config?.setBatch;
|
const setBatch = window.metona?.config?.setBatch;
|
||||||
if (!setBatch) {
|
if (!setBatch) {
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.error('配置 API 不可用'))
|
.then((mod) => mod.default.error(t('llm.api.unavailable')))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -380,13 +385,13 @@ export function LLMSettings() {
|
|||||||
const r = await setBatch(entries);
|
const r = await setBatch(entries);
|
||||||
if (r && !r.success) {
|
if (r && !r.success) {
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.error(r.error ?? '配置保存失败'))
|
.then((mod) => mod.default.error(r.error ?? t('llm.save.failed.generic')))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
// v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口)
|
// v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口)
|
||||||
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
|
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
|
||||||
import('@metona-team/metona-toast')
|
import('@metona-team/metona-toast')
|
||||||
.then((mod) => mod.default.success('配置已保存'))
|
.then((mod) => mod.default.success(t('llm.save.success')))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
// v0.7.2 P3-9: 保存成功后配置与引擎 adapter 一致 —— 若列表已加载过则静默刷新,
|
// v0.7.2 P3-9: 保存成功后配置与引擎 adapter 一致 —— 若列表已加载过则静默刷新,
|
||||||
// 保证候选列表与刚保存的 Provider/BaseURL 同源
|
// 保证候选列表与刚保存的 Provider/BaseURL 同源
|
||||||
@@ -405,10 +410,10 @@ export function LLMSettings() {
|
|||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
LLM 配置
|
{t('llm.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
加载中...
|
{t('llm.loading')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -419,13 +424,13 @@ export function LLMSettings() {
|
|||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
LLM 配置
|
{t('llm.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormControl size="small">
|
<FormControl size="small">
|
||||||
<InputLabel>Provider</InputLabel>
|
<InputLabel>{t('llm.provider.label')}</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={provider}
|
value={provider}
|
||||||
label="Provider"
|
label={t('llm.provider.label')}
|
||||||
onChange={(e) => handleProviderChange(e.target.value)}
|
onChange={(e) => handleProviderChange(e.target.value)}
|
||||||
>
|
>
|
||||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||||
@@ -438,12 +443,12 @@ export function LLMSettings() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="API Base URL"
|
label={t('llm.baseURL')}
|
||||||
value={baseURL}
|
value={baseURL}
|
||||||
onChange={(e) => setBaseURL(e.target.value)}
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
placeholder="如 https://api.deepseek.com"
|
placeholder="如 https://api.deepseek.com"
|
||||||
error={urlError}
|
error={urlError}
|
||||||
helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '}
|
helperText={urlError ? t('llm.baseURL.error') : ' '}
|
||||||
/>
|
/>
|
||||||
{/* v0.7.2 P3-9: 模型名称 — 自由输入 + 动态模型列表候选(freeSolo) */}
|
{/* v0.7.2 P3-9: 模型名称 — 自由输入 + 动态模型列表候选(freeSolo) */}
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
@@ -495,17 +500,17 @@ export function LLMSettings() {
|
|||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
size="small"
|
size="small"
|
||||||
label="模型名称"
|
label={t('llm.model.label')}
|
||||||
placeholder="如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5"
|
placeholder={t('llm.model.placeholder')}
|
||||||
error={modelHasSpace}
|
error={modelHasSpace}
|
||||||
helperText={
|
helperText={
|
||||||
modelHasSpace
|
modelHasSpace
|
||||||
? '模型名称不能包含空格'
|
? t('llm.model.space')
|
||||||
: modelsError
|
: modelsError
|
||||||
? modelsError
|
? modelsError
|
||||||
: modelsLoaded
|
: modelsLoaded
|
||||||
? ' '
|
? ' '
|
||||||
: '可手动输入,或点击下方按钮获取模型列表'
|
: t('llm.models.hint.unloaded')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -519,10 +524,10 @@ export function LLMSettings() {
|
|||||||
startIcon={modelsLoading ? <CircularProgress size={12} /> : undefined}
|
startIcon={modelsLoading ? <CircularProgress size={12} /> : undefined}
|
||||||
sx={{ minWidth: 120, fontSize: 11 }}
|
sx={{ minWidth: 120, fontSize: 11 }}
|
||||||
>
|
>
|
||||||
获取模型列表
|
{t('llm.models.load')}
|
||||||
</Button>
|
</Button>
|
||||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||||
基于「已保存」的配置查询(修改后请先保存)
|
{t('llm.models.hint')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
|
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
|
||||||
@@ -536,10 +541,9 @@ export function LLMSettings() {
|
|||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<Stack>
|
<Stack>
|
||||||
<Typography variant="body2">启用多模态(图片输入)</Typography>
|
<Typography variant="body2">{t('llm.multimodal.label')}</Typography>
|
||||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
开启后可在输入框上传图片;DeepSeek 需 vision
|
{t('llm.multimodal.helper')}
|
||||||
系列模型。历史会话图片会随上下文回传(最近 10 张)
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
@@ -549,7 +553,7 @@ export function LLMSettings() {
|
|||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="API Key"
|
label={t('llm.apiKey.label')}
|
||||||
type={showKey ? 'text' : 'password'}
|
type={showKey ? 'text' : 'password'}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
@@ -557,7 +561,7 @@ export function LLMSettings() {
|
|||||||
error={apiKeyEmpty}
|
error={apiKeyEmpty}
|
||||||
helperText={
|
helperText={
|
||||||
apiKeyEmpty
|
apiKeyEmpty
|
||||||
? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401`
|
? t('llm.apiKey.required', { provider: PROVIDER_LABELS[provider] ?? provider })
|
||||||
: ' '
|
: ' '
|
||||||
}
|
}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -575,17 +579,17 @@ export function LLMSettings() {
|
|||||||
{provider === 'ollama' && (
|
{provider === 'ollama' && (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="上下文长度 (num_ctx)"
|
label={t('llm.numCtx.label')}
|
||||||
type="number"
|
type="number"
|
||||||
value={numCtx ?? ''}
|
value={numCtx ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setNumCtx(v === '' ? null : Number(v));
|
setNumCtx(v === '' ? null : Number(v));
|
||||||
}}
|
}}
|
||||||
placeholder="默认由模型决定(如 2048、4096、128000)"
|
placeholder={t('llm.numCtx.placeholder')}
|
||||||
slotProps={{ htmlInput: { min: 512, step: 512 } }}
|
slotProps={{ htmlInput: { min: 512, step: 512 } }}
|
||||||
error={numCtxError}
|
error={numCtxError}
|
||||||
helperText={numCtxError ? '最小值为 512' : ' '}
|
helperText={numCtxError ? t('llm.numCtx.error') : ' '}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* ===== v0.7.2 P3-10: Ollama 模型下载(adapter.pullModel 首次接线 UI) ===== */}
|
{/* ===== v0.7.2 P3-10: Ollama 模型下载(adapter.pullModel 首次接线 UI) ===== */}
|
||||||
@@ -601,7 +605,7 @@ export function LLMSettings() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||||
下载 Ollama 模型
|
{t('llm.pull.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -609,7 +613,7 @@ export function LLMSettings() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
value={pullModelName}
|
value={pullModelName}
|
||||||
onChange={(e) => setPullModelName(e.target.value)}
|
onChange={(e) => setPullModelName(e.target.value)}
|
||||||
placeholder="模型名,如 qwen3:8b"
|
placeholder={t('llm.pull.placeholder')}
|
||||||
disabled={pulling}
|
disabled={pulling}
|
||||||
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
|
slotProps={{ input: { sx: { fontFamily: 'monospace', fontSize: 12 } } }}
|
||||||
/>
|
/>
|
||||||
@@ -621,7 +625,7 @@ export function LLMSettings() {
|
|||||||
onClick={handlePullCancel}
|
onClick={handlePullCancel}
|
||||||
sx={{ flexShrink: 0 }}
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
取消
|
{t('llm.pull.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -631,7 +635,7 @@ export function LLMSettings() {
|
|||||||
disabled={!pullModelName.trim()}
|
disabled={!pullModelName.trim()}
|
||||||
sx={{ flexShrink: 0 }}
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
下载
|
{t('llm.pull.download')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -653,7 +657,7 @@ export function LLMSettings() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||||
从 Ollama 服务拉取模型(大模型下载耗时取决于网络,可随时取消);完成后获取模型列表即可见
|
{t('llm.pull.helper')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -661,14 +665,14 @@ export function LLMSettings() {
|
|||||||
{provider === 'deepseek' && (
|
{provider === 'deepseek' && (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="上下文窗口 (contextWindow)"
|
label={t('llm.ctxWindow.label')}
|
||||||
type="number"
|
type="number"
|
||||||
value={dsCtxWindow}
|
value={dsCtxWindow}
|
||||||
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)}
|
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
placeholder="如 64000、128000、1000000"
|
placeholder="如 64000、128000、1000000"
|
||||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
error={dsCtxError}
|
error={dsCtxError}
|
||||||
helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
|
helperText={dsCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -688,17 +692,20 @@ export function LLMSettings() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
账户余额
|
{t('llm.balance.label')}
|
||||||
</Typography>
|
</Typography>
|
||||||
{balanceLoading ? (
|
{balanceLoading ? (
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
查询中...
|
{t('llm.balance.querying')}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : balance ? (
|
) : balance ? (
|
||||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
|
||||||
{balance.totalBalance} {balance.currency}
|
{balance.totalBalance} {balance.currency}
|
||||||
<Typography component="span" variant="caption" sx={{ color: 'text.disabled', ml: 1 }}>
|
<Typography component="span" variant="caption" sx={{ color: 'text.disabled', ml: 1 }}>
|
||||||
(赠送 {balance.grantedBalance} + 充值 {balance.toppedUpBalance})
|
{t('llm.balance.granted', {
|
||||||
|
granted: balance.grantedBalance,
|
||||||
|
topped: balance.toppedUpBalance,
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
@@ -706,7 +713,7 @@ export function LLMSettings() {
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
sx={{ color: balanceError ? 'error.main' : 'text.disabled' }}
|
sx={{ color: balanceError ? 'error.main' : 'text.disabled' }}
|
||||||
>
|
>
|
||||||
{balanceError ?? '未查询(需已保存 API Key)'}
|
{balanceError ?? t('llm.balance.unqueried')}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -716,76 +723,78 @@ export function LLMSettings() {
|
|||||||
disabled={balanceLoading || !apiKey.trim()}
|
disabled={balanceLoading || !apiKey.trim()}
|
||||||
sx={{ ml: 'auto', minWidth: 64, fontSize: 11 }}
|
sx={{ ml: 'auto', minWidth: 64, fontSize: 11 }}
|
||||||
>
|
>
|
||||||
刷新
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
{provider === 'agnes' && (
|
{provider === 'agnes' && (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="上下文窗口 (contextWindow)"
|
label={t('llm.ctxWindow.label')}
|
||||||
type="number"
|
type="number"
|
||||||
value={agnesCtxWindow}
|
value={agnesCtxWindow}
|
||||||
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
|
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
placeholder="如 64000、128000、1000000"
|
placeholder="如 64000、128000、1000000"
|
||||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
error={agnesCtxError}
|
error={agnesCtxError}
|
||||||
helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
|
helperText={agnesCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.helper')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{provider === 'mimo' && (
|
{provider === 'mimo' && (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="上下文窗口 (contextWindow)"
|
label={t('llm.ctxWindow.label')}
|
||||||
type="number"
|
type="number"
|
||||||
value={mimoCtxWindow}
|
value={mimoCtxWindow}
|
||||||
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 1000000)}
|
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
placeholder="如 65536、131072、1000000"
|
placeholder="如 65536、131072、1000000"
|
||||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
error={mimoCtxError}
|
error={mimoCtxError}
|
||||||
helperText={mimoCtxError ? '最小值为 4096' : '默认 1000000(1M),用于上下文压缩判断'}
|
helperText={mimoCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.mimoHelper')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{provider === 'openai' && (
|
{provider === 'openai' && (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="上下文窗口 (contextWindow)"
|
label={t('llm.ctxWindow.label')}
|
||||||
type="number"
|
type="number"
|
||||||
value={oaCtxWindow}
|
value={oaCtxWindow}
|
||||||
onChange={(e) => setOaCtxWindow(Number(e.target.value) || 128000)}
|
onChange={(e) => setOaCtxWindow(Number(e.target.value) || 128000)}
|
||||||
placeholder="如 128000、200000、1000000"
|
placeholder="如 128000、200000、1000000"
|
||||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
error={oaCtxError}
|
error={oaCtxError}
|
||||||
helperText={oaCtxError ? '最小值为 4096' : 'gpt-4o 默认 128K,gpt-4.1 默认 1M'}
|
helperText={oaCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.oaHelper')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{provider === 'anthropic' && (
|
{provider === 'anthropic' && (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="上下文窗口 (contextWindow)"
|
label={t('llm.ctxWindow.label')}
|
||||||
type="number"
|
type="number"
|
||||||
value={anthropicCtxWindow}
|
value={anthropicCtxWindow}
|
||||||
onChange={(e) => setAnthropicCtxWindow(Number(e.target.value) || 200000)}
|
onChange={(e) => setAnthropicCtxWindow(Number(e.target.value) || 200000)}
|
||||||
placeholder="如 200000"
|
placeholder="如 200000"
|
||||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
error={anthropicCtxError}
|
error={anthropicCtxError}
|
||||||
helperText={anthropicCtxError ? '最小值为 4096' : 'Claude 默认 200K'}
|
helperText={
|
||||||
|
anthropicCtxError ? t('llm.ctxWindow.minError') : t('llm.ctxWindow.anthropicHelper')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
|
{/* ===== P1: 故障转移 Provider(主 Provider 请求失败时自动切换) ===== */}
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
故障转移(可选)
|
{t('llm.fallback.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。
|
{t('llm.fallback.helper')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormControl size="small">
|
<FormControl size="small">
|
||||||
<InputLabel>备用 Provider</InputLabel>
|
<InputLabel>{t('llm.fallback.provider')}</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={fbProvider}
|
value={fbProvider}
|
||||||
label="备用 Provider"
|
label={t('llm.fallback.provider')}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setFbProvider(v);
|
setFbProvider(v);
|
||||||
@@ -795,7 +804,7 @@ export function LLMSettings() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="">
|
<MenuItem value="">
|
||||||
<em>禁用</em>
|
<em>{t('llm.fallback.disabled')}</em>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||||
<MenuItem value="agnes">Agnes AI</MenuItem>
|
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||||
@@ -809,14 +818,14 @@ export function LLMSettings() {
|
|||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="备用 Base URL"
|
label={t('llm.fallback.baseURL')}
|
||||||
value={fbBaseURL}
|
value={fbBaseURL}
|
||||||
onChange={(e) => setFbBaseURL(e.target.value)}
|
onChange={(e) => setFbBaseURL(e.target.value)}
|
||||||
placeholder="如 https://api.deepseek.com"
|
placeholder="如 https://api.deepseek.com"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="备用模型名称"
|
label={t('llm.fallback.model')}
|
||||||
value={fbModel}
|
value={fbModel}
|
||||||
onChange={(e) => setFbModel(e.target.value)}
|
onChange={(e) => setFbModel(e.target.value)}
|
||||||
placeholder="如 deepseek-v4-flash"
|
placeholder="如 deepseek-v4-flash"
|
||||||
@@ -824,7 +833,7 @@ export function LLMSettings() {
|
|||||||
{fbProvider !== 'ollama' && (
|
{fbProvider !== 'ollama' && (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
label="备用 API Key"
|
label={t('llm.fallback.apiKey')}
|
||||||
type={showFbKey ? 'text' : 'password'}
|
type={showFbKey ? 'text' : 'password'}
|
||||||
value={fbApiKey}
|
value={fbApiKey}
|
||||||
onChange={(e) => setFbApiKey(e.target.value)}
|
onChange={(e) => setFbApiKey(e.target.value)}
|
||||||
@@ -852,11 +861,11 @@ export function LLMSettings() {
|
|||||||
disabled={saving || hasBlockingError}
|
disabled={saving || hasBlockingError}
|
||||||
startIcon={saving ? <CircularProgress size={12} /> : undefined}
|
startIcon={saving ? <CircularProgress size={12} /> : undefined}
|
||||||
>
|
>
|
||||||
{saving ? '保存中...' : '保存配置'}
|
{saving ? t('llm.saving') : t('llm.save')}
|
||||||
</Button>
|
</Button>
|
||||||
{hasBlockingError && (
|
{hasBlockingError && (
|
||||||
<Typography variant="caption" sx={{ color: 'error.main', fontSize: 11 }}>
|
<Typography variant="caption" sx={{ color: 'error.main', fontSize: 11 }}>
|
||||||
请修正表单错误后再保存
|
{t('llm.save.blocked')}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ import {
|
|||||||
InputLabel,
|
InputLabel,
|
||||||
FormControl,
|
FormControl,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Folder, Copy } from 'lucide-react';
|
import { Folder, Copy, RefreshCw } from 'lucide-react';
|
||||||
import { useConfig } from './useConfig';
|
import { useConfig } from './useConfig';
|
||||||
import { useUIStore } from '@renderer/stores/ui-store';
|
import { useUIStore } 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 { useSessionStore } from '@renderer/stores/session-store';
|
||||||
|
import { formatFileSize } from '@renderer/lib/formatters';
|
||||||
|
|
||||||
export function LogsSettings() {
|
export function LogsSettings() {
|
||||||
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
|
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
|
||||||
@@ -315,6 +316,12 @@ export function LogsSettings() {
|
|||||||
{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}
|
{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* v0.7.3 P3-3: JSONL 录制文件生命周期(统计 + 手动清理) */}
|
||||||
|
<TraceFilesPanel />
|
||||||
|
|
||||||
|
{/* v0.7.3 P3-4: SLO / 健康快照可视化(只读 + 手动刷新) */}
|
||||||
|
<HealthSnapshotPanel />
|
||||||
|
|
||||||
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */}
|
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={confirmClear !== null}
|
open={confirmClear !== null}
|
||||||
@@ -340,3 +347,251 @@ export function LogsSettings() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== v0.7.3 P3-3: JSONL 录制文件统计与清理 =====
|
||||||
|
|
||||||
|
function TraceFilesPanel(): React.JSX.Element {
|
||||||
|
const [stats, setStats] = useState<{ count: number; totalBytes: number } | null>(null);
|
||||||
|
const [pruning, setPruning] = useState(false);
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.logs?.traceStats();
|
||||||
|
if (r?.success && r.data) setStats(r.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LogsSettings] traceStats failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePrune = async () => {
|
||||||
|
setPruning(true);
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.logs?.pruneTraceFiles();
|
||||||
|
if (r?.success) {
|
||||||
|
import('@metona-team/metona-toast')
|
||||||
|
.then((mod) =>
|
||||||
|
mod.default.success(`已清理 ${r.data?.deleted ?? 0} 个旧录制文件(保留最近 200 个)`),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
void loadStats();
|
||||||
|
} else {
|
||||||
|
import('@metona-team/metona-toast')
|
||||||
|
.then((mod) => mod.default.error(r?.error ?? '清理失败'))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
import('@metona-team/metona-toast')
|
||||||
|
.then((mod) => mod.default.error(`清理失败:${(err as Error).message}`))
|
||||||
|
.catch(() => {});
|
||||||
|
} finally {
|
||||||
|
setPruning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
|
会话录制文件(TRACE JSONL)
|
||||||
|
</Typography>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||||
|
{stats
|
||||||
|
? `${stats.count} 个文件 · 共 ${formatFileSize(stats.totalBytes)}(保留策略:最近 200 个,启动时自动清理)`
|
||||||
|
: '统计中...'}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<RefreshCw size={14} />}
|
||||||
|
onClick={() => void loadStats()}
|
||||||
|
>
|
||||||
|
刷新统计
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={handlePrune}
|
||||||
|
disabled={pruning || !stats || stats.count === 0}
|
||||||
|
>
|
||||||
|
{pruning ? '清理中...' : '清理旧录制文件'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== v0.7.3 P3-4: SLO / 健康快照可视化 =====
|
||||||
|
|
||||||
|
function HealthSnapshotPanel(): React.JSX.Element {
|
||||||
|
const [snapshot, setSnapshot] = useState<MetonaHealthSnapshot | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [chainResult, setChainResult] = useState<{
|
||||||
|
valid: boolean;
|
||||||
|
verified: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const loadSnapshot = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.app?.getHealthSnapshot();
|
||||||
|
if (r?.success && r.data) setSnapshot(r.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LogsSettings] healthSnapshot failed:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSnapshot();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVerifyChain = async () => {
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.audit?.verifyChain();
|
||||||
|
if (r?.success) {
|
||||||
|
setChainResult({
|
||||||
|
valid: r.valid,
|
||||||
|
verified: r.verifiedRecords,
|
||||||
|
total: r.totalRecords,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
import('@metona-team/metona-toast')
|
||||||
|
.then((mod) => mod.default.error(r?.error ?? '校验失败'))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
import('@metona-team/metona-toast')
|
||||||
|
.then((mod) => mod.default.error(`校验失败:${(err as Error).message}`))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtPct = (v: number): string => `${(v * 100).toFixed(2)}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
|
运行健康(SLO / 健康检查)
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
sx={{ ml: 'auto', fontSize: 10, minWidth: 60 }}
|
||||||
|
startIcon={<RefreshCw size={12} />}
|
||||||
|
onClick={() => void loadSnapshot()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{!snapshot ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 11 }}>
|
||||||
|
{loading ? '加载中...' : '暂无快照'}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'secondary.main',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<HealthRow
|
||||||
|
label="请求错误率(5 分钟窗口)"
|
||||||
|
value={`${fmtPct(snapshot.slo.errorRate)}(${snapshot.slo.totalRequests} 次请求)`}
|
||||||
|
warn={snapshot.slo.violated}
|
||||||
|
/>
|
||||||
|
<HealthRow
|
||||||
|
label="延迟 P50 / P95 / P99"
|
||||||
|
value={`${Math.round(snapshot.slo.percentiles['P50'] ?? 0)} / ${Math.round(
|
||||||
|
snapshot.slo.percentiles['P95'] ?? 0,
|
||||||
|
)} / ${Math.round(snapshot.slo.percentiles['P99'] ?? 0)} ms`}
|
||||||
|
/>
|
||||||
|
<HealthRow
|
||||||
|
label="SLO 燃烧速率(目标 99.9%)"
|
||||||
|
value={snapshot.slo.burnRate.toFixed(2)}
|
||||||
|
warn={snapshot.slo.violated}
|
||||||
|
/>
|
||||||
|
<HealthRow
|
||||||
|
label="健康检查"
|
||||||
|
value={
|
||||||
|
snapshot.health
|
||||||
|
? snapshot.health.healthy
|
||||||
|
? '全部通过'
|
||||||
|
: `异常:${snapshot.health.checks
|
||||||
|
.filter((c) => !c.healthy)
|
||||||
|
.map((c) => c.name)
|
||||||
|
.join(', ')}`
|
||||||
|
: '启动后尚未执行(周期 60s)'
|
||||||
|
}
|
||||||
|
warn={snapshot.health ? !snapshot.health.healthy : false}
|
||||||
|
/>
|
||||||
|
{chainResult && (
|
||||||
|
<HealthRow
|
||||||
|
label="审计日志链校验"
|
||||||
|
value={
|
||||||
|
chainResult.valid
|
||||||
|
? `通过(${chainResult.verified}/${chainResult.total} 条)`
|
||||||
|
: `已篡改!首个异常记录 #${chainResult.total}`
|
||||||
|
}
|
||||||
|
warn={!chainResult.valid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mt: 1, fontSize: 10, minWidth: 100 }}
|
||||||
|
onClick={handleVerifyChain}
|
||||||
|
>
|
||||||
|
校验审计日志链
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
warn,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
warn?: boolean;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 11 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
ml: 'auto',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: warn ? 'error.main' : 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,9 +131,16 @@ export function MCPSettings() {
|
|||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
connected: 'success.main',
|
connected: 'success.main',
|
||||||
connecting: 'warning.main',
|
connecting: 'warning.main',
|
||||||
|
// v0.7.3 P4-2: reconnecting —— 自动重连排程中(信息态,非故障)
|
||||||
|
reconnecting: 'info.main',
|
||||||
disconnected: 'text.secondary',
|
disconnected: 'text.secondary',
|
||||||
error: 'error.main',
|
error: 'error.main',
|
||||||
};
|
};
|
||||||
|
/** v0.7.3 P4-2: reconnecting 状态显示第 N/3 次尝试 */
|
||||||
|
const statusLabel = (s: { status: string; reconnectAttempt?: number }): string =>
|
||||||
|
s.status === 'reconnecting' && s.reconnectAttempt
|
||||||
|
? `${s.status} (${s.reconnectAttempt}/3)`
|
||||||
|
: s.status;
|
||||||
|
|
||||||
// L-11 修复: 确认移除 MCP 服务
|
// L-11 修复: 确认移除 MCP 服务
|
||||||
// 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭
|
// 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭
|
||||||
@@ -185,7 +192,7 @@ export function MCPSettings() {
|
|||||||
{s.name}
|
{s.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
|
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
|
||||||
{s.status}
|
{statusLabel(s)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{s.toolCount > 0 && (
|
{s.toolCount > 0 && (
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
|||||||
@@ -103,6 +103,144 @@ registerTranslations('zh-CN', {
|
|||||||
'monitor.sub.running': '运行中',
|
'monitor.sub.running': '运行中',
|
||||||
'monitor.sub.completed': '已完成',
|
'monitor.sub.completed': '已完成',
|
||||||
'monitor.sub.error': '失败',
|
'monitor.sub.error': '失败',
|
||||||
|
|
||||||
|
// ===== 输入框(ChatInput)—— v0.7.3 P4-3 =====
|
||||||
|
'input.placeholder.ready': '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)',
|
||||||
|
'input.placeholder.toolsLoading': '工具加载中...',
|
||||||
|
'input.placeholder.configLoading': '正在加载配置...',
|
||||||
|
'input.send': '发送',
|
||||||
|
'input.abort': '中断',
|
||||||
|
'input.attach.image': '附加文件(图片/文本/代码)',
|
||||||
|
'input.attach.textOnly.model': '附加文件(文本/代码)— 当前模型不支持图片',
|
||||||
|
'input.attach.textOnly.toggle': '附加文件(文本/代码)— 多模态未开启(设置 → LLM 配置)',
|
||||||
|
'input.image.skipped.model':
|
||||||
|
'当前模型不支持图片({{provider}} 需多模态模型),已跳过 {{count}} 个图片文件',
|
||||||
|
'input.image.skipped.toggle': '多模态未开启(设置 → LLM 配置),已跳过 {{count}} 个图片文件',
|
||||||
|
'input.image.skipped.probe': '当前模型不支持图片(能力探测),已跳过 {{count}} 个图片文件',
|
||||||
|
'input.file.readFailed': '文件读取失败,请检查文件是否损坏或被锁定',
|
||||||
|
'input.file.partialFailed': '{{count}} 个文件读取失败,已跳过',
|
||||||
|
'input.text.truncated': '文本附件 "{{name}}" 超过 512KB,已截断为前 512KB',
|
||||||
|
'input.text.truncatedNote':
|
||||||
|
'\n\n[... 文件内容已截断:原始大小 {{size}},仅包含前 {{limit}}。如需完整内容请分段处理 ...]',
|
||||||
|
'input.toast.toolsLoading': '工具正在加载中,请稍候...',
|
||||||
|
'input.slash.tool': '选择工具',
|
||||||
|
'input.slash.memory': '搜索记忆',
|
||||||
|
'input.slash.clear': '清空会话',
|
||||||
|
'input.slash.export': '导出 Markdown',
|
||||||
|
|
||||||
|
// ===== LLM 配置(LLMSettings)—— v0.7.3 P4-3 =====
|
||||||
|
'llm.title': 'LLM 配置',
|
||||||
|
'llm.loading': '加载中...',
|
||||||
|
'llm.provider.label': 'Provider',
|
||||||
|
'llm.baseURL': 'API Base URL',
|
||||||
|
'llm.baseURL.error': '需以 http:// 或 https:// 开头',
|
||||||
|
'llm.model.label': '模型名称',
|
||||||
|
'llm.model.placeholder': '如 deepseek-v4-pro、gpt-4o、claude-sonnet-4-5',
|
||||||
|
'llm.model.space': '模型名称不能包含空格',
|
||||||
|
'llm.models.load': '获取模型列表',
|
||||||
|
'llm.models.hint': '基于「已保存」的配置查询(修改后请先保存)',
|
||||||
|
'llm.models.hint.unloaded': '可手动输入,或点击下方按钮获取模型列表',
|
||||||
|
'llm.models.failed': '获取模型列表失败',
|
||||||
|
'llm.multimodal.label': '启用多模态(图片输入)',
|
||||||
|
'llm.multimodal.helper':
|
||||||
|
'开启后可在输入框上传图片;DeepSeek 需 vision 系列模型。历史会话图片会随上下文回传(最近 10 张)',
|
||||||
|
'llm.apiKey.label': 'API Key',
|
||||||
|
'llm.apiKey.required': '必填,未填 {{provider}} 的 API Key 会 401',
|
||||||
|
'llm.numCtx.label': '上下文长度 (num_ctx)',
|
||||||
|
'llm.numCtx.placeholder': '默认由模型决定(如 2048、4096、128000)',
|
||||||
|
'llm.numCtx.error': '最小值为 512',
|
||||||
|
'llm.ctxWindow.label': '上下文窗口 (contextWindow)',
|
||||||
|
'llm.ctxWindow.placeholder': '如 64000、128000、1000000',
|
||||||
|
'llm.ctxWindow.minError': '最小值为 4096',
|
||||||
|
'llm.ctxWindow.helper': '用于上下文压缩判断,不传给 API',
|
||||||
|
'llm.ctxWindow.mimoHelper': '默认 1000000(1M),用于上下文压缩判断',
|
||||||
|
'llm.ctxWindow.oaHelper': 'gpt-4o 默认 128K,gpt-4.1 默认 1M',
|
||||||
|
'llm.ctxWindow.anthropicHelper': 'Claude 默认 200K',
|
||||||
|
'llm.pull.title': '下载 Ollama 模型',
|
||||||
|
'llm.pull.placeholder': '模型名,如 qwen3:8b',
|
||||||
|
'llm.pull.download': '下载',
|
||||||
|
'llm.pull.cancel': '取消',
|
||||||
|
'llm.pull.helper':
|
||||||
|
'从 Ollama 服务拉取模型(大模型下载耗时取决于网络,可随时取消);完成后获取模型列表即可见',
|
||||||
|
'llm.pull.done': '模型 {{name}} 下载完成',
|
||||||
|
'llm.pull.failed': '模型下载失败:{{message}}',
|
||||||
|
'llm.balance.label': '账户余额',
|
||||||
|
'llm.balance.querying': '查询中...',
|
||||||
|
'llm.balance.unqueried': '未查询(需已保存 API Key)',
|
||||||
|
'llm.balance.granted': '(赠送 {{granted}} + 充值 {{topped}})',
|
||||||
|
'llm.balance.queryFailed': '查询失败',
|
||||||
|
'llm.fallback.title': '故障转移(可选)',
|
||||||
|
'llm.fallback.helper':
|
||||||
|
'主 Provider 请求失败(重试耗尽或密钥失效)时自动切换到备用 Provider 重发。留空禁用。',
|
||||||
|
'llm.fallback.provider': '备用 Provider',
|
||||||
|
'llm.fallback.disabled': '禁用',
|
||||||
|
'llm.fallback.baseURL': '备用 Base URL',
|
||||||
|
'llm.fallback.model': '备用模型名称',
|
||||||
|
'llm.fallback.apiKey': '备用 API Key',
|
||||||
|
'llm.save': '保存配置',
|
||||||
|
'llm.saving': '保存中...',
|
||||||
|
'llm.save.blocked': '请修正表单错误后再保存',
|
||||||
|
'llm.save.blockedToast': '请修正表单中的错误后再保存',
|
||||||
|
'llm.save.success': '配置已保存',
|
||||||
|
'llm.save.failed': '保存失败:{{message}}',
|
||||||
|
'llm.api.unavailable': '配置 API 不可用',
|
||||||
|
'llm.save.failed.generic': '配置保存失败',
|
||||||
|
|
||||||
|
// ===== 记忆库(MemoryViewer)—— v0.7.3 P4-3 =====
|
||||||
|
'memory.search.title': '记忆搜索',
|
||||||
|
'memory.list.title': '记忆库',
|
||||||
|
'memory.results': '{{count}} 条结果',
|
||||||
|
'memory.total': '{{count}} 条',
|
||||||
|
'memory.search.placeholder': '搜索记忆...',
|
||||||
|
'memory.search.clear': '清除',
|
||||||
|
'memory.searching': '搜索中...',
|
||||||
|
'memory.search.empty': '无匹配结果',
|
||||||
|
'memory.empty': '暂无记忆数据',
|
||||||
|
'memory.loadFailed': '加载记忆失败',
|
||||||
|
'memory.deleteFailed': '删除失败',
|
||||||
|
'memory.type.episodic': '情景记忆',
|
||||||
|
'memory.type.semantic': '语义记忆',
|
||||||
|
'memory.type.working': '工作记忆',
|
||||||
|
|
||||||
|
// ===== 引导向导(OnboardingWizard)—— v0.7.3 P4-3 =====
|
||||||
|
'onboarding.step.welcome': '欢迎',
|
||||||
|
'onboarding.step.llm': '配置 LLM',
|
||||||
|
'onboarding.step.agent': '自定义 Agent',
|
||||||
|
'onboarding.step.workspace': '工作空间',
|
||||||
|
'onboarding.step.done': '开始使用',
|
||||||
|
'onboarding.prev': '上一步',
|
||||||
|
'onboarding.next': '下一步',
|
||||||
|
'onboarding.finish': '开始使用',
|
||||||
|
'onboarding.welcome.title': '欢迎使用 MetonaAI Desktop',
|
||||||
|
'onboarding.welcome.body':
|
||||||
|
'生产级通用 AI Agent 智能体桌面应用,支持多轮对话、工具调用、记忆系统和 MCP 协议集成。',
|
||||||
|
'onboarding.welcome.hint': '让我们花 1 分钟完成初始配置。',
|
||||||
|
'onboarding.llm.title': '配置 LLM Provider',
|
||||||
|
'onboarding.llm.body': '选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。',
|
||||||
|
'onboarding.llm.multimodal.label': '启用多模态(图片输入)',
|
||||||
|
'onboarding.llm.multimodal.helper': '开启后可在输入框上传图片;DeepSeek 需 vision 系列模型',
|
||||||
|
'onboarding.llm.apiKey.placeholder': 'sk-...(本地模型可留空)',
|
||||||
|
'onboarding.llm.ctx.ollama': '上下文长度 (num_ctx)',
|
||||||
|
'onboarding.llm.ctx.window': '上下文窗口 (contextWindow)',
|
||||||
|
'onboarding.llm.ctx.ollamaPlaceholder': '默认由模型决定(如 2048、4096、128000)',
|
||||||
|
'onboarding.llm.ctx.placeholder': '如 64000、128000、1000000',
|
||||||
|
'onboarding.llm.ctx.helper': '用于上下文压缩判断,不传给 API',
|
||||||
|
'onboarding.llm.ctx.minError': '最小值为 {{min}}',
|
||||||
|
'onboarding.agent.title': '自定义 Agent',
|
||||||
|
'onboarding.agent.body': '编辑工作空间中的 SOUL.md 文件来定义 Agent 的身份和性格。',
|
||||||
|
'onboarding.agent.soulLabel': 'SOUL.md — 定义 Agent 的身份、性格、核心价值观',
|
||||||
|
'onboarding.agent.soulHint': '此步骤可稍后在工作空间目录中完成。',
|
||||||
|
'onboarding.workspace.title': '工作空间',
|
||||||
|
'onboarding.workspace.body': '选择工作空间目录,或使用默认路径。',
|
||||||
|
'onboarding.workspace.pick': '选择文件夹',
|
||||||
|
'onboarding.workspace.placeholder': '~/MetonaWorkspaces/default/',
|
||||||
|
'onboarding.workspace.helper': '包含 SOUL.md、MEMORY.md 两个必需文件,首次打开时自动创建。',
|
||||||
|
'onboarding.done.title': '配置完成!',
|
||||||
|
'onboarding.done.body': 'MetonaAI Desktop 已准备就绪。开始与你的 AI Agent 对话吧!',
|
||||||
|
'onboarding.done.hint': '按 Ctrl+Enter 发送消息,输入 / 查看命令列表',
|
||||||
|
'onboarding.toast.saveFailed': '配置保存失败,请重试',
|
||||||
|
'onboarding.toast.saveFailedWithReason': '保存配置失败:{{message}}',
|
||||||
|
'onboarding.toast.folderFailed': '选择文件夹失败',
|
||||||
});
|
});
|
||||||
|
|
||||||
registerTranslations('en-US', {
|
registerTranslations('en-US', {
|
||||||
@@ -195,4 +333,149 @@ registerTranslations('en-US', {
|
|||||||
'monitor.sub.running': 'Running',
|
'monitor.sub.running': 'Running',
|
||||||
'monitor.sub.completed': 'Completed',
|
'monitor.sub.completed': 'Completed',
|
||||||
'monitor.sub.error': 'Failed',
|
'monitor.sub.error': 'Failed',
|
||||||
|
|
||||||
|
// ===== 输入框(ChatInput)=====
|
||||||
|
'input.placeholder.ready':
|
||||||
|
'Type a message... (Cmd/Ctrl+Enter to send, Cmd/Ctrl+Shift+Enter for newline, / for commands)',
|
||||||
|
'input.placeholder.toolsLoading': 'Loading tools...',
|
||||||
|
'input.placeholder.configLoading': 'Loading configuration...',
|
||||||
|
'input.send': 'Send',
|
||||||
|
'input.abort': 'Stop',
|
||||||
|
'input.attach.image': 'Attach files (images/text/code)',
|
||||||
|
'input.attach.textOnly.model': 'Attach files (text/code) — current model has no image support',
|
||||||
|
'input.attach.textOnly.toggle': 'Attach files (text/code) — multimodal disabled (Settings → LLM)',
|
||||||
|
'input.image.skipped.model':
|
||||||
|
'Current model has no image support ({{provider}} requires a multimodal model); {{count}} image file(s) skipped',
|
||||||
|
'input.image.skipped.toggle':
|
||||||
|
'Multimodal is disabled (Settings → LLM); {{count}} image file(s) skipped',
|
||||||
|
'input.image.skipped.probe':
|
||||||
|
'Current model has no image support (capability probe); {{count}} image file(s) skipped',
|
||||||
|
'input.file.readFailed': 'Failed to read files — check whether they are corrupted or locked',
|
||||||
|
'input.file.partialFailed': '{{count}} file(s) failed to read and were skipped',
|
||||||
|
'input.text.truncated':
|
||||||
|
'Text attachment "{{name}}" exceeds 512KB and was truncated to the first 512KB',
|
||||||
|
'input.text.truncatedNote':
|
||||||
|
'\n\n[... File content truncated: original size {{size}}, only the first {{limit}} included. Process in segments if you need the full content ...]',
|
||||||
|
'input.toast.toolsLoading': 'Tools are still loading, please wait...',
|
||||||
|
'input.slash.tool': 'Pick a tool',
|
||||||
|
'input.slash.memory': 'Search memory',
|
||||||
|
'input.slash.clear': 'Clear session',
|
||||||
|
'input.slash.export': 'Export Markdown',
|
||||||
|
|
||||||
|
// ===== LLM 配置(LLMSettings)=====
|
||||||
|
'llm.title': 'LLM Configuration',
|
||||||
|
'llm.loading': 'Loading...',
|
||||||
|
'llm.provider.label': 'Provider',
|
||||||
|
'llm.baseURL': 'API Base URL',
|
||||||
|
'llm.baseURL.error': 'Must start with http:// or https://',
|
||||||
|
'llm.model.label': 'Model Name',
|
||||||
|
'llm.model.placeholder': 'e.g. deepseek-v4-pro, gpt-4o, claude-sonnet-4-5',
|
||||||
|
'llm.model.space': 'Model name must not contain spaces',
|
||||||
|
'llm.models.load': 'Fetch model list',
|
||||||
|
'llm.models.hint': 'Queries the saved configuration (save changes first)',
|
||||||
|
'llm.models.hint.unloaded': 'Type manually, or fetch the model list below',
|
||||||
|
'llm.models.failed': 'Failed to fetch model list',
|
||||||
|
'llm.multimodal.label': 'Enable multimodal (image input)',
|
||||||
|
'llm.multimodal.helper':
|
||||||
|
'Allows uploading images; DeepSeek requires a vision model. Images from history are replayed (last 10)',
|
||||||
|
'llm.apiKey.label': 'API Key',
|
||||||
|
'llm.apiKey.required': 'Required — missing {{provider}} API Key will result in 401',
|
||||||
|
'llm.numCtx.label': 'Context length (num_ctx)',
|
||||||
|
'llm.numCtx.placeholder': 'Default decided by the model (e.g. 2048, 4096, 128000)',
|
||||||
|
'llm.numCtx.error': 'Minimum is 512',
|
||||||
|
'llm.ctxWindow.label': 'Context window (contextWindow)',
|
||||||
|
'llm.ctxWindow.placeholder': 'e.g. 64000, 128000, 1000000',
|
||||||
|
'llm.ctxWindow.minError': 'Minimum is 4096',
|
||||||
|
'llm.ctxWindow.helper': 'Used for context-compression decisions; not sent to the API',
|
||||||
|
'llm.ctxWindow.mimoHelper': 'Default 1000000 (1M), used for compression decisions',
|
||||||
|
'llm.ctxWindow.oaHelper': 'gpt-4o defaults to 128K, gpt-4.1 to 1M',
|
||||||
|
'llm.ctxWindow.anthropicHelper': 'Claude defaults to 200K',
|
||||||
|
'llm.pull.title': 'Download Ollama model',
|
||||||
|
'llm.pull.placeholder': 'Model name, e.g. qwen3:8b',
|
||||||
|
'llm.pull.download': 'Download',
|
||||||
|
'llm.pull.cancel': 'Cancel',
|
||||||
|
'llm.pull.helper':
|
||||||
|
'Pull a model from the Ollama server (large downloads depend on network; cancellable). Fetch the list afterwards',
|
||||||
|
'llm.pull.done': 'Model {{name}} downloaded',
|
||||||
|
'llm.pull.failed': 'Model download failed: {{message}}',
|
||||||
|
'llm.balance.label': 'Account Balance',
|
||||||
|
'llm.balance.querying': 'Querying...',
|
||||||
|
'llm.balance.unqueried': 'Not queried (requires a saved API Key)',
|
||||||
|
'llm.balance.granted': '(granted {{granted}} + topped up {{topped}})',
|
||||||
|
'llm.balance.queryFailed': 'Query failed',
|
||||||
|
'llm.fallback.title': 'Failover (optional)',
|
||||||
|
'llm.fallback.helper':
|
||||||
|
'Switch to the fallback provider when the primary fails (retries exhausted or invalid key). Leave empty to disable.',
|
||||||
|
'llm.fallback.provider': 'Fallback Provider',
|
||||||
|
'llm.fallback.disabled': 'Disabled',
|
||||||
|
'llm.fallback.baseURL': 'Fallback Base URL',
|
||||||
|
'llm.fallback.model': 'Fallback Model Name',
|
||||||
|
'llm.fallback.apiKey': 'Fallback API Key',
|
||||||
|
'llm.save': 'Save Configuration',
|
||||||
|
'llm.saving': 'Saving...',
|
||||||
|
'llm.save.blocked': 'Fix form errors before saving',
|
||||||
|
'llm.save.blockedToast': 'Please fix the form errors before saving',
|
||||||
|
'llm.save.success': 'Configuration saved',
|
||||||
|
'llm.save.failed': 'Save failed: {{message}}',
|
||||||
|
'llm.api.unavailable': 'Configuration API unavailable',
|
||||||
|
'llm.save.failed.generic': 'Failed to save configuration',
|
||||||
|
|
||||||
|
// ===== 记忆库(MemoryViewer)=====
|
||||||
|
'memory.search.title': 'Memory Search',
|
||||||
|
'memory.list.title': 'Memory Store',
|
||||||
|
'memory.results': '{{count}} results',
|
||||||
|
'memory.total': '{{count}} items',
|
||||||
|
'memory.search.placeholder': 'Search memories...',
|
||||||
|
'memory.search.clear': 'Clear',
|
||||||
|
'memory.searching': 'Searching...',
|
||||||
|
'memory.search.empty': 'No matches',
|
||||||
|
'memory.empty': 'No memories yet',
|
||||||
|
'memory.loadFailed': 'Failed to load memories',
|
||||||
|
'memory.deleteFailed': 'Delete failed',
|
||||||
|
'memory.type.episodic': 'Episodic',
|
||||||
|
'memory.type.semantic': 'Semantic',
|
||||||
|
'memory.type.working': 'Working',
|
||||||
|
|
||||||
|
// ===== 引导向导(OnboardingWizard)=====
|
||||||
|
'onboarding.step.welcome': 'Welcome',
|
||||||
|
'onboarding.step.llm': 'Configure LLM',
|
||||||
|
'onboarding.step.agent': 'Customize Agent',
|
||||||
|
'onboarding.step.workspace': 'Workspace',
|
||||||
|
'onboarding.step.done': 'Get Started',
|
||||||
|
'onboarding.prev': 'Back',
|
||||||
|
'onboarding.next': 'Next',
|
||||||
|
'onboarding.finish': 'Get Started',
|
||||||
|
'onboarding.welcome.title': 'Welcome to MetonaAI Desktop',
|
||||||
|
'onboarding.welcome.body':
|
||||||
|
'A production-grade AI Agent desktop app with multi-turn chat, tool calling, memory, and MCP integration.',
|
||||||
|
'onboarding.welcome.hint': "Let's spend 1 minute on initial setup.",
|
||||||
|
'onboarding.llm.title': 'Configure LLM Provider',
|
||||||
|
'onboarding.llm.body':
|
||||||
|
'Pick a provider and fill in API details. Base URL and model accept any input.',
|
||||||
|
'onboarding.llm.multimodal.label': 'Enable multimodal (image input)',
|
||||||
|
'onboarding.llm.multimodal.helper': 'Allows uploading images; DeepSeek requires a vision model',
|
||||||
|
'onboarding.llm.apiKey.placeholder': 'sk-... (leave empty for local models)',
|
||||||
|
'onboarding.llm.ctx.ollama': 'Context length (num_ctx)',
|
||||||
|
'onboarding.llm.ctx.window': 'Context window (contextWindow)',
|
||||||
|
'onboarding.llm.ctx.ollamaPlaceholder': 'Default decided by the model (e.g. 2048, 4096, 128000)',
|
||||||
|
'onboarding.llm.ctx.placeholder': 'e.g. 64000, 128000, 1000000',
|
||||||
|
'onboarding.llm.ctx.helper': 'Used for context-compression decisions; not sent to the API',
|
||||||
|
'onboarding.llm.ctx.minError': 'Minimum is {{min}}',
|
||||||
|
'onboarding.agent.title': 'Customize Agent',
|
||||||
|
'onboarding.agent.body':
|
||||||
|
'Edit SOUL.md in the workspace to define the agent identity and personality.',
|
||||||
|
'onboarding.agent.soulLabel': 'SOUL.md — define the agent identity, personality, and core values',
|
||||||
|
'onboarding.agent.soulHint': 'You can do this later in the workspace directory.',
|
||||||
|
'onboarding.workspace.title': 'Workspace',
|
||||||
|
'onboarding.workspace.body': 'Choose a workspace directory, or keep the default.',
|
||||||
|
'onboarding.workspace.pick': 'Choose Folder',
|
||||||
|
'onboarding.workspace.placeholder': '~/MetonaWorkspaces/default/',
|
||||||
|
'onboarding.workspace.helper':
|
||||||
|
'Contains the two required files SOUL.md and MEMORY.md, auto-created on first open.',
|
||||||
|
'onboarding.done.title': 'All set!',
|
||||||
|
'onboarding.done.body': 'MetonaAI Desktop is ready. Start chatting with your AI agent!',
|
||||||
|
'onboarding.done.hint': 'Press Ctrl+Enter to send; type / to see commands',
|
||||||
|
'onboarding.toast.saveFailed': 'Failed to save configuration, please retry',
|
||||||
|
'onboarding.toast.saveFailedWithReason': 'Failed to save configuration: {{message}}',
|
||||||
|
'onboarding.toast.folderFailed': 'Failed to choose folder',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 模型能力门控纯函数(v0.7.3 P1-4)
|
||||||
|
*
|
||||||
|
* ChatInput 的图片上传入口此前只有"多模态总开关 × DeepSeek vision 命名"两道
|
||||||
|
* 判定 —— Ollama 恒放行,但本地语言模型是否支持 vision 因模型而异(qwen3 纯文本
|
||||||
|
* vs llava 视觉)。v0.6.4 P4-1 已实现 /api/show 能力探测,本模块把"是否允许上传
|
||||||
|
* 图片"的完整判定收敛为纯函数,供组件消费与表测锁定。
|
||||||
|
*
|
||||||
|
* 判定链(任一不满足即拒绝):
|
||||||
|
* 1. multimodalEnabled 总开关(设置 → LLM 配置);
|
||||||
|
* 2. Provider 级:DeepSeek 非 vision 系列命名 → 拒绝(模型级防线,与 adapter 口径一致);
|
||||||
|
* 3. 模型级:Ollama 消费 llm:listModels 的 supportsVision 探测结果 ——
|
||||||
|
* false 拒绝;undefined(未知/探测失败/列表未加载)保守放行(fail-open,
|
||||||
|
* 与 adapter 侧探测失败回退 true 的可用性策略对齐);其余 Provider 无探测数据放行。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ImageUploadDenialReason =
|
||||||
|
| 'disabled' // 多模态总开关未开启
|
||||||
|
| 'provider-vision' // 当前 Provider 的该模型命名不支持 vision(DeepSeek)
|
||||||
|
| 'probed-no-vision'; // 能力探测明确返回不支持(Ollama)
|
||||||
|
|
||||||
|
export interface ImageUploadGateInput {
|
||||||
|
multimodalEnabled: boolean;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
/**
|
||||||
|
* 模型能力探测缓存(agent-store.modelVisionCaps;modelId → supportsVision)。
|
||||||
|
* 仅记录探测为 false 的条目即可生效(undefined/缺失 = 未知 → 放行)。
|
||||||
|
*/
|
||||||
|
visionCaps?: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageUploadGateResult {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: ImageUploadDenialReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsImageUpload(input: ImageUploadGateInput): ImageUploadGateResult {
|
||||||
|
// 1. 总开关
|
||||||
|
if (!input.multimodalEnabled) {
|
||||||
|
return { allowed: false, reason: 'disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = input.model ?? '';
|
||||||
|
|
||||||
|
// 2. DeepSeek 模型级命名防线(非 vision 系列不支持图片,见 DeepSeekAdapter)
|
||||||
|
if (input.provider === 'deepseek') {
|
||||||
|
if (model.length === 0 || !model.includes('vision')) {
|
||||||
|
return { allowed: false, reason: 'provider-vision' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ollama 能力探测(v0.7.3 P1-4 接线 /api/show supportsVision)
|
||||||
|
if (input.provider === 'ollama' && model) {
|
||||||
|
const probed = input.visionCaps?.[model];
|
||||||
|
if (probed === false) {
|
||||||
|
return { allowed: false, reason: 'probed-no-vision' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Trace 生命周期(v0.7.3 P3-3)
|
||||||
|
*
|
||||||
|
* 背景:traceSteps 跨 run 只增不减(saveTraceData 每次全量重写 sessions.metadata),
|
||||||
|
* 长会话单行 metadata 可达数十 MB —— v0.7.2 A4 只裁剪了单条 tool_result 里的
|
||||||
|
* base64,没有约束 run 条目数。现按"保留最近 N 个 run"截断,历史 trace 的完整
|
||||||
|
* 记录仍在 JSONL 录制文件中(TRACE 层),详情面板仅需近程可读性。
|
||||||
|
*
|
||||||
|
* 纯函数、零副作用:可在 node vitest 下直接表测。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 落库保留的最大 run 数(默认 20) */
|
||||||
|
export const MAX_TRACE_RUNS = 20;
|
||||||
|
|
||||||
|
export interface TraceStepLike {
|
||||||
|
runId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按首次出现顺序对 runId 分组,仅保留最近 maxRuns 个 run 的步骤。
|
||||||
|
* 无 runId 的 legacy 步骤各自独立成组(逐条参与淘汰,不整组豁免)。
|
||||||
|
*
|
||||||
|
* @param steps 全量 traceSteps(跨 run 累积)
|
||||||
|
* @param maxRuns 保留的 run 组数上限
|
||||||
|
*/
|
||||||
|
export function keepRecentRuns<T extends TraceStepLike>(steps: T[], maxRuns = MAX_TRACE_RUNS): T[] {
|
||||||
|
if (steps.length <= 0) return steps;
|
||||||
|
|
||||||
|
// 分组(保持首次出现顺序);无 runId 的步骤用序号伪分组,避免被误合并
|
||||||
|
const order: string[] = [];
|
||||||
|
const groups = new Map<string, T[]>();
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const step = steps[i];
|
||||||
|
const key = step.runId ?? `__legacy_${i}`;
|
||||||
|
const group = groups.get(key);
|
||||||
|
if (group) {
|
||||||
|
group.push(step);
|
||||||
|
} else {
|
||||||
|
groups.set(key, [step]);
|
||||||
|
order.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超限时丢弃最旧的 run 组
|
||||||
|
if (order.length <= maxRuns) return steps;
|
||||||
|
const keepKeys = new Set(order.slice(order.length - maxRuns));
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const key of order) {
|
||||||
|
if (!keepKeys.has(key)) continue;
|
||||||
|
out.push(...groups.get(key)!);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -9,11 +9,29 @@ import { useSessionStore } from './session-store';
|
|||||||
// v0.7.2 A4: Trace 落库前复用显示层裁剪(剥离 base64/超长字符串),防止
|
// v0.7.2 A4: Trace 落库前复用显示层裁剪(剥离 base64/超长字符串),防止
|
||||||
// view_image 截图等超大工具结果(单张 ~6.7MB base64)原样写入 sessions.metadata
|
// view_image 截图等超大工具结果(单张 ~6.7MB base64)原样写入 sessions.metadata
|
||||||
import { toDisplayResult } from '../lib/tool-result-display';
|
import { toDisplayResult } from '../lib/tool-result-display';
|
||||||
|
// v0.7.3 P3-3: Trace 生命周期 —— metadata 只保留最近 N 个 run 的步骤
|
||||||
|
import { keepRecentRuns } from '../lib/trace-lifecycle';
|
||||||
|
|
||||||
// L-1: 消息 ID 防碰撞计数器
|
// L-1: 消息 ID 防碰撞计数器
|
||||||
let _msgIdCounter = 0;
|
let _msgIdCounter = 0;
|
||||||
export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`;
|
export const genMsgId = (role: string) => `msg_${Date.now()}_${role}_${_msgIdCounter++}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.3 P1-2: 按锚点时间戳截断 traceSteps(编辑重发/重新生成的本地侧镜像)。
|
||||||
|
*
|
||||||
|
* DB 侧 truncateMessagesAfter 已同步过滤 metadata.traceSteps(startedAt <
|
||||||
|
* 锚点消息 created_at,严格小于 —— 锚点触发的 run 与消息同毫秒落库,等值
|
||||||
|
* 属于"锚点侧"必须丢弃);本函数对前端内存态做同一语义的过滤,保证截断后
|
||||||
|
* TraceViewer 不再显示已被撤销轮次的"幽灵步骤"(切换会话再切回时 DB 与
|
||||||
|
* 内存两侧口径一致)。legacy 步骤缺 startedAt 时保守保留(无时间依据可判废)。
|
||||||
|
*/
|
||||||
|
export function trimTraceStepsByAnchor<T extends { startedAt?: number }>(
|
||||||
|
steps: T[],
|
||||||
|
anchorTimestamp: number,
|
||||||
|
): T[] {
|
||||||
|
return steps.filter((s) => typeof s.startedAt !== 'number' || s.startedAt < anchorTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* L-16 修复: 提取上下文窗口默认值为命名常量
|
* L-16 修复: 提取上下文窗口默认值为命名常量
|
||||||
* - Ollama 默认 4096(与 OllamaAdapter.DEFAULT_CONTEXT_WINDOW 保持一致)
|
* - Ollama 默认 4096(与 OllamaAdapter.DEFAULT_CONTEXT_WINDOW 保持一致)
|
||||||
@@ -155,6 +173,13 @@ interface AgentState {
|
|||||||
*/
|
*/
|
||||||
sessionRunStates: Record<string, AgentStatus>;
|
sessionRunStates: Record<string, AgentStatus>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.3 P1-4: 模型视觉能力探测缓存(modelId → supportsVision)。
|
||||||
|
* 仅存探测为 false 的条目即可生效(缺失/undefined = 未知 → 上传门控保守放行)。
|
||||||
|
* 数据源:window.metona.llm.listModels()(Ollama 侧为 /api/show 能力探测)。
|
||||||
|
*/
|
||||||
|
modelVisionCaps: Record<string, boolean>;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setCurrentSession: (id: string | null) => void;
|
setCurrentSession: (id: string | null) => void;
|
||||||
setMessages: (messages: ChatMessage[]) => void;
|
setMessages: (messages: ChatMessage[]) => void;
|
||||||
@@ -193,6 +218,16 @@ interface AgentState {
|
|||||||
* status 传入 null 表示该会话运行已结束(TERMINATED / abort),移除条目。
|
* status 传入 null 表示该会话运行已结束(TERMINATED / abort),移除条目。
|
||||||
*/
|
*/
|
||||||
updateSessionRunState: (sessionId: string, status: AgentStatus | null) => void;
|
updateSessionRunState: (sessionId: string, status: AgentStatus | null) => void;
|
||||||
|
/**
|
||||||
|
* v0.7.3 P1-4: 合并模型能力探测结果(仅记录 supportsVision === false 的模型)。
|
||||||
|
* 供 LLMSettings 加载列表后与 store 刷新动作调用。
|
||||||
|
*/
|
||||||
|
applyModelCapabilities: (models: Array<{ id: string; supportsVision?: boolean }>) => void;
|
||||||
|
/**
|
||||||
|
* v0.7.3 P1-4: 拉取 llm:listModels 并合并能力缓存(启动 / Provider 切换 /
|
||||||
|
* Ollama 模型下载完成后调用)。失败静默 —— 门控按未知模型保守放行。
|
||||||
|
*/
|
||||||
|
refreshModelCapabilities: () => Promise<void>;
|
||||||
saveTraceData: () => void;
|
saveTraceData: () => void;
|
||||||
clearMessages: () => Promise<void>;
|
clearMessages: () => Promise<void>;
|
||||||
abort: () => void;
|
abort: () => void;
|
||||||
@@ -234,6 +269,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
multimodalEnabled: false,
|
multimodalEnabled: false,
|
||||||
// v0.7.2 P3-11: 后台会话运行状态图(初始为空)
|
// v0.7.2 P3-11: 后台会话运行状态图(初始为空)
|
||||||
sessionRunStates: {},
|
sessionRunStates: {},
|
||||||
|
// v0.7.3 P1-4: 模型视觉能力缓存(初始为空 = 全部未知 → 门控保守放行)
|
||||||
|
modelVisionCaps: {},
|
||||||
|
|
||||||
// ===== Actions =====
|
// ===== Actions =====
|
||||||
|
|
||||||
@@ -620,6 +657,29 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
return { sessionRunStates: { ...s.sessionRunStates, [sessionId]: status } };
|
return { sessionRunStates: { ...s.sessionRunStates, [sessionId]: status } };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// v0.7.3 P1-4: 合并模型能力探测结果(supportsVision=false 才入缓存)
|
||||||
|
applyModelCapabilities: (models) =>
|
||||||
|
set((s) => {
|
||||||
|
const additions: Record<string, boolean> = {};
|
||||||
|
for (const m of models) {
|
||||||
|
if (m?.id && m.supportsVision === false) additions[m.id] = false;
|
||||||
|
}
|
||||||
|
if (Object.keys(additions).length === 0) return s;
|
||||||
|
return { modelVisionCaps: { ...s.modelVisionCaps, ...additions } };
|
||||||
|
}),
|
||||||
|
|
||||||
|
// v0.7.3 P1-4: 拉取 llm:listModels 并合并能力缓存(失败静默,门控按未知放行)
|
||||||
|
refreshModelCapabilities: async () => {
|
||||||
|
try {
|
||||||
|
const r = await window.metona?.llm?.listModels?.();
|
||||||
|
if (r?.success && Array.isArray(r.data)) {
|
||||||
|
useAgentStore.getState().applyModelCapabilities(r.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 静默 —— 探测不可用时门控按未知模型保守放行 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
saveTraceData: () => {
|
saveTraceData: () => {
|
||||||
const { currentSessionId, traceSteps, tokenUsage } = get();
|
const { currentSessionId, traceSteps, tokenUsage } = get();
|
||||||
if (currentSessionId && window.metona?.sessions?.saveTrace) {
|
if (currentSessionId && window.metona?.sessions?.saveTrace) {
|
||||||
@@ -630,7 +690,10 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
// toDisplayResult(dataUrl 剥离 + 任意 >8KB 字符串截断),内存态 traceSteps
|
// toDisplayResult(dataUrl 剥离 + 任意 >8KB 字符串截断),内存态 traceSteps
|
||||||
// 在入 store 时已同步瘦身(见 useAgentStream),此处对 restore 自 DB 的
|
// 在入 store 时已同步瘦身(见 useAgentStream),此处对 restore 自 DB 的
|
||||||
// 旧格式数据兜底二次裁剪。
|
// 旧格式数据兜底二次裁剪。
|
||||||
const sanitizedSteps = traceSteps.map((step) =>
|
// v0.7.3 P3-3: 条目数同样收口 —— 只保留最近 20 个 run 的步骤
|
||||||
|
// (历史 run 的完整记录在 JSONL 录制文件中,metadata 仅需近程可读性)。
|
||||||
|
const recentSteps = keepRecentRuns(traceSteps);
|
||||||
|
const sanitizedSteps = recentSteps.map((step) =>
|
||||||
step.toolCalls?.length
|
step.toolCalls?.length
|
||||||
? {
|
? {
|
||||||
...step,
|
...step,
|
||||||
@@ -745,12 +808,15 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 本地截断(保留之前的消息,重置运行状态)
|
// 本地截断(保留之前的消息,重置运行状态)
|
||||||
|
// v0.7.3 P1-2: traceSteps 同步按锚点时间戳过滤 —— DB 侧 truncateMessagesAfter
|
||||||
|
// 已过滤 metadata.traceSteps,此处对内存态做同一语义截断,消除幽灵步骤
|
||||||
set({
|
set({
|
||||||
messages: messages.slice(0, idx),
|
messages: messages.slice(0, idx),
|
||||||
agentStatus: 'idle',
|
agentStatus: 'idle',
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
currentIteration: 0,
|
currentIteration: 0,
|
||||||
currentRunId: null,
|
currentRunId: null,
|
||||||
|
traceSteps: trimTraceStepsByAnchor(get().traceSteps, original.timestamp),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 从原消息附件重建图片参数(附件随消息保留重发)
|
// 从原消息附件重建图片参数(附件随消息保留重发)
|
||||||
@@ -802,13 +868,14 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本地截断
|
// 本地截断(v0.7.3 P1-2: traceSteps 同步过滤,语义与 editAndResend 一致)
|
||||||
set({
|
set({
|
||||||
messages: messages.slice(0, lastUserIdx),
|
messages: messages.slice(0, lastUserIdx),
|
||||||
agentStatus: 'idle',
|
agentStatus: 'idle',
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
currentIteration: 0,
|
currentIteration: 0,
|
||||||
currentRunId: null,
|
currentRunId: null,
|
||||||
|
traceSteps: trimTraceStepsByAnchor(get().traceSteps, lastUser.timestamp),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 重发原内容(含原附件)
|
// 重发原内容(含原附件)
|
||||||
|
|||||||
Vendored
+53
-2
@@ -127,7 +127,7 @@ interface MetonaSessionsAPI {
|
|||||||
>;
|
>;
|
||||||
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean; error?: string }>;
|
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean; error?: string }>;
|
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
|
/** v0.7.3 P1-3: deleteMessage 死通道已移除(消息删除语义由 truncateAfter 覆盖) */
|
||||||
/** v0.7.2 A1: 语义为"操作完成"(空会话清空同样成功);失败时携带 error */
|
/** v0.7.2 A1: 语义为"操作完成"(空会话清空同样成功);失败时携带 error */
|
||||||
clearMessages: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
clearMessages: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
/** P2-11: 截断消息(编辑重发/重新生成) */
|
/** P2-11: 截断消息(编辑重发/重新生成) */
|
||||||
@@ -173,9 +173,12 @@ interface MetonaMCPServerConfig {
|
|||||||
|
|
||||||
interface MetonaMCPServerStatus {
|
interface MetonaMCPServerStatus {
|
||||||
name: string;
|
name: string;
|
||||||
status: 'connecting' | 'connected' | 'disconnected' | 'error';
|
/** v0.7.3 P4-2: 新增 reconnecting(自动重连排程中,含第 N 次尝试) */
|
||||||
|
status: 'connecting' | 'connected' | 'disconnected' | 'error' | 'reconnecting';
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** v0.7.3 P4-2: reconnecting 状态下的已尝试次数 */
|
||||||
|
reconnectAttempt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetonaMCPAPI {
|
interface MetonaMCPAPI {
|
||||||
@@ -243,6 +246,50 @@ interface MetonaAppAPI {
|
|||||||
| { status: 'up-to-date'; latestVersion: string }
|
| { status: 'up-to-date'; latestVersion: string }
|
||||||
| { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string }
|
| { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string }
|
||||||
>;
|
>;
|
||||||
|
/** v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告) */
|
||||||
|
getHealthSnapshot: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: MetonaHealthSnapshot;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== v0.7.3 P3-4: 健康快照载荷 =====
|
||||||
|
|
||||||
|
interface MetonaHealthSnapshot {
|
||||||
|
slo: {
|
||||||
|
errorRate: number;
|
||||||
|
throughput: number;
|
||||||
|
avgLatencyMs: number;
|
||||||
|
percentiles: Record<string, number>;
|
||||||
|
burnRate: number;
|
||||||
|
target: number;
|
||||||
|
violated: boolean;
|
||||||
|
totalRequests: number;
|
||||||
|
errorRequests: number;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
health: {
|
||||||
|
healthy: boolean;
|
||||||
|
checks: Array<{ name: string; healthy: boolean; latencyMs?: number; error?: string }>;
|
||||||
|
timestamp: string;
|
||||||
|
} | null;
|
||||||
|
generatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== v0.7.3 P3-3: JSONL 录制文件生命周期 =====
|
||||||
|
|
||||||
|
interface MetonaLogsAPI {
|
||||||
|
traceStats: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: { count: number; totalBytes: number };
|
||||||
|
}>;
|
||||||
|
pruneTraceFiles: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: { deleted: number };
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Workspace API =====
|
// ===== Workspace API =====
|
||||||
@@ -314,6 +361,8 @@ interface MetonaModelInfoLite {
|
|||||||
maxOutputTokens?: number;
|
maxOutputTokens?: number;
|
||||||
supportsToolCalling?: boolean;
|
supportsToolCalling?: boolean;
|
||||||
supportsThinking?: boolean;
|
supportsThinking?: boolean;
|
||||||
|
/** v0.7.3 P1-4: 视觉能力(undefined = 未知,前端保守放行;Ollama 探测填充) */
|
||||||
|
supportsVision?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,6 +626,8 @@ interface MetonaBridge {
|
|||||||
tasks: MetonaTasksAPI;
|
tasks: MetonaTasksAPI;
|
||||||
audit: MetonaAuditAPI;
|
audit: MetonaAuditAPI;
|
||||||
tool: MetonaToolAPI;
|
tool: MetonaToolAPI;
|
||||||
|
/** v0.7.3 P3-3: JSONL 录制文件生命周期 */
|
||||||
|
logs: MetonaLogsAPI;
|
||||||
/** v0.6.4: 托盘动作(新建会话死链接线) */
|
/** v0.6.4: 托盘动作(新建会话死链接线) */
|
||||||
tray?: MetonaTrayAPI;
|
tray?: MetonaTrayAPI;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user