feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
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:
@@ -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_VERSION(v0.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 透传与 updateMessageContent(R
|
||||
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 (
|
||||
|
||||
@@ -113,8 +113,9 @@ export class DatabaseService {
|
||||
* 版本号相同的库将跳过整个探测式迁移批次。
|
||||
* v0.7.4 P4-4: 1 → 2 —— 迁移 9(messages_fts trigram)纳入版本化,
|
||||
* 失败中断批次不盖章 → 下次启动重试(根治"失败被吞 + 永久跳过")。
|
||||
* v0.8.0 P2-1: 2 → 3 —— 迁移 10(sessions.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 的
|
||||
|
||||
@@ -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 能力 */
|
||||
}
|
||||
|
||||
// 注册到 ToolRegistry(v0.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 ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有连接
|
||||
*/
|
||||
|
||||
@@ -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) ?? '(已删除会话)',
|
||||
|
||||
@@ -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-updater,dev 模式同样可用)。
|
||||
* 2. 自动更新(startAutoUpdater,v0.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 provider;app.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 feed(app-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 为同步 API(void 返回),崩溃循环保护路径直接弹窗
|
||||
// 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`);
|
||||
|
||||
@@ -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 的
|
||||
* decodeBufferWithDetection(NUL 启发式在 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 的最后更新时间戳
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user