feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s

P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
This commit is contained in:
2026-09-05 20:06:26 +08:00
parent 99d0c54129
commit 5b9d4d19b3
78 changed files with 5128 additions and 411 deletions
@@ -281,15 +281,21 @@ describe.skipIf(!dbAvailable)('DatabaseService — 多版本迁移矩阵(user_
rmSync(dir, { recursive: true, force: true });
});
it('user_version=2 的库:快速路径跳过迁移,版本号保持不变', async () => {
it('user_version=2 的库:增量迁移至 SCHEMA_VERSIONv0.8.0 补 sessions.deleted_at', async () => {
const { DatabaseService } = await import('../database.service');
const dir = mkdtempSync(join(tmpdir(), 'metona-migv2-'));
const legacy = openLegacyDb(dir);
// v0.8.0 注: 真实 v2 库必含 sessions 表 —— fixture 补齐以覆盖迁移 10
legacy.exec(`
CREATE TABLE messages (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL,
content TEXT, created_at INTEGER NOT NULL
);
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 VIRTUAL TABLE messages_fts USING fts5(content, content=messages, content_rowid=rowid, tokenize='trigram');
`);
legacy.pragma('user_version = 2');
@@ -298,7 +304,14 @@ describe.skipIf(!dbAvailable)('DatabaseService — 多版本迁移矩阵(user_
const svc = new DatabaseService(dir);
svc.initialize();
const db = svc.getDB();
expect(db.pragma('user_version', { simple: true })).toBe(2);
// v0.8.0 P2-1 契约更新: SCHEMA_VERSION 升至 3(迁移 10 sessions.deleted_at),
// user_version=2 的库不再是最新版 —— 会执行增量迁移并盖章到 3;
// FTS 已是 trigram(迁移 9 幂等跳过),增量迁移只补 sessions.deleted_at 列。
expect(db.pragma('user_version', { simple: true })).toBe(DatabaseService.SCHEMA_VERSION);
const delCol = db
.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('sessions') WHERE name='deleted_at'")
.get() as { c: number };
expect(delCol.c).toBe(1);
// 快速路径不重建 FTS(保持原有 trigram 表)
const ftsSql = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='messages_fts'`)
@@ -50,6 +50,7 @@ describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
@@ -281,6 +282,7 @@ describe.skipIf(!dbAvailable)('SessionService — 会话 CRUD 与消息边界',
total_tokens INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
@@ -618,6 +620,7 @@ describe.skipIf(!dbAvailable)('searchMessages — 更多查询形态与边界',
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
@@ -787,11 +790,26 @@ describe.skipIf(!dbAvailable)('searchMessages — 更多查询形态与边界',
expect(hits.some((r: any) => r.sessionId === 'q8')).toBe(true);
});
it('已删除会话的标题回退为占位文案((已删除会话)', () => {
// 消息引用已不存在的会话 id(真实库 FK 会阻止,此处验证 title 补全兜底)
it('回收站会话(deleted_at 非空)从搜索结果整体剔除(v0.8.0 P2-1 契约', () => {
// v0.8.0 P2-1 契约变更: 旧实现把缺标题的会话显示为"(已删除会话)"占位;
// 软删除引入后,回收站会话的搜索命中整体剔除(恢复后重新可见),
// 硬删除会话的消息已级联清除,占位分支不再可达。
insertMessage('q9_m1', 'q9_ghost', 'user', 'ghostkeyword 消息', 1);
const hits = sessionService.searchMessages('ghostkeyword');
expect(hits.some((r: any) => r.sessionTitle === '(已删除会话)')).toBe(true);
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('q9_ghost', 'ghost', 1, 1)`,
).run();
const before = sessionService.searchMessages('ghostkeyword');
expect(before.some((r: any) => r.sessionId === 'q9_ghost')).toBe(true);
// 软删除后 → 搜索不再命中
db.prepare(`UPDATE sessions SET deleted_at = ? WHERE id = 'q9_ghost'`).run(Date.now());
const after = sessionService.searchMessages('ghostkeyword');
expect(after.some((r: any) => r.sessionId === 'q9_ghost')).toBe(false);
// 恢复后 → 重新命中
db.prepare(`UPDATE sessions SET deleted_at = NULL WHERE id = 'q9_ghost'`).run();
const restored = sessionService.searchMessages('ghostkeyword');
expect(restored.some((r: any) => r.sessionId === 'q9_ghost')).toBe(true);
});
it('单字符 CJK 查询走 LIKE 回退命中子串', () => {
@@ -945,6 +963,7 @@ describe.skipIf(!dbAvailable)('saveMessage id 透传与 updateMessageContentR
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
@@ -56,6 +56,7 @@ describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互',
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
@@ -284,6 +285,7 @@ describe.skipIf(!dbAvailable)('clearMessages × 摘要游标交互(B-2', ()
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
@@ -392,6 +394,7 @@ describe.skipIf(!dbAvailable)('session_summaries 级联删除(F-3', () => {
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE session_summaries (
@@ -472,6 +475,7 @@ describe.skipIf(!dbAvailable)('maybeSummarize — 触发阈值与增量', () =>
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
@@ -652,6 +656,7 @@ describe.skipIf(!dbAvailable)('buildHistoryMessages — 无摘要与 role 过滤
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
@@ -746,6 +751,7 @@ describe.skipIf(!dbAvailable)('restoreHistoryImages — 更多边界', () => {
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
@@ -0,0 +1,157 @@
/**
* v0.8.0 P2-1: 会话回收站(软删除 30 天保留)回归测试。
*
* 钉住契约:
* - delete → 软删除(deleted_at 时间戳),list/getSession 不再返回,listDeleted 返回;
* - restore → 恢复到主列表;
* - purge → 硬删除(不可恢复);
* - purgeExpiredTrash(30d) → 仅清理超期条目;
* - searchMessages 聚合剔除回收站会话。
*
* 运行要求: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)('会话回收站(v0.8.0 P2-1', () => {
let sessionService: any;
let db: InstanceType<typeof Database>;
beforeAll(async () => {
const { SessionService } = await import('../session.service');
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
// 最小 schema(与 DatabaseService.createTables 对齐的关键列:deleted_at + 级联删除)
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,
total_tokens INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
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,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE TABLE session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
`);
sessionService = new SessionService(() => db);
});
afterAll(() => {
try {
db.close();
} catch {
/* ignore */
}
});
it('delete → 软删除:list 不返回、listDeleted 返回、getSession 排除', () => {
const a = sessionService.create('A');
const b = sessionService.create('B');
expect(sessionService.delete(a.id)).toBe(true);
const list = sessionService.list().map((s: { id: string }) => s.id);
expect(list).toContain(b.id);
expect(list).not.toContain(a.id);
const trash = sessionService.listDeleted().map((s: { id: string }) => s.id);
expect(trash).toEqual([a.id]);
expect(sessionService.getSession(a.id)).toBeNull();
expect(sessionService.getSession(b.id)).not.toBeNull();
});
it('restore → 恢复到主列表', () => {
const a = sessionService.listDeleted()[0];
expect(sessionService.restore(a.id)).toBe(true);
expect(sessionService.list().map((s: { id: string }) => s.id)).toContain(a.id);
expect(sessionService.listDeleted()).toHaveLength(0);
});
it('purge → 硬删除(仅对回收站条目生效)', () => {
const s = sessionService.create('temp');
sessionService.delete(s.id);
sessionService.saveMessage({ sessionId: s.id, role: 'user', content: 'x' });
expect(sessionService.purge(s.id)).toBe(true);
const count = db
.prepare('SELECT COUNT(*) AS c FROM messages WHERE session_id = ?')
.get(s.id) as {
c: number;
};
expect(count.c).toBe(0);
// 未删除的会话不可 purge(幂等保护)
const live = sessionService.create('live');
expect(sessionService.purge(live.id)).toBe(false);
});
it('purgeExpiredTrash(30d) → 仅清理超期条目', () => {
const old = sessionService.create('old');
sessionService.delete(old.id);
db.prepare('UPDATE sessions SET deleted_at = ? WHERE id = ?').run(
Date.now() - 31 * 24 * 60 * 60 * 1000,
old.id,
);
const fresh = sessionService.create('fresh');
sessionService.delete(fresh.id); // deleted_at = now
const purged = sessionService.purgeExpiredTrash(30);
expect(purged).toBe(1);
const remaining = db.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id = ?').get(old.id) as {
c: number;
};
expect(remaining.c).toBe(0);
expect(sessionService.listDeleted().map((s: { id: string }) => s.id)).toContain(fresh.id);
});
it('searchMessages 聚合剔除回收站会话(≤2 字符 LIKE 回退路径)', () => {
const s1 = sessionService.create('搜索目标会话');
sessionService.saveMessage({ sessionId: s1.id, role: 'user', content: '量子计算机的独特内容' });
// '量子' ≤2 字符走 LIKE 回退(最小 schema 无 FTS 表,与生产 trigram 路径
// 共用同一个 aggregateSearchResults 聚合出口,剔除逻辑同源)
const r1 = sessionService.searchMessages('量子');
expect(r1.length).toBe(1);
sessionService.delete(s1.id);
const r2 = sessionService.searchMessages('量子');
expect(r2.length).toBe(0);
});
});
@@ -45,6 +45,7 @@ describe.skipIf(!dbAvailable)('truncateMessagesAfter × TRACE metadata 过滤(
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
+19 -1
View File
@@ -113,8 +113,9 @@ export class DatabaseService {
* 版本号相同的库将跳过整个探测式迁移批次。
* v0.7.4 P4-4: 1 → 2 —— 迁移 9messages_fts trigram)纳入版本化,
* 失败中断批次不盖章 → 下次启动重试(根治"失败被吞 + 永久跳过")。
* v0.8.0 P2-1: 2 → 3 —— 迁移 10sessions.deleted_at 回收站软删除列)。
*/
static readonly SCHEMA_VERSION = 2;
static readonly SCHEMA_VERSION = 3;
constructor(workspacePath?: string) {
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
@@ -180,6 +181,7 @@ export class DatabaseService {
db.exec(`
-- ===== 会话表 =====
-- v0.8.0 P2-1: deleted_at 回收站软删除(NULL = 正常;时间戳 = 已删除待清理)
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '新会话',
@@ -189,6 +191,7 @@ export class DatabaseService {
total_tokens INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
metadata TEXT DEFAULT '{}'
);
@@ -321,6 +324,8 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_pinned ON sessions(pinned DESC, updated_at DESC);
-- v0.8.0 P2-1: idx_sessions_deleted 由迁移 10 统一创建(createTables 的静态
-- CREATE INDEX 在"表存在但缺 deleted_at 列"的遗留库上会 no-such-column 失败)
CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_logs(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_type ON audit_logs(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
@@ -639,6 +644,19 @@ export class DatabaseService {
// 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串
}
// v0.8.0 P2-1 迁移 10: sessions.deleted_at 回收站软删除列 + 索引
// 存在性守卫:极简遗留库(如 v2 盖章的早期测试库)可能没有 sessions 表,
// 迁移必须可重试且不因缺表中断整个批次
{
const hasSessions = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
.get();
if (hasSessions) {
tryAddColumn('sessions', 'deleted_at', 'INTEGER');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_deleted ON sessions(deleted_at);');
}
}
// v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer
// 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild
// 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的
+70
View File
@@ -234,6 +234,20 @@ export interface MCPServerConfig {
enabled: boolean;
}
/** v0.8.0 P2-5: MCP Resource / Prompt 发现条目(渲染端展示用宽松形态) */
export interface MCPResourceInfo {
uri: string;
name: string;
description?: string;
mimeType?: string;
}
export interface MCPPromptInfo {
name: string;
description?: string;
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
}
export interface MCPServerState {
config: MCPServerConfig;
status: MCPServerStatus;
@@ -241,6 +255,9 @@ export interface MCPServerState {
tools: Tool[];
error?: string;
connectedAt?: number;
/** v0.8.0 P2-5: 发现的 Resources / Prompts(可选能力,未声明/失败时为空数组) */
resources: MCPResourceInfo[];
prompts: MCPPromptInfo[];
}
// ===== MCP Tool Adapter =====
@@ -506,6 +523,8 @@ export class MCPManager {
status: 'connecting',
client: null,
tools: [],
resources: [],
prompts: [],
});
try {
@@ -558,6 +577,42 @@ export class MCPManager {
const toolsResult = await client.listTools();
const tools = toolsResult.tools ?? [];
// v0.8.0 P2-5: Resources / Prompts 发现(可选能力 —— server 未声明或
// 请求失败一律置空数组,不阻断连接与工具注册)
const discoveredResources: MCPResourceInfo[] = [];
const discoveredPrompts: MCPPromptInfo[] = [];
try {
const res = await client.listResources();
for (const r of res.resources ?? []) {
discoveredResources.push({
uri: String(r.uri ?? ''),
name: String(r.name ?? ''),
description: typeof r.description === 'string' ? r.description : undefined,
mimeType: typeof r.mimeType === 'string' ? r.mimeType : undefined,
});
}
} catch {
/* server 未声明 resources 能力 */
}
try {
const res = await client.listPrompts();
for (const p of res.prompts ?? []) {
discoveredPrompts.push({
name: String(p.name ?? ''),
description: typeof p.description === 'string' ? p.description : undefined,
arguments: Array.isArray(p.arguments)
? p.arguments.map((a) => ({
name: String(a.name ?? ''),
description: typeof a.description === 'string' ? a.description : undefined,
required: a.required === true,
}))
: undefined,
});
}
} catch {
/* server 未声明 prompts 能力 */
}
// 注册到 ToolRegistryv0.6.4: registerMCP 对重名冲突返回 false,此处聚合上报)
let registeredCount = 0;
let skippedCount = 0;
@@ -580,6 +635,8 @@ export class MCPManager {
state.tools = tools;
state.connectedAt = Date.now();
state.error = undefined;
state.resources = discoveredResources;
state.prompts = discoveredPrompts;
// v0.7.3 P4-2: 连接成功 —— 清零重连计数并取消排程
this.reconnectAttempts.delete(name);
@@ -646,6 +703,8 @@ export class MCPManager {
}
state.client = null;
state.tools = [];
state.resources = [];
state.prompts = [];
this.notifyToolsChanged();
}
@@ -801,6 +860,17 @@ export class MCPManager {
};
}
/**
* v0.8.0 P2-5: 获取 server 发现的 Resources / Prompts(未连接返回空集合)。
*/
getServerContents(name: string): { resources: MCPResourceInfo[]; prompts: MCPPromptInfo[] } {
const state = this.servers.get(name);
if (!state || state.status !== 'connected') {
return { resources: [], prompts: [] };
}
return { resources: state.resources ?? [], prompts: state.prompts ?? [] };
}
/**
* 关闭所有连接
*/
+85 -12
View File
@@ -81,6 +81,9 @@ export class SessionService {
/**
* 列出所有会话
*
* v0.8.0 P2-1: 排除回收站(deleted_at 非空)会话 —— 删除为软删除,
* 列表/查询面不再出现;回收站内容经 listDeleted 查看。
*/
list(options?: { archived?: boolean }): SessionInfo[] {
const db = this.getDBFn();
@@ -90,7 +93,7 @@ export class SessionService {
.prepare(
`
SELECT * FROM sessions
WHERE archived = ?
WHERE archived = ? AND deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC
`,
)
@@ -99,16 +102,74 @@ export class SessionService {
return rows.map((row) => this.toSessionInfo(row));
}
/**
* v0.8.0 P2-1: 列出回收站会话(deleted_at 非空,按删除时间倒序)
*/
listDeleted(): SessionInfo[] {
const db = this.getDBFn();
const rows = db
.prepare(
`
SELECT * FROM sessions
WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC
`,
)
.all() as SessionRow[];
return rows.map((row) => this.toSessionInfo(row));
}
/**
* v0.8.0 P2-1: 从回收站恢复会话(清除 deleted_at
*/
restore(sessionId: string): boolean {
const db = this.getDBFn();
const result = db
.prepare('UPDATE sessions SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL')
.run(sessionId);
if (result.changes > 0) log.info(`Session restored from trash: ${sessionId}`);
return result.changes > 0;
}
/**
* v0.8.0 P2-1: 彻底删除(硬删除,级联清消息/摘要/任务)
*/
purge(sessionId: string): boolean {
const db = this.getDBFn();
const result = db
.prepare('DELETE FROM sessions WHERE id = ? AND deleted_at IS NOT NULL')
.run(sessionId);
if (result.changes > 0) log.info(`Session purged from trash: ${sessionId}`);
return result.changes > 0;
}
/**
* v0.8.0 P2-1: 清理回收站中超过 maxAgeDays 的会话(硬删除,级联清理)
* 启动时与每 24h 定时调用(main.ts)。
*/
purgeExpiredTrash(maxAgeDays = 30): number {
const db = this.getDBFn();
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
const rows = db
.prepare('SELECT id FROM sessions WHERE deleted_at IS NOT NULL AND deleted_at < ?')
.all(cutoff) as Array<{ id: string }>;
for (const row of rows) {
db.prepare('DELETE FROM sessions WHERE id = ?').run(row.id);
}
return rows.length;
}
/**
* v0.7.4 P1-4: 按 ID 查询会话(含归档会话)。
* 供 agent:sendMessage 前置存在性预检 —— 指向已删除会话的消息会在 saveMessage
* 因 FK 约束抛错,提前校验可走统一的 ERROR+DONE 收尾路径而非 invoke reject。
* 因 FK 约束foreign_keys=ON抛错,提前校验可走统一的 ERROR+DONE 收尾路径而非 invoke reject。
* v0.8.0 P2-1: 排除回收站会话(deleted_at 非空 = 已删除,消息不可继续发送)。
*/
getSession(sessionId: string): SessionInfo | null {
const db = this.getDBFn();
const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) as
| SessionRow
| undefined;
const row = db
.prepare('SELECT * FROM sessions WHERE id = ? AND deleted_at IS NULL')
.get(sessionId) as SessionRow | undefined;
return row ? this.toSessionInfo(row) : null;
}
@@ -163,14 +224,20 @@ export class SessionService {
}
/**
* 删除会话(级联删除消息)
* 删除会话
*
* v0.8.0 P2-1 根治: 改为**软删除**(置 deleted_at)—— 旧实现直接硬删除,
* 误删无法恢复。现进入回收站(listDeleted 查看,30 天后 purgeExpiredTrash
* 自动清理),彻底删除由 purge 显式触发(级联清消息)。
*/
delete(sessionId: string): boolean {
const db = this.getDBFn();
const result = db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
const result = db
.prepare('UPDATE sessions SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL')
.run(Date.now(), sessionId);
if (result.changes > 0) {
log.info(`Session deleted: ${sessionId}`);
log.info(`Session moved to trash: ${sessionId}`);
return true;
}
return false;
@@ -606,14 +673,20 @@ export class SessionService {
if (aggregated.size === 0) return [];
// 补全会话标题(archived 一起搜——搜索不应因归档而缺结果)
// v0.8.0 P2-1: 回收站会话(deleted_at 非空)从搜索结果剔除
const sessionIds = Array.from(aggregated.keys());
const placeholders = sessionIds.map(() => '?').join(',');
const titleRows = db
.prepare(`SELECT id, title FROM sessions WHERE id IN (${placeholders})`)
.all(...sessionIds) as Array<{ id: string; title: string }>;
const titleMap = new Map(titleRows.map((r) => [r.id, r.title]));
.prepare(`SELECT id, title, deleted_at FROM sessions WHERE id IN (${placeholders})`)
.all(...sessionIds) as Array<{ id: string; title: string; deleted_at: number | null }>;
const titleMap = new Map(
titleRows.filter((r) => r.deleted_at == null).map((r) => [r.id, r.title]),
);
const filteredAggregated = Array.from(aggregated.entries()).filter(([sid]) =>
titleMap.has(sid),
);
return Array.from(aggregated.entries())
return filteredAggregated
.map(([sessionId, info]) => ({
sessionId,
sessionTitle: titleMap.get(sessionId) ?? '(已删除会话)',
+114 -8
View File
@@ -1,15 +1,17 @@
/**
* UpdateService — 轻量自动更新检查(v0.6.4 P4-2
* UpdateService — 轻量自动更新检查(v0.6.4 P4-2 引入,v0.8.0 P2-3 接入 electron-updater
*
* 选型说明:完整方案为 electron-updater(需签名/发布通道配套);本轮依赖冻结
* 且尚无发布基础设施,落地"feed 比对"最小闭环:
* - 配置 app.updateFeedUrl 指向版本清单 JSON
* { "version": "0.7.0", "url": "https://…/MetonaAI-Setup.exe", "notes": "…" }
* - 用户点击"检查更新"→ 主进程拉取清单 → 语义化版本比较 → 结果经 toast/UI 反馈,
* available 时提供下载直链(shell.openExternal 由既有 app:openExternal 白名单承接)
* 未来接入 electron-updater 时,本服务的 IPC 面与 UI 不变,仅替换 check() 实现
* 双轨设计:
* 1. 手动检查(check()):feed 比对式 —— 拉取版本清单 JSON 与当前版本比较,
* 结果经 IPC 直接返回(不依赖 electron-updaterdev 模式同样可用)。
* 2. 自动更新(startAutoUpdaterv0.8.0 P2-3):生产环境接入 electron-updater
* generic provider),启动 5s 后静默检查;available → 广播 update:status +
* 系统通知;用户触发下载安装(app:updateInstall)→ downloadAndInstall 后重启
* 未配置 feed / dev 模式 / electron-updater 不可用时静默禁用(可用性优先)
*/
import log from 'electron-log';
export interface UpdateFeedInfo {
version: string;
url?: string;
@@ -22,6 +24,15 @@ export type UpdateCheckResult =
| { status: 'up-to-date'; latestVersion: string }
| { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string };
/** v0.8.0 P2-3: 广播给渲染层的更新状态事件(update:status 通道载荷) */
export type UpdateStatusEvent =
| { status: 'checking' }
| { status: 'available'; latestVersion: string; notes?: string }
| { status: 'not-available'; latestVersion: string }
| { status: 'downloading'; percent: number }
| { status: 'downloaded' }
| { status: 'error'; message: string };
/**
* 语义化版本比较(仅支持 Major.Minor.Patch 与可选的预发布后缀忽略策略:
* 带预发布标签视为小于同号正式版)。非法输入返回 false(宁可不提示升级)。
@@ -91,3 +102,98 @@ export class UpdateService {
}
}
}
// ===== v0.8.0 P2-3: electron-updater 集成(生产环境自动更新) =====
/** AutoUpdater 句柄(IPC 层消费;null = 未启用/dev 模式/未配置 feed */
export interface AutoUpdaterHandle {
checkForUpdates: () => Promise<void>;
downloadAndInstall: () => Promise<void>;
}
let activeHandle: AutoUpdaterHandle | null = null;
/** 获取已启动的 AutoUpdater 句柄(app:updateInstall 消费;未启用时返回 null */
export function getAutoUpdaterHandle(): AutoUpdaterHandle | null {
return activeHandle;
}
/**
* 启动 electron-updater 自动更新(生产环境专用)。
*
* @param feedUrl 更新源根目录(generic providerapp.updateFeedUrl 配置)
* @param onEvent 状态事件回调(主进程广播 update:status 给渲染层)
* @param autoCheckDelayMs 启动后延迟检查时间(默认 5s,让窗口先就绪)
* @returns 句柄(未启用时返回 null —— dev / electron-updater 加载失败)
*/
export async function startAutoUpdater(
feedUrl: string,
onEvent: (event: UpdateStatusEvent) => void,
autoCheckDelayMs = 5_000,
): Promise<AutoUpdaterHandle | null> {
if (!feedUrl) {
log.info('[Update] auto-updater disabled: no feed URL configured');
return null;
}
try {
// 动态导入:dev 模式(未打包)下 electron-updater 无法工作(无 app-update.yml),
// 加载失败时静默禁用,保持可用性
const { autoUpdater } = await import('electron-updater');
autoUpdater.autoDownload = false; // 下载需用户触发(app:updateInstall
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.logger = null; // 事件统一经 onEvent 广播,避免双通道日志噪声
autoUpdater.on('checking-for-update', () => onEvent({ status: 'checking' }));
autoUpdater.on('update-available', (info) => {
onEvent({ status: 'available', latestVersion: String(info.version ?? ''), notes: undefined });
});
autoUpdater.on('update-not-available', (info) => {
onEvent({ status: 'not-available', latestVersion: String(info.version ?? '') });
});
autoUpdater.on('download-progress', (progress) => {
onEvent({ status: 'downloading', percent: Math.round(progress.percent ?? 0) });
});
autoUpdater.on('update-downloaded', () => onEvent({ status: 'downloaded' }));
autoUpdater.on('error', (err) => {
onEvent({ status: 'error', message: (err as Error).message });
});
// generic provider feedapp-update.yml 缺失时显式 setFeedURL 兜底)
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
const handle: AutoUpdaterHandle = {
checkForUpdates: async () => {
try {
await autoUpdater.checkForUpdates();
} catch (err) {
onEvent({ status: 'error', message: (err as Error).message });
}
},
downloadAndInstall: async () => {
try {
await autoUpdater.downloadUpdate();
onEvent({ status: 'downloaded' });
autoUpdater.quitAndInstall();
} catch (err) {
onEvent({ status: 'error', message: (err as Error).message });
}
},
};
activeHandle = handle;
// 启动 5s 后静默检查(不阻塞窗口就绪)
const timer = setTimeout(() => {
void handle.checkForUpdates();
}, autoCheckDelayMs);
timer.unref?.();
log.info(`[Update] auto-updater started (feed=${feedUrl})`);
return handle;
} catch (err) {
log.warn(
'[Update] electron-updater unavailable, auto-update disabled:',
(err as Error).message,
);
return null;
}
}
@@ -27,6 +27,18 @@ export interface WindowState {
export class WindowManager {
private windows = new Map<string, BrowserWindow>();
private activeWindowId: string | null = null;
/**
* v0.8.0 P1-3.7: 渲染进程崩溃自愈退避 —— 按 window id 记录 { count, windowStart }。
* 60s 窗口内连续崩溃 ≥3 次不再自动 reload(避免 crash→reload→crash 死循环),
* 改弹系统错误对话框;自上次 reload 起 5 分钟无崩溃则计数复位。
*/
private crashReloadAttempts = new Map<
number,
{ count: number; windowStart: number; lastAt: number }
>();
private static readonly CRASH_RELOAD_MAX = 3;
private static readonly CRASH_RELOAD_WINDOW_MS = 60_000;
private static readonly CRASH_RELOAD_RESET_MS = 5 * 60_000;
/**
* 创建新窗口
@@ -92,11 +104,51 @@ export class WindowManager {
// 崩溃可观测性 + 自愈: 渲染进程崩溃(OOM/原生崩溃)时记录归因日志并自动重载,
// 替代静默白屏(用户此前感知为"应用崩溃"且无从恢复)
// v0.8.0 P1-3.7: 增加退避 —— 60s 内连续 ≥3 次崩溃停止自动 reload 并弹窗
//(根因持久存在时旧实现会 crash→reload→crash 死循环);稳定 5 分钟后计数复位。
win.webContents.on('render-process-gone', (_event, details) => {
log.error(
`[WindowManager] render-process-gone (id=${win.id}): reason=${details.reason} exitCode=${details.exitCode}`,
);
if (!win.isDestroyed() && details.reason !== 'clean-exit') {
const now = Date.now();
const record = this.crashReloadAttempts.get(win.id) ?? {
count: 0,
windowStart: now,
lastAt: 0,
};
// 稳定 5 分钟 → 计数复位
if (record.lastAt > 0 && now - record.lastAt > WindowManager.CRASH_RELOAD_RESET_MS) {
record.count = 0;
record.windowStart = now;
}
// 60s 滑动窗口外 → 重新起算
if (now - record.windowStart > WindowManager.CRASH_RELOAD_WINDOW_MS) {
record.count = 0;
record.windowStart = now;
}
record.count += 1;
record.lastAt = now;
this.crashReloadAttempts.set(win.id, record);
if (record.count > WindowManager.CRASH_RELOAD_MAX) {
log.error(
`[WindowManager] Crash reload threshold exceeded (${record.count} in ${WindowManager.CRASH_RELOAD_WINDOW_MS / 1000}s) — NOT reloading automatically (crash loop protection)`,
);
try {
// showErrorBox 为同步 APIvoid 返回),崩溃循环保护路径直接弹窗
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { dialog } = require('electron') as typeof import('electron');
dialog.showErrorBox(
'MetonaAI Desktop 反复崩溃',
`渲染进程在 ${WindowManager.CRASH_RELOAD_WINDOW_MS / 1000} 秒内连续崩溃 ${record.count} 次,已停止自动恢复。\n\n请重启应用;若反复出现,请携带日志(设置 → 日志与数据 → 打开日志目录)反馈。`,
);
} catch {
/* dialog 不可用时仅保留日志 */
}
return;
}
try {
win.webContents.reload();
log.info(`[WindowManager] Window ${id} reloaded after renderer crash`);
+110 -2
View File
@@ -12,10 +12,12 @@
* @see standard/开发规范.md — 使用 fs/path 内置模块(非第三方库)
*/
import { join } from 'path';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join, resolve, sep } from 'path';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync } from 'fs';
import { app } from 'electron';
import log from 'electron-log';
// v0.8.0 P2-4: @ 提及文本片段的编码检测(与 read_file 同源)
import { decodeBufferWithDetection } from '../harness/tools/built-in/file-guard';
// ===== 类型定义 =====
@@ -138,6 +140,112 @@ export class WorkspaceService {
return { ...this.files };
}
// ===== v0.8.0 P2-4: @ 文件提及数据源 =====
/** 列出目录树时的噪声目录(依赖产物/版本库/运行时数据,永不进入提及索引) */
private static readonly MENTION_SKIP_DIRS = new Set([
'node_modules',
'.git',
'logs',
'.metona',
'dist',
'dist-electron',
'out',
'build',
'release',
]);
/**
* 工作空间文件名索引(@ 文件提及联想数据源)。
*
* 同步递归收集相对路径;跳过 MENTION_SKIP_DIRS 与隐藏目录;深度上限 8;
* 数量上限 maxFiles(防超大工作空间拖慢主进程)。FileGuard 的 MEMORY.md
* 保护不适用于"文件名索引"(只列名字不读内容)。
*/
listFiles(maxFiles = 2000): string[] {
const out: string[] = [];
const walk = (dir: string, rel: string, depth: number): void => {
if (out.length >= maxFiles || depth > 8) return;
let entries: import('fs').Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (out.length >= maxFiles) return;
if (entry.name.startsWith('.')) continue;
if (entry.isDirectory() && WorkspaceService.MENTION_SKIP_DIRS.has(entry.name)) continue;
const relName = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isFile()) {
out.push(relName);
} else if (entry.isDirectory()) {
walk(join(dir, entry.name), relName, depth + 1);
}
}
};
walk(this.workspacePath, '', 0);
return out;
}
/**
* 读取 @ 提及文件的文本片段(≤ maxBytes,默认 512KB,二进制拒绝)。
*
* 安全:路径 resolve 后必须位于工作空间内;根目录 MEMORY.md 与工具读取
* 同口径保护(isProtectedWorkspaceFile);文本判定复用 file-guard 的
* decodeBufferWithDetectionNUL 启发式在 BOM 之后仍命中则拒绝)。
*/
readFileClip(
relPath: string,
maxBytes = 512 * 1024,
): {
success: boolean;
error?: string;
name?: string;
content?: string;
truncated?: boolean;
size?: number;
} {
try {
const resolved = resolve(this.workspacePath, relPath);
if (resolved !== this.workspacePath && !resolved.startsWith(this.workspacePath + sep)) {
return { success: false, error: 'Path escapes workspace' };
}
if (!existsSync(resolved) || !statSync(resolved).isFile()) {
return { success: false, error: 'File not found' };
}
// 根目录 MEMORY.md 保护(与 read_file 工具同口径)
if (
resolve(resolved).toLowerCase() === resolve(this.workspacePath, 'MEMORY.md').toLowerCase()
) {
return { success: false, error: 'Access to workspace root MEMORY.md is protected' };
}
const size = statSync(resolved).size;
const buf = readFileSync(resolved);
const head = buf.subarray(0, Math.min(buf.length, 8192));
// 二进制判定:非 UTF-16 BOM 前缀下含 NUL 字节 → 拒绝(文本附件语义)
const isUtf16Bom =
(head[0] === 0xff && head[1] === 0xfe) || (head[0] === 0xfe && head[1] === 0xff);
if (!isUtf16Bom) {
for (const b of head) {
if (b === 0)
return { success: false, error: 'Binary file is not supported for @ mention' };
}
}
const clipped = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf;
const { content } = decodeBufferWithDetection(clipped);
return {
success: true,
name: relPath,
content,
truncated: buf.length > maxBytes,
size,
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
/**
* 更新 MEMORY.md 的最后更新时间戳
*/