diff --git a/docs/architecture/perf-audit-review-hardening/plan.md b/docs/architecture/perf-audit-review-hardening/plan.md new file mode 100644 index 000000000..315c32d00 --- /dev/null +++ b/docs/architecture/perf-audit-review-hardening/plan.md @@ -0,0 +1,30 @@ +# Perf Audit Review Hardening Plan + +## Approach + +- Treat startup soft timeouts and foreground API readiness as separate concerns. +- Prefer batch/keyset reads over cursor iteration when the same SQLite connection writes during + backfill processing. +- Add narrowly targeted tests for each reviewed regression rather than broad refactors. + +## Affected Interfaces + +- MCP client/presenter lifecycle behavior around startup, cancellation, shutdown, and foreground + tool/list/resource calls. +- Session store activation routing guards in the renderer. +- Generated icon collection inputs and freshness validation. +- Usage dashboard contract schema for backfill status progress fields. +- Shared MCP type exports through `@shared/presenter`. + +## Compatibility + +- Existing MCP servers can still start in the background after the startup soft timeout. +- Existing workspace preview URLs remain valid; large files are streamed instead of rejected. +- Existing SQLite repair startup behavior remains intact. + +## Test Strategy + +- Add unit tests for startup diagnosis scheduling, MCP soft timeout readiness/cancellation, session + activation races, icon whitelist generation, contract schema preservation, and placeholder history + loading. +- Run existing main and renderer test suites after targeted tests pass. diff --git a/docs/architecture/perf-audit-review-hardening/spec.md b/docs/architecture/perf-audit-review-hardening/spec.md new file mode 100644 index 000000000..89cec6e59 --- /dev/null +++ b/docs/architecture/perf-audit-review-hardening/spec.md @@ -0,0 +1,36 @@ +# Perf Audit Review Hardening Spec + +## User Need + +The `perf/audit-fixes` branch should keep its performance improvements without introducing startup, +MCP, renderer navigation, icon, backfill, shutdown, or contract regressions found during PR review. + +## Goal + +Fix the merge-blocking review issues and the highest-value follow-up issues that directly undermine +the performance audit goals. + +## Acceptance Criteria + +- Startup schema diagnosis does not run a duplicate foreground/background pass on healthy startup. +- MCP startup soft timeout only releases startup orchestration; foreground MCP APIs wait for a real + connection and cancellation does not produce false connection-failure notifications. +- Session activation hydration cannot navigate back to stale sessions after later navigation. +- Generated icon collections include literal icons declared in shared code. +- Backfills avoid full-table materialization and do not rely on long-lived write-conflicting + iterators. +- Plugin-owned MCP server shutdown has the same timeout behavior as normal MCP shutdown. +- Shared contracts and type barrels do not strip or ambiguously re-export newly added fields/types. +- Pending assistant placeholders are not cleared by loading older history. +- Workspace preview handles large streamed files without silent broken previews. + +## Constraints + +- Preserve the existing repair-before-startup behavior for repairable SQLite schema drift. +- Preserve the public MCP startup soft-timeout behavior for initial app startup responsiveness. +- Keep changes scoped to the audited regressions; lower-risk naming/log cleanup can remain follow-up. + +## Non-Goals + +- Revisit the previously deferred F2/F4/F7/F10/F14 audit items. +- Redesign StartupWorkloadCoordinator scheduling beyond fixing confirmed regressions. diff --git a/docs/architecture/perf-audit-review-hardening/tasks.md b/docs/architecture/perf-audit-review-hardening/tasks.md new file mode 100644 index 000000000..d4ef961f8 --- /dev/null +++ b/docs/architecture/perf-audit-review-hardening/tasks.md @@ -0,0 +1,13 @@ +# Perf Audit Review Hardening Tasks + +- [x] Fix duplicate foreground schema diagnosis and update F1 docs. +- [x] Harden MCP soft-timeout readiness, cancellation, auth restart, global disable, and shutdown. +- [x] Guard stale session activation navigation and notification. +- [x] Include shared icon sources and add generated icon freshness checking. +- [x] Convert backfills to batch/keyset reads without full-table materialization. +- [x] Preserve usage backfill progress fields across contract parsing. +- [x] Remove ambiguous MCP type re-exports. +- [x] Make pending assistant placeholder detection order-aware. +- [x] Let workspace preview stream large files or show explicit UI errors. +- [x] Add targeted regression tests and run validation. + - Full `pnpm test` still has one existing failure in `agentSessionPresenter/integration.test.ts` for long converted steer input context rebudgeting. diff --git a/docs/architecture/perf-fixes-from-audit/fix-F01-sqlite-startup-defer.md b/docs/architecture/perf-fixes-from-audit/fix-F01-sqlite-startup-defer.md new file mode 100644 index 000000000..d3321685d --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F01-sqlite-startup-defer.md @@ -0,0 +1,95 @@ +# 修复 F1:SQLite 启动关键路径收缩与诊断去重 + +## 目标 +缩短 DeepChat 主进程启动关键路径中的 SQLite 阻塞时间,但不改变当前数据库初始化的正确性边界: +- 关键路径仍保留数据库打开、最小连接验证、建表、版本表初始化、迁移与必要的启动期 schema 处理。 +- 保留启动期自动 repair 所需的 schema 诊断,不再额外注册重复的观察性后台诊断。 +- 先补齐分阶段 duration 日志,把启动耗时拆解为可观测的 `open / initTables / migrate / diagnose / repair` 五段。 +- 将“非关键表延迟建表”降级为可选后续优化,不作为本修复的必做项。 + +## 定位 +- 启动链路中,`databaseInitHook` 会同步执行 `DatabaseInitializer.initialize()`,随后再调用 `DatabaseInitializer.migrate()`,见 [src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts#L45-L53](../../../src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts#L45-L53)。 +- `DatabaseInitializer.initialize()` 当前会在启动关键路径内: + 1. `new SQLitePresenter(...)` + 2. `validateConnection()` + 3. `diagnoseStartupSchema()` + 4. 必要时执行 `repairSQLiteDatabaseFile(...)` + 见 [src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts#L42-L123](../../../src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts#L42-L123)。 +- `DatabaseInitializer.migrate()` 目前只是日志占位,真实迁移已在 `SQLitePresenter` 初始化阶段完成,见 [src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts#L125-L142](../../../src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts#L125-L142)。 +- `SQLitePresenter` 构造函数会同步调用 `initializeDatabase()`,见 [src/main/presenter/sqlitePresenter/index.ts#L255-L263](../../../src/main/presenter/sqlitePresenter/index.ts#L255-L263)。 +- `initializeDatabase()` 当前同步执行 `openSQLiteDatabase`、`SELECT 1`、`initTables()`、`initVersionTable()`、`migrate()`,见 [src/main/presenter/sqlitePresenter/index.ts#L322-L329](../../../src/main/presenter/sqlitePresenter/index.ts#L322-L329)。 +- `initTables()` 当前一次性实例化并创建 27 张活跃表,且与 `getMigrationTables()` 返回值和大量实例属性强耦合,见 [src/main/presenter/sqlitePresenter/index.ts#L406-L466](../../../src/main/presenter/sqlitePresenter/index.ts#L406-L466) 与 [src/main/presenter/sqlitePresenter/index.ts#L483-L512](../../../src/main/presenter/sqlitePresenter/index.ts#L483-L512)。 +- `migrate()` 在 presenter 内部同步执行版本迁移,见 [src/main/presenter/sqlitePresenter/index.ts#L514-L571](../../../src/main/presenter/sqlitePresenter/index.ts#L514-L571)。 +- `StartupWorkloadCoordinator.scheduleTask()` 的任务参数强制要求 `target`,且 `target` 类型来自 `StartupWorkloadTargetSchema`,见 [src/main/presenter/startupWorkloadCoordinator/index.ts#L26-L36](../../../src/main/presenter/startupWorkloadCoordinator/index.ts#L26-L36) 与 [src/shared/contracts/common.ts#L268-L268](../../../src/shared/contracts/common.ts#L268-L268)。当前合法取值只有 `'main'` 与 `'settings'`。 +- 现有启动任务示例均使用 `target: 'main'`,例如 `main:mcp-init`,见 [src/main/presenter/index.ts#L744-L835](../../../src/main/presenter/index.ts#L744-L835)。 +- 经代码搜索,`SQLitePresenter` 仅在一处被实例化:`DatabaseInitializer.ts:50`,不存在“多处 new SQLitePresenter”这一现状。对应搜索结果见 [src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts#L50-L50](../../../src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts#L50-L50)。 + +## 修复方案 +1. **不再注册重复后台诊断** + - 当前同步 `diagnoseStartupSchema()` 仍承担 repair 前置判定职责,不能直接后移。 + - 成功路径不再注册第二次 `main:sqlite-schema-diagnosis`,避免 `scheduleTask()` 立即 pump 后在 INIT 阶段同步执行第二次诊断。 + - `SQLitePresenter` 继续只负责数据库能力,不直接依赖启动编排层。 + +2. **合法 target 必须来自现有 schema** + - `scheduleTask()` 必须显式传 `target`,且取值受 `StartupWorkloadTargetSchema` 限制,目前只有 `'main'` 和 `'settings'` 两个合法值,来源见 [src/shared/contracts/common.ts#L268-L268](../../../src/shared/contracts/common.ts#L268-L268)。 + - 本修复所处链路是主进程主启动流程,因此若接入现有启动 run,应使用现有主流程 target:`'main'`。 + - 文档和示例中不要使用不存在的 `'app'` 值;若需复用当前启动 run,应由上层把 `createRun('main')` 生成的 `runId` 一并传入。 + +3. **关键路径与后台任务重新分界** + - 保持关键路径: + - `new SQLitePresenter(...)` + - `validateConnection()` + - 启动成功前必须完成的建表与迁移 + - 对“启动即应修复”的 schema 问题做一次同步修复尝试(当前行为在 `DatabaseInitializer.initialize()` 中) + - 不再重复执行: + - 成功启动后仅用于观察/告警的第二次 schema 诊断 + - 由第二次诊断产生的重复 repair/manual issue 摘要日志 + - 若后续增加更温和的补充检查,必须保证不会在 `initialize()` 返回前同步运行 + - 若保留当前“同步 diagnose 后决定 repair”的行为,则本修复第一阶段至少应先补充日志,不应在没有替代保障前贸然移除同步诊断。 + +4. **阶段耗时日志覆盖五阶段** + - 必须分别记录以下五段 duration,而不是只记总时长: + - `open`:围绕 `openSQLiteDatabase` + `SELECT 1` + - `initTables`:围绕 `initTables()` + - `migrate`:围绕 `migrate()` + - `diagnose`:围绕 `diagnoseStartupSchema()` / `database.diagnoseSchema(...)` + - `repair`:围绕 `repairSQLiteDatabaseFile(...)` + - 建议统一使用 `performance.now()`,并采用稳定日志格式,例如: + - `DatabaseInitializer: phase=diagnose duration=...ms` + - `DatabaseInitializer: phase=repair duration=...ms` + - `SQLitePresenter: phase=open duration=...ms` + - `SQLitePresenter: phase=initTables duration=...ms` + - `SQLitePresenter: phase=migrate duration=...ms` + +5. **后续补充诊断的接入约束** + - 如果未来重新引入观察性诊断,必须由持有 coordinator 的层调度,并保证任务体第一段不会同步阻塞 `DatabaseInitializer.initialize()`。 + - 任何补充诊断只能记录日志或健康状态,不能改变本次启动的 repair/continue 决策。 + +## 步骤拆分 +1. **先补 observability** + - 在 `SQLitePresenter.initializeDatabase()` 中补 `open / initTables / migrate` 三段 `performance.now()` 日志。 + - 在 `DatabaseInitializer.initialize()` / `diagnoseStartupSchema()` 的调用链中补 `diagnose / repair` 两段 `performance.now()` 日志。 + +2. **去掉重复观察性诊断** + - `DatabaseInitializer.initialize()` 成功路径只依赖一次 startup diagnosis。 + - 不再向 `DatabaseInitializer` 注入 `startupWorkloadCoordinator` / `runId` / `target`,因为当前没有需要额外调度的补充诊断。 + - 保留同步 diagnose 的 repair 判定链路,避免丢失启动期自动修复入口。 + +4. **非关键表延迟建表列为可选后续项** + - 不把拆分 `initTables()` 作为 F1 必做交付。 + - 若后续继续推进,应单列子任务验证首次访问守卫、迁移表清单、表实例生命周期与测试覆盖。 + +## 验证 +- 文档一致性:确认方案中不再声称 `SQLitePresenter` 直接调度 coordinator,也不再使用非法 target 值 `'app'`。 +- 代码落地后应验证: + - 五段日志 `open / initTables / migrate / diagnose / repair` 均能输出。 + - 健康启动只执行一次 startup schema diagnosis。 + - `SQLitePresenter` 实例化点仍只有 [src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts#L50-L50](../../../src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts#L50-L50) 这一处,除非后续设计明确新增构造点。 +- 静态/常规检查按仓库规范执行:`pnpm run format`、`pnpm run i18n`、`pnpm run lint`。 + +## 风险 +- **同步 diagnose 当前承担 repair 前置判定职责**:`DatabaseInitializer.initialize()` 现在会依据诊断结果决定是否调用 `repairSQLiteDatabaseFile(...)`,若直接把 diagnose 后移,可能丢失启动期自动修复入口。 +- **`initTables()` 拆分风险高于文档初版假设**:当前 27 张表的实例化、`createTable()` 调用、`getMigrationTables()` 返回值和公开属性之间高度耦合,见 [src/main/presenter/sqlitePresenter/index.ts#L406-L466](../../../src/main/presenter/sqlitePresenter/index.ts#L406-L466) 与 [src/main/presenter/sqlitePresenter/index.ts#L483-L512](../../../src/main/presenter/sqlitePresenter/index.ts#L483-L512)。贸然拆成“核心/非核心表”可能破坏迁移顺序、属性可用性或首次访问假设。 +- **延迟建表只能作为可选优化**:若后续要延迟非关键表建表,必须补齐首次使用前的存在性守卫,并验证所有读取路径不会提前访问这些表。 +- **重复诊断会伪装成后台任务**:`StartupWorkloadCoordinator.scheduleTask()` 会立即 pump;若任务体第一段仍是同步 SQLite 诊断,就会在 `initialize()` 返回前完成,反而增加关键路径耗时。 +- **repair 仍包含同步文件 IO**:即使后续把部分诊断转入后台,`repairSQLiteDatabaseFile(...)` 涉及文件级操作,仍需谨慎控制触发时机,避免与其他数据库访问竞争。 diff --git a/docs/architecture/perf-fixes-from-audit/fix-F03-backfill-coordinator.md b/docs/architecture/perf-fixes-from-audit/fix-F03-backfill-coordinator.md new file mode 100644 index 000000000..687de091b --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F03-backfill-coordinator.md @@ -0,0 +1,182 @@ +# 修复 F3:backfill 纳入 coordinator + keyset batch + +## 目标 +将 5 个 after-start 后台任务统一纳入 `StartupWorkloadCoordinator` 的 `scheduleTask(...)` 调度,避免它们在首屏后以 fire-and-forget 方式无约束并发;同时把 mainline normalization / usage stats backfill 从 `.all()` 全量读改为 keyset 小批分页、小批次让出主线程,并补齐 `processedCount` 与耗时观测。 + +本项目标是降低两类风险: +- 首屏后多个后台任务同时争抢主线程 / IO,导致 UI 卡顿。 +- 大表一次性 `.all()` 读取造成内存峰值和长时间同步阻塞。 + +## 定位 +### 2.1 5 个 hook 目前都绕过 coordinator +以下 5 个 hook 都是 `void startX().catch(...)`,虽然有 priority,但并没有进入 coordinator 的 `scheduleTask(...)`: +- [`legacyImportHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/legacyImportHook.ts#L5-L26):`priority: 20` +- [`rtkHealthCheckHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/rtkHealthCheckHook.ts#L5-L25):`priority: 20` +- [`usageStatsBackfillHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/usageStatsBackfillHook.ts#L5-L25):`priority: 21` +- [`sqliteMainlineNormalizationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/sqliteMainlineNormalizationHook.ts#L5-L29):`priority: 22` +- [`disabledSearchToolCleanupHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/disabledSearchToolCleanupHook.ts#L5-L29):`priority: 23` + +而 coordinator 已提供: +- `taskContext.yield()` / `reportProgress()` / `signal`:[`startupWorkloadCoordinator/index.ts`](../../../src/main/presenter/startupWorkloadCoordinator/index.ts#L20-L35) +- `cpu: 1`、`io: 2` 的并发上限:[`startupWorkloadCoordinator/index.ts`](../../../src/main/presenter/startupWorkloadCoordinator/index.ts#L72-L75) +- `scheduleTask(...)` 的任务排队与执行:[`startupWorkloadCoordinator/index.ts`](../../../src/main/presenter/startupWorkloadCoordinator/index.ts#L150-L245) + +### 2.2 presenter 入口尚未接收 taskContext +当前 `agentSessionPresenter` 的公开入口仍是不带 context 的 `startX()`: +- [`startLegacyImport`](../../../src/main/presenter/agentSessionPresenter/index.ts#L1609-L1611) +- [`startUsageStatsBackfill`](../../../src/main/presenter/agentSessionPresenter/index.ts#L1613-L1632) +- [`startMainlineNormalizationBackfill`](../../../src/main/presenter/agentSessionPresenter/index.ts#L1634-L1654) +- [`startDisabledSearchToolCleanupBackfill`](../../../src/main/presenter/agentSessionPresenter/index.ts#L1656-L1678) +- [`startRtkHealthCheck`](../../../src/main/presenter/agentSessionPresenter/index.ts#L1680-L1682) + +因此,文档若要求在循环中调用 `taskContext.yield()`,必须同步补上 hook → presenter 的签名改造或适配层,否则 coordinator 提供的 context 无法真正传入 backfill 实现。 + +### 2.3 mainline normalization / usage stats 目前仍有全量读取与粗粒度 yield +- `runMainlineNormalizationBackfill()`: + - `SELECT * FROM new_sessions ORDER BY updated_at ASC` + `.all()`:[`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3424-L3431) + - `SELECT * FROM deepchat_messages ORDER BY created_at ASC` + `.all()`:[`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3462-L3464) + - 每 200 条才 `yieldToEventLoop()`:[`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3456-L3459), [`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3468-L3477) +- `runUsageStatsBackfill()`: + - 先拿 `listAssistantUsageCandidates()` 的全量数组:[`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3685-L3687) + - 每 200 条才 `yieldToEventLoop()`:[`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3720-L3732) + +### 2.4 rtk health check 的 resource 分类结论 +`rtk-health-check` 应归类为 `resource: 'io'`,不应写成待定。 + +依据: +- `startRtkHealthCheck()` 直接转调 `rtkRuntimeService.startHealthCheck()`:[`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L1680-L1682) +- `startHealthCheck()` 进入 `runHealthCheck(...)`:[`rtkRuntimeService.ts`](../../../src/main/lib/agentRuntime/rtkRuntimeService.ts#L285-L290) +- 该服务依赖 shell 环境探测、runtime 初始化、外部命令执行等 IO/子进程行为,而非纯内存计算: + - `getShellEnvironment` / `runCommand` / `RuntimeHelper` 注入:[`rtkRuntimeService.ts`](../../../src/main/lib/agentRuntime/rtkRuntimeService.ts#L66-L79) + - `runCommandImpl(...)` 用于执行 `gain` / `rewrite` 等 runtime 命令:[`rtkRuntimeService.ts`](../../../src/main/lib/agentRuntime/rtkRuntimeService.ts#L322-L330), [`rtkRuntimeService.ts`](../../../src/main/lib/agentRuntime/rtkRuntimeService.ts#L413-L416) + +所以该任务与 legacy import / 其余 backfill 一样,更符合 `background + io` 调度模型。 + +## 修复方案 +### 3.1 5 个 hook 全部改为 coordinator 调度 +将现有 `void startX().catch(...)` 改为 `startupWorkloadCoordinator.scheduleTask(...)`,统一进入 background 队列。 + +建议映射: +- `legacy-import`:`phase: 'background'`,`resource: 'io'` +- `rtk-health-check`:`phase: 'background'`,`resource: 'io'` +- `usage-stats-backfill`:`phase: 'background'`,`resource: 'io'` +- `sqlite-mainline-normalization`:`phase: 'background'`,`resource: 'io'` +- `disabled-search-tool-cleanup`:`phase: 'background'`,`resource: 'io'` + +说明: +- 这 5 项都以数据库、配置、runtime 探测、子进程或磁盘数据处理为主,不属于长时间纯计算型 `cpu` 任务。 +- 当前 `MAX_CONCURRENCY` 为 `io: 2`,因此纳入 coordinator 后可避免 5 个任务无上限地同时抢占资源:[`startupWorkloadCoordinator/index.ts`](../../../src/main/presenter/startupWorkloadCoordinator/index.ts#L72-L75) + +### 3.2 补齐 hook → presenter → 内部实现的 taskContext 接线 +这是本次 review 必须补充的关键步骤。 + +当前 `startUsageStatsBackfill()` / `startMainlineNormalizationBackfill()` 等入口不接收参数,因此需要二选一: + +方案 A:直接扩展公开入口签名 +```ts +async startMainlineNormalizationBackfill(taskContext?: StartupWorkloadTaskContext): Promise +``` +内部把 `taskContext` 继续传给 `runMainlineNormalizationBackfill(taskContext)`,未通过 coordinator 调用时则走默认 context(例如回退到现有 `yieldToEventLoop()` 封装)。 + +方案 B:保留现有 `startX()` 接口,增加 coordinator 专用入口 +```ts +async startMainlineNormalizationBackfill(): Promise +async runMainlineNormalizationBackfillWithContext(taskContext: StartupWorkloadTaskContext): Promise +``` +hook 在 `scheduleTask.run(context)` 中调用新入口;原 `startX()` 继续服务现有外部调用与 Promise 门控逻辑。 + +推荐优先 B: +- 改动更渐进,不强迫所有现有调用方一起升级签名。 +- 可以保留 `this.mainlineNormalizationPromise` / `this.usageStatsBackfillPromise` 之类的去重门控,再由 context-aware 内部实现复用。 + +无论选 A 还是 B,文档都要求明确:**只有把 context 真正传入 backfill 循环,`taskContext.yield()` 才能替代当前的 `yieldToEventLoop()` 并与 coordinator 协作。** + +### 3.3 mainline normalization:keyset 分页,去掉全表 `.all()` 与 `SELECT *` +`runMainlineNormalizationBackfill()` 需要同时修两件事: +1. 不再 `SELECT *` +2. 不再 `.all()` 全表进内存 + +实现方案:按 `(updated_at, id)` / `(created_at, id)` keyset 分页,每页初始 50 条。 + +```ts +SELECT id, title, updated_at +FROM new_sessions +WHERE updated_at > ? OR (updated_at = ? AND id > ?) +ORDER BY updated_at ASC, id ASC +LIMIT ? +``` + +`deepchat_messages` 同理,只取 `backfillNormalizedMessageRow(...)` 真正需要的列: + +```ts +SELECT id, session_id, role, status, content, updated_at, created_at +FROM deepchat_messages +WHERE created_at > ? OR (created_at = ? AND id > ?) +ORDER BY created_at ASC, id ASC +LIMIT ? +``` + +选择 keyset 分页而不是长生命周期 `iterate()`,是为了避免同一 SQLite 连接在 cursor 未关闭时继续写入 normalized tables 导致 `connection is busy`。 + +### 3.4 usage stats backfill 同样改为分页候选 +`runUsageStatsBackfill()` 当前先拿 `listAssistantUsageCandidates()` 全量数组,再同步遍历:[`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3685-L3732) + +应改为: +- `deepchatMessagesTable.listAssistantUsageCandidatesPage(cursor, limit)`:优先,keyset 分页接口。 +- `listAssistantUsageCandidates()`:仅作为旧测试/旧表实现的兼容兜底,不应作为生产路径。 + +循环中按 batch 更新运行状态并 `await taskContext.yield()`,替代当前每 200 条才 `yieldToEventLoop()` 的实现。 + +### 3.5 batch 大小表述改为“初始 50,待压测确定” +此前文档把 200 → 50 写成近似定案,但仓内没有现成基准支撑,因此这里改为: +- **初始 batchSize 设为 50**,作为首轮实现的保守起点。 +- **最终批次大小待压测确定**,允许在 25–100 区间内根据真实数据集与 UI 平滑度调整。 + +这样既避免沿用当前 200 的粗粒度,也不把 50 写成没有证据支持的最终结论。 + +### 3.6 processedCount / durationMs 观测补齐 +建议所有相关 backfill 至少补以下观测: +- `startedAt` +- `processedCount` +- `updatedCount` / `configUpdatedCount`(如适用) +- `finishedAt` +- `durationMs` + +运行中状态应按 batch 粒度刷新 `processedCount`;完成日志记录总耗时,方便判断限流后是否出现吞吐下降。 + +## 步骤拆分 +1. 5 个 after-start hook 从 `void startX().catch(...)` 改为 `startupWorkloadCoordinator.scheduleTask(...)`。 +2. 为 `startUsageStatsBackfill` / `startMainlineNormalizationBackfill` / `startDisabledSearchToolCleanupBackfill` / `startLegacyImport` / `startRtkHealthCheck` 补 context 接线:扩签名或增加 coordinator 适配入口。 +3. `runMainlineNormalizationBackfill()` 改为只查必要列,优先 `prepare(...).iterate()`,移除 `.all()` 全量读取。 +4. `runUsageStatsBackfill()` 改为迭代器或分页候选,按 batch `yield`。 +5. 加 `processedCount`、`durationMs`、批次状态刷新,并补验证与压测记录。 + +## 验证 +- 单元测试 / 集成测试建议覆盖: + - hook 是否改为调用 `scheduleTask(...)`,而不是直接 `void startX()`。 + - `scheduleTask` 后 coordinator 的 `io` 并发上限是否生效(不应超过 2 个同类任务同时 running)。 + - `taskContext.yield()` 的调用节奏是否符合 batch 配置。 + - mainline normalization / usage stats 在流式实现下结果与现状一致。 +- `iterate()` 可用性验证: + - 确认 `better-sqlite3-multiple-ciphers` 在当前仓库安装版本下运行时支持 `statement.iterate()`。 + - 确认项目的 TypeScript 类型声明允许直接调用 `iterate()`;若类型缺失,需要先补类型声明或在方案中明确采用分页兜底。 +- 大数据验证: + - 使用 synthetic 数据或现有大样本库验证 `.all()` 移除后内存峰值下降。 + - 对比 batchSize=50 等候选值下的总耗时与 UI 平滑度,决定最终阈值。 +- 常规校验: + - `pnpm run typecheck` + - `pnpm run lint` + - `pnpm run format` + - `pnpm run i18n` + - E2E:`01-launch` +- 日志 / 状态检查: + - 完成日志包含 `processedCount`、`durationMs` + - 运行中状态包含周期性刷新的 `processedCount` + +## 风险 +- `iterate()` 运行时可用 ≠ 类型立即可用;若 TS 声明缺失,需要补声明,否则只能先走分页兜底。 +- `LIMIT/OFFSET` 在大表深翻页时会随着 offset 增大而退化,因为 SQLite 仍需扫描并跳过前面的记录;因此分页只能作为兼容兜底,不能视作与 `iterate()` 等价的长期方案。 +- 只查必要列后,需确认 [`backfillNormalizedMessageRow`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3593-L3641) 等下游逻辑没有隐式依赖被删掉的字段。 +- coordinator 调度会改变启动后这些后台任务的开始时机,需要确认不会影响任何必须“立刻完成”的初始化语义。 +- batch 调小会提升让出频率,能改善卡顿,但也可能拉长总耗时;最终值必须以压测结果为准。 +- `usageStatsBackfill` 的运行中 / 完成 / 超时语义必须保持兼容,避免因改造 iterator / 分页接口造成重复跑、漏跑或状态卡死。 diff --git a/docs/architecture/perf-fixes-from-audit/fix-F05-mcp-startup-timeout.md b/docs/architecture/perf-fixes-from-audit/fix-F05-mcp-startup-timeout.md new file mode 100644 index 000000000..9fab5a93d --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F05-mcp-startup-timeout.md @@ -0,0 +1,94 @@ +# 修复 F5:MCP 后台启动 soft timeout + 扩展状态事件 + +## 目标 +- 为 MCP 启动补一层 **soft timeout**,避免慢 server 把后台初始化长时间拖在单个连接上。 +- 保留现有 **5 分钟 hard timeout** 作为最终失败兜底;soft timeout 只负责“尽快放行启动编排”。 +- 明确扩展 per-server 状态事件结构,支持 `connecting` / `timeout` / `retrying` / `connected` / `failed`,而不是继续复用当前 `running/stopped` 布尔态。 +- 明确 soft timeout 后的 server/client 生命周期,以及它与 shutdown 的交互边界。 + +## 定位 +- `initializeMcp()` 已通过 `startupWorkloadCoordinator.scheduleTask({ id: 'main:mcp-init', phase: 'background', resource: 'io' })` 进入后台任务,因此当前问题不再是首屏被 MCP 同步阻塞,而是后台初始化长尾过长。[`src/main/presenter/index.ts#L795`](../../../src/main/presenter/index.ts#L795) +- `McpPresenter.initialize()` 会先串行做 npm registry 探测,再启动 custom prompts server,随后对 `enabledServers` 使用 `for...of + await` 串行 `startServer()`;单个慢连接会线性拖长总耗时。[`src/main/presenter/mcpPresenter/index.ts#L157`](../../../src/main/presenter/mcpPresenter/index.ts#L157) [`src/main/presenter/mcpPresenter/index.ts#L195`](../../../src/main/presenter/mcpPresenter/index.ts#L195) +- `McpPresenter.shutdown()` 当前仍是串行遍历 running clients 并逐个 `await this.stopServer(...)`;若某个 client 正处于慢连接或慢关闭链路,shutdown 会被拖住。[`src/main/presenter/mcpPresenter/index.ts#L240`](../../../src/main/presenter/mcpPresenter/index.ts#L240) +- `ServerManager.startServer()` 在创建 `McpClient` 后立即 `await client.connect()`;只要 connect 抛错,就会执行 `clients.delete(name)` 并记录 last error。现状没有“soft timeout 后保留 client 持续后台连接”的语义空间。[`src/main/presenter/mcpPresenter/serverManager.ts#L222`](../../../src/main/presenter/mcpPresenter/serverManager.ts#L222) +- `McpClient.emitServerStatusChanged()` 当前只发送 `{ status: 'running' | 'stopped', isRunning: boolean }`,并同步发布 deepchat 事件里的 `isRunning` 布尔态。这个事件结构无法直接表达 `connecting`、`timeout`、`retrying`、`failed` 等中间态,因此本项必须扩展事件 contract,而不是“复用现有布尔态”。[`src/main/presenter/mcpPresenter/mcpClient.ts#L164`](../../../src/main/presenter/mcpPresenter/mcpClient.ts#L164) +- `McpClient.connect()` 目前只有单层 `Promise.race(connectPromise, timeoutPromise)`,timeout 固定 5 分钟;连接成功后发 `running`,没有 soft timeout、没有 startup retry、也没有中间状态事件。[`src/main/presenter/mcpPresenter/mcpClient.ts#L467`](../../../src/main/presenter/mcpPresenter/mcpClient.ts#L467) +- `McpClient` 现有恢复逻辑只覆盖“已建立会话后出现 session error”场景:`checkAndHandleSessionError()` 会 cleanup 并允许后续再连,但不是启动阶段自动 retry,更不会在 startup timeout 后继续后台重试。[`src/main/presenter/mcpPresenter/mcpClient.ts#L933`](../../../src/main/presenter/mcpPresenter/mcpClient.ts#L933) + +### 根因 +- 现状只有 **5 分钟硬超时**,缺少“先放行初始化、慢 server 后续再处理”的 soft/hard 分层,因此单个 server 的最坏启动等待就是 5 分钟。 +- `enabledServers` 启动是串行的,多个慢 server 会把长尾线性叠加。 +- 状态事件只有 `running/stopped` 布尔语义,UI/日志/调度层都无法区分“正在连接”“soft timeout 后后台重试”“最终失败”等状态,导致方案无法只靠现有事件落地。 +- `ServerManager.startServer()` 的失败路径会立刻删 client;如果 soft timeout 仍按“抛错即清理”的现状处理,就等于把 soft timeout 当 hard failure,无法实现“放行编排但保留后台连接”。 +- shutdown 只会 stop 当前 running clients;若引入启动期后台 retry/慢连接但不定义其生命周期与 shutdown 行为,退出路径会继续出现不可控长尾。 + +## 修复方案 +### 4.1 明确采用 soft/hard 两层 timeout +- 保留现有 **5 分钟 hard timeout** 作为最终失败兜底,不改其职责。 +- 新增 **startup soft timeout**:仅用于应用启动/初始化场景,达到阈值后立即把该 server 标记为“慢启动”,并让初始化编排继续处理其他 server。 +- soft timeout 值当前没有现网分布、registry 10s 探测耗时分布或 npm 安装耗时统计支撑,因此应表述为 **待压测确定的参数**;初始建议值可先取 **45s**,后续通过压测与样本日志再调。 + +### 4.2 扩展状态事件 contract,不复用布尔态 +- 需要新增一个明确的 server lifecycle 状态枚举,例如: + - `connecting` + - `timeout` + - `retrying` + - `connected` + - `failed` + - `stopped` +- 推荐把 `MCP_EVENTS.SERVER_STATUS_CHANGED` 的 payload 从当前 `{ status: 'running' | 'stopped', isRunning: boolean }` 扩展为结构化对象,例如包含: + - `name` + - `lifecycleStatus` + - `isRunning`(可保留作兼容字段,但不再承载全部语义) + - `phase?: 'startup' | 'manual' | 'retry' | 'shutdown'` + - `attempt?: number` + - `reason?: 'soft-timeout' | 'hard-timeout' | 'connect-error' | 'shutdown'` +- deepchat 事件 `mcp.server.status.changed` 也需同步扩展,避免 renderer / 日志 / 诊断各自猜测布尔态含义。 +- 兼容策略建议:短期可保留 `isRunning` 供旧消费方读取,但新增逻辑统一读取新的枚举状态;文档中不要再写“复用 running/stopped 表示 connecting/timeout”。 + +### 4.3 soft timeout 后保留 client,推荐转入后台 retrying +- 推荐方案:**soft timeout 不执行现有失败清理路径**,而是保留已创建的 `McpClient`,将其标记为 `timeout -> retrying`,让底层连接继续在后台完成或进入受控 retry。 +- 不推荐把 soft timeout 直接映射到 `cleanupResources()` / `clients.delete(name)`:那会把体验层的“超时放行”误当成真实失败,导致每次慢启动都被重置,既无法后台连上,也与现有 `ServerManager.startServer()` 的 hard failure 语义混淆。 +- 为了落地这一点,需要把启动结果拆成至少两类: + 1. **hard failure**:连接明确失败或命中 5 分钟硬超时,沿用删除 client、记录 last error、发 failed 的路径。 + 2. **soft timeout**:启动编排层收到“先放行”的结果,但 client 仍保留在 manager 中,状态为 `retrying` 或 `connecting-slow`,后续由 client 自身或 presenter 调度器继续完成连接。 +- 推荐实现方向: + - `client.connect()` 返回可区分 `connected` / `soft-timeout-released` / `failed` 的结果,而不是只靠 throw。 + - `serverManager.startServer()` 只在 hard failure 时删除 client;soft timeout 时保留 client 引用并更新状态。 + - 若后台最终连上,再发 `connected`;若后台最终命中 hard timeout 或真实错误,再转 `failed` 并清理。 + +### 4.4 启动 retry 与 shutdown 的交互 +- 当前代码没有 startup retry;如果本项要支持 soft timeout 后“后台继续连”,就必须显式补一层启动阶段 retry / completion 管理,而不是误用 session error 恢复逻辑。 +- retry 形态建议: + - 对“连接仍在进行中”的场景,优先允许原 connect promise 继续跑完,不要重复发起并行 connect。 + - 只有在原连接明确失败后,才进入下一次延迟 retry。 + - retry 间隔建议作为 **待压测确定的参数**;初始可用固定退避或小规模指数退避。 +- shutdown 必须与该机制配套: + - 若 client 处于 `connecting` / `retrying`,shutdown 不能只看 `getRunningClients()` 的已运行集合,否则这些慢 client 可能绕过停服逻辑。 + - 推荐把 shutdown 扩展为处理“running + connecting/retrying”两类 active clients,并对 stop / cancel connect 设独立超时兜底。 + - 若底层连接仍在进行,shutdown 应优先设置取消标记/中断后续 retry,再执行 disconnect/cleanup;避免应用退出后后台仍残留连接任务。 +- 这也意味着 F5 与 F8 存在交叉:F8 的 shutdown timeout 需要覆盖“慢关闭”,F5 还要额外覆盖“慢连接拖住 shutdown”的场景。 + +### 4.5 启动并发改为待测参数,初始建议 2–3 +- `enabledServers` 当前串行启动,长尾最差;推荐改为 **受限并发**。 +- 并发值同样缺少真实分布支撑,不应写成拍脑袋常量结论。建议表述为: + - **待压测确定的参数**; + - 初始建议取 **2–3**; + - server 数少(1–2 个)时并发收益有限,可保持等于 server 数; + - server 数较多、且存在 npm 包安装/子进程启动/网络探测时,优先从 2 起测,资源更宽裕的机器再看 3。 +- 即使并发参数暂不落地,soft timeout + 扩展状态事件也应先做,因为它们直接决定“不会被单个 5 分钟连接卡住”这件事。 + +## 验证 +- 代码层验证:确认状态事件 contract 已从布尔态扩展为枚举态,至少能区分 `connecting`、`retrying`、`connected`、`failed`、`stopped`。 +- 启动体验验证:构造 1 个慢 server + 1 个正常 server,确认慢 server 命中 soft timeout 后不会阻塞其他 server 的启动编排,且状态可见为 `timeout/retrying`。 +- 生命周期验证:确认 soft timeout 后 `ServerManager` 不会立刻删除该 client;后台最终连接成功时会转 `connected`,最终真实失败时才转 `failed` 并清理。 +- shutdown 专项验证:构造“连接阶段卡住”的 server,确认应用关闭时不会因为慢连接无限拖住;需要专门验证 shutdown 会处理 `connecting/retrying` client,而不只是已运行 client。 +- 并发验证:在 1、2、4+ server 组合下对比串行与受限并发,记录总启动耗时、子进程峰值/CPU/网络占用,作为 2–3 默认值选择依据。 +- 基础校验:本次为文档修订,不执行代码命令;实施验收时按仓库规范执行 `pnpm run format`、`pnpm run i18n`、`pnpm run lint`。 + +## 风险 +- 扩展状态事件会触及 main/renderer/event 消费链;若仅新增生产端、不改消费端,UI 仍可能停留在旧的 `running/stopped` 语义。 +- soft timeout 后保留 client 虽然能避免重复冷启动,但会引入“半连接中”状态管理复杂度;必须明确 manager 如何识别 active client、何时允许 stop/cancel。 +- 若 shutdown 仍只处理 running clients,后台 retry/慢连接会成为新的退出长尾来源,因此 F5 不能只改启动、不补 shutdown 交互说明。 +- 45s 与并发 2–3 目前都只是初始建议,不应在实施前宣称为最终最优值;需要以压测或样本日志校准。 +- 保留 5 分钟 hard timeout 是为了兼容现有最慢路径与真实失败判定;若未来掌握更细的 transport/registry 分布,可再按 transport 类型分层调参。 \ No newline at end of file diff --git a/docs/architecture/perf-fixes-from-audit/fix-F06-protocol-handler-async.md b/docs/architecture/perf-fixes-from-audit/fix-F06-protocol-handler-async.md new file mode 100644 index 000000000..9970cd5fb --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F06-protocol-handler-async.md @@ -0,0 +1,236 @@ +# 修复 F6:protocol handler 改为 async + 明确 streaming fallback + +## 目标 + +将 [`protocolRegistrationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L75-L223) 中 `deepcdn`、`imgcache`、`workspace-preview` 三个 protocol handler 从同步文件 IO 改为异步实现,消除主进程在资源读取路径上的 `existsSync` / `statSync` / `readFileSync` 阻塞。 + +本修复的首选方案是: + +- `deepcdn`:`async` handler + `fs.promises.readFile` +- `imgcache`:`async` handler + streaming `Response` +- `workspace-preview`:`async` handler + streaming `Response` + 50 MB 预览上限 + +同时明确确定性的兼容回退:若 Electron 40.10.5 的某个运行时边界导致 `Response` 不接受 `Readable.toWeb(fs.createReadStream(...))` 作为 body,则保持 handler 仍为 `async`,仅把 body 构造回退为 `await fs.promises.readFile(...)`。即:**优先 streaming,失败时 fallback 到 async readFile,不回退到任何同步 IO。** + +## 定位 + +### 2.1 同步阻塞点 + +当前实现位于 [`protocolRegistrationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L75-L223): + +- `deepcdn`:`fs.existsSync` 在 [protocolRegistrationHook.ts#L87](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L87),`fs.existsSync` 在 [protocolRegistrationHook.ts#L108](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L108),`fs.readFileSync` 在 [protocolRegistrationHook.ts#L117](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L117) +- `imgcache`:`fs.existsSync` 在 [protocolRegistrationHook.ts#L139](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L139),`fs.readFileSync` 在 [protocolRegistrationHook.ts#L170](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L170) +- `workspace-preview`:`fs.existsSync` / `fs.statSync` 在 [protocolRegistrationHook.ts#L194](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L194),`fs.readFileSync` 在 [protocolRegistrationHook.ts#L204](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L204) + +这些调用都位于 `protocol.handle(...)` 回调内部,请求命中时直接运行在主进程事件循环上。大量图片、预览文件或慢盘访问会放大卡顿风险。 + +### 2.2 MIME 现状必须完整保留 + +当前 `workspace-preview` 走本文件内的 `getMimeTypeForPath`([protocolRegistrationHook.ts#L18-L64](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L18-L64)),覆盖: + +- 文本/脚本:`.html` `.htm` `.xhtml` `.css` `.js` `.mjs` +- 数据:`.json` `.map` `.pdf` +- 图片:`.svg` `.png` `.gif` `.webp` `.jpg` `.jpeg` `.bmp` `.ico` `.avif` +- 字体:`.woff` `.woff2` `.ttf` `.otf` +- 默认:`application/octet-stream` + +当前 `deepcdn` 还额外显式覆盖了 `.wasm -> application/wasm`([protocolRegistrationHook.ts#L99-L100](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L99-L100))以及 `.data -> application/octet-stream`([protocolRegistrationHook.ts#L101-L102](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L101-L102))。修复后不能因为抽取/复用 MIME 逻辑而丢失 `.wasm`,否则会回归内置资源加载。 + +### 2.3 Electron 40.10.5 的兼容事实 + +仓库 `electron` 版本固定为 [`package.json#L185`](../../../package.json#L185) 的 `40.10.5`。项目内已经存在 Node stream 转 Web stream 的运行时用法:[`acpProcessManager.ts#L1407-L1408`](../../../src/main/presenter/llmProviderPresenter/acp/acpProcessManager.ts#L1407-L1408) 使用 `Writable.toWeb(...)` 和 `Readable.toWeb(...)`。这说明当前运行时至少已在项目内接受 Node/Web stream 互转模式。 + +因此文档结论应明确为:**Electron 40.10.5 上,`protocol.handle` 返回 `Promise` 是可行设计;body 首选 `Readable.toWeb(fs.createReadStream(...))`。** 但为避免类型层或运行时边界差异,需同时落地确定性 fallback:同一 async handler 内,捕获 stream body 构造/返回失败后改用 `await fs.promises.readFile(...)` 返回 `Response`。 + +### 2.4 workspace-preview 的 realpathSync 不属于本次热路径问题 + +[`workspacePreviewProtocol.ts`](../../../src/main/presenter/workspacePresenter/workspacePreviewProtocol.ts#L47-L52) 的 `normalizePathForAccess()` 仍使用 `fs.realpathSync`: + +- [workspacePreviewProtocol.ts#L47-L52](../../../src/main/presenter/workspacePresenter/workspacePreviewProtocol.ts#L47-L52) + +需要在文档中区分:这里属于路径规范化与访问控制辅助逻辑,发生在 `register*` / URL 解析流程中;它不是本次 F6 关注的「handler 内同步读取文件内容」问题,不应误判为同一类热点阻塞。若后续要继续优化,可单独立项评估,但不混入本修复。 + +## 修复方案 + +### 3.1 总体策略 + +1. 三个 handler 全部改成 `async`。 +2. 用一次 `await fs.promises.stat(...)` 或 `await fs.promises.readFile(...)` 替代 `existsSync + statSync/readFileSync` 的同步双重 IO。 +3. `deepcdn` 保持 async 非 streaming 即可;`imgcache` 与 `workspace-preview` 以 streaming `Response` 为首选。 +4. 为 streaming body 提供确定 fallback: + - 首选:`Readable.toWeb(fs.createReadStream(fullPath))` + - fallback:`await fs.promises.readFile(fullPath)` +5. 所有 fallback 仍保持 async,不允许回退到同步 API。 + +### 3.2 deepcdn + +`deepcdn` 服务内置资源,路径有界,文件通常较小。本项收益重点不是削减内存峰值,而是去掉主进程同步阻塞。 + +建议: + +- 保留 `Response(Buffer)` 形态,但改为 `async` + `await fs.promises.readFile(fullPath)` +- 用 `await fs.promises.access(...)` 或直接 `readFile` 捕获 `ENOENT` 替代 `existsSync` +- MIME 逻辑保持当前显式覆盖,尤其保留 `.wasm -> application/wasm` + +### 3.3 imgcache + +`imgcache` 面向用户图片缓存,文件大小与并发都不可控,适合优先使用 streaming。 + +建议: + +- 先 `await fs.promises.stat(fullPath)` 获取存在性、目录判定、`Content-Length` +- 非目录后优先 `Readable.toWeb(fs.createReadStream(fullPath))` +- 若 stream body 不被接受,则 fallback 到 `await fs.promises.readFile(fullPath)` +- MIME 映射继续覆盖 `.png` `.gif` `.webp` `.svg` `.jpg` `.jpeg` `.bmp` `.ico` `.avif` + +### 3.4 workspace-preview + +`workspace-preview` 风险最高,应同时处理同步阻塞和超大文件预览问题。 + +建议: + +- `resolveWorkspacePreviewRequest(request.url)` 保持原状,继续先做 403 路径校验 +- 用一次 `await fs.promises.stat(fullPath)` 完成存在性 + 目录校验 +- 引入 `MAX_WORKSPACE_PREVIEW_BYTES = 50 * 1024 * 1024` +- `stat.size > 50 MB` 时直接返回 `413 Payload Too Large` +- 正常文件优先 streaming `Response`,失败时 fallback 到 async `readFile` +- 保持 `Cache-Control: no-store`、`X-Content-Type-Options: nosniff` +- 为 `getMimeTypeForPath(fullPath)` 增加按完整路径缓存的 `Map`,减少高频预览时重复扩展名判断 + +#### 为什么是 50 MB + +给出固定阈值,避免“按需求再定”的模糊结论: + +- workspace preview 的目标是文本、图片、文档等“可快速查看”内容,不是通用大文件传输 +- 50 MB 足以覆盖常见 markdown、代码、JSON、SVG、普通截图/图片与中等 PDF 预览 +- 超过 50 MB 时,即使改成 async/streaming,也会增加 renderer 处理时间、协议带宽占用和误点大文件的体验成本 +- 413 明确、可预期,且比默默读取超大文件更安全 + +因此本修复将 **50 MB** 作为默认产品阈值写入方案,而不是留空。 + +## 步骤拆分 + +### 4.1 streaming body helper(含确定 fallback) + +```ts +import fs, { promises as fsp } from 'fs' +import { Readable } from 'stream' + +async function createProtocolBody(fullPath: string): Promise { + try { + return Readable.toWeb(fs.createReadStream(fullPath)) as unknown as ReadableStream + } catch { + return await fsp.readFile(fullPath) + } +} +``` + +### 4.2 imgcache handler + +```ts +import fs, { promises as fsp } from 'fs' +import { Readable } from 'stream' + +protocol.handle('imgcache', async (request) => { + const filePath = request.url.slice('imgcache://'.length) + const fullPath = path.join(app.getPath('userData'), 'images', filePath) + + try { + const stat = await fsp.stat(fullPath) + if (stat.isDirectory()) { + return new Response('Image not found', { status: 404 }) + } + + const body = await createProtocolBody(fullPath) + return new Response(body, { + headers: { + 'Content-Type': getMimeTypeForPath(fullPath), + 'Content-Length': String(stat.size) + } + }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return new Response(`Image not found: ${filePath}`, { status: 404 }) + } + throw error + } +}) +``` + +### 4.3 workspace-preview handler + +```ts +import fs, { promises as fsp } from 'fs' +import { Readable } from 'stream' + +const MAX_WORKSPACE_PREVIEW_BYTES = 50 * 1024 * 1024 +const workspacePreviewMimeCache = new Map() + +function getWorkspacePreviewMimeType(fullPath: string): string { + const cached = workspacePreviewMimeCache.get(fullPath) + if (cached) { + return cached + } + const mimeType = getMimeTypeForPath(fullPath) + workspacePreviewMimeCache.set(fullPath, mimeType) + return mimeType +} + +protocol.handle(WORKSPACE_PREVIEW_PROTOCOL, async (request) => { + const fullPath = resolveWorkspacePreviewRequest(request.url) + if (!fullPath) { + return new Response('Forbidden', { status: 403 }) + } + + try { + const stat = await fsp.stat(fullPath) + if (stat.isDirectory()) { + return new Response(`File not found: ${fullPath}`, { status: 404 }) + } + if (stat.size > MAX_WORKSPACE_PREVIEW_BYTES) { + return new Response('Payload too large', { status: 413 }) + } + + const body = await createProtocolBody(fullPath) + return new Response(body, { + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': getWorkspacePreviewMimeType(fullPath), + 'Content-Length': String(stat.size), + 'X-Content-Type-Options': 'nosniff' + } + }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return new Response(`File not found: ${fullPath}`, { status: 404 }) + } + throw error + } +}) +``` + +### 4.4 deepcdn MIME 保留要求 + +若抽取统一 MIME helper,需要确保至少覆盖当前集合: + +- `getMimeTypeForPath` 已有:`.html` `.htm` `.xhtml` `.css` `.js` `.mjs` `.json` `.map` `.pdf` `.svg` `.png` `.gif` `.webp` `.jpg` `.jpeg` `.bmp` `.ico` `.avif` `.woff` `.woff2` `.ttf` `.otf` +- `deepcdn` 额外必须保留:`.wasm -> application/wasm`、`.data -> application/octet-stream` + +不能因为示意代码只展示常见图片 MIME,就让 deepcdn 的 `.wasm` 支持在正式实现中丢失。 + +## 验证 + +- 类型检查:确认 `async` handler、`BodyInit`、`Readable.toWeb(...)` 的类型声明可通过 `pnpm run typecheck` +- lint / format / i18n:按仓库要求执行 `pnpm run format`、`pnpm run i18n`、`pnpm run lint` +- 回归验证: + - `deepcdn` 访问 `.js` / `.css` / `.json` / `.wasm` + - `imgcache` 图片命中、404、目录路径 + - `workspace-preview` 的 403 / 404 / 413 / 正常返回 / MIME 头 / `nosniff` +- E2E:`29/30` workspace watcher 相关场景 + 图片/文件预览场景 + +## 风险 + +- `Readable.toWeb(fs.createReadStream(...))` 在 Electron 40.10.5 上预期可行,但仍要保留已定义好的 async `readFile` fallback,不能只在文档里“口头回退”。 +- `Content-Length` 必须与 `stat.size` 一致,否则 renderer 侧可能出现加载异常。 +- `workspace-preview` 的 50 MB 上限会改变超大文件预览行为;需要确保上层 UI 对 413 有合理提示或至少稳定失败。 +- [`workspacePreviewProtocol.ts#L47-L52`](../../../src/main/presenter/workspacePresenter/workspacePreviewProtocol.ts#L47-L52) 的 `realpathSync` 仍存在,但它不属于本次 handler 读取热路径;本修复不应把它当作同一个问题处理。 diff --git a/docs/architecture/perf-fixes-from-audit/fix-F08-shutdown-observability.md b/docs/architecture/perf-fixes-from-audit/fix-F08-shutdown-observability.md new file mode 100644 index 000000000..3bdac4be9 --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F08-shutdown-observability.md @@ -0,0 +1,66 @@ +# 修复 F8:关闭路径可观测性与停服超时兜底 + +## 目标 +- 在 `Presenter.destroy()` 增加逐步骤耗时日志,形成关闭长尾定位的最小可观测闭环。 +- 为 MCP 关闭链补充 per-server timeout,并明确超时后的清理/强杀策略。 +- 在不破坏现有状态管理语义的前提下,收敛 plugin-owned MCP server 的停服耗时。 + +## 定位 +- `requestShutdown()` 只是发起 `BEFORE_QUIT` 阶段并等待 `executeShutdownPhase()` 返回,本身不提供更细粒度的 destroy 观测。[`src/main/presenter/lifecyclePresenter/index.ts#L143`](../../../src/main/presenter/lifecyclePresenter/index.ts#L143) +- `executeHooksByPriority()` 当前按优先级分组、组内并行、组间串行执行 hook,但没有逐 hook duration 统计;它适合作为补充层,不是本次最小闭环的必做层。[`src/main/presenter/lifecyclePresenter/index.ts#L167`](../../../src/main/presenter/lifecyclePresenter/index.ts#L167) +- `before-quit` 拦截进入 `requestShutdown()` 后,真正重型关闭工作最终仍落在 `Presenter.destroy()` 链上,因此 destroy 层日志最关键。[`src/main/presenter/lifecyclePresenter/index.ts#L423`](../../../src/main/presenter/lifecyclePresenter/index.ts#L423) +- `Presenter.destroy()` 目前串行关闭 `pluginPresenter`、`mcpPresenter`、remote control、memory、sqlite、workspace、skill 等步骤,但缺少逐步骤耗时日志。[`src/main/presenter/index.ts#L954`](../../../src/main/presenter/index.ts#L954) +- `PluginHost.shutdown()` 目前串行遍历 plugin-owned server 并逐个 `await this.mcpPresenter.stopServer(serverName)`。[`src/main/presenter/pluginPresenter/index.ts#L132`](../../../src/main/presenter/pluginPresenter/index.ts#L132) +- `McpPresenter.shutdown()` 当前通过 `shutdownPromise` 防重复进入,但内部仍是逐个 server 串行 `await this.stopServer(...)`。[`src/main/presenter/mcpPresenter/index.ts#L227`](../../../src/main/presenter/mcpPresenter/index.ts#L227) +- `McpClient` 只在连接阶段提供超时;关闭阶段 `disconnect()` -> `cleanupResources()` -> `closeTransport()` 没有 timeout 包装。对于 `stdio` transport,会先执行 `terminateProcessTree(child, { graceMs: 2000 })`,随后仍会 `await transport.close()`;若卡在 `transport.close()`,上层仅靠 `Promise.race()` 无法取消底层任务。[`src/main/presenter/mcpPresenter/mcpClient.ts#L467`](../../../src/main/presenter/mcpPresenter/mcpClient.ts#L467) [`src/main/presenter/mcpPresenter/mcpClient.ts#L562`](../../../src/main/presenter/mcpPresenter/mcpClient.ts#L562) +- `serverManager.stopServer()` 只读取 `clients.get(name)`,调用该 client 的 `disconnect()`,然后删除对应 map 项并发事件;未见跨 server 共享的 stop 中间状态或全局串行锁。因此不同 server 的 stop 没有显式共享可变状态,具备改为并发执行的前提。[`src/main/presenter/mcpPresenter/serverManager.ts#L301`](../../../src/main/presenter/mcpPresenter/serverManager.ts#L301) + +### 根因 +- 关闭路径存在多层串行:hook 阶段完成后仍要进入 `Presenter.destroy()` 串行执行多个子系统关闭,缺少逐步骤耗时日志时,长尾来源不可见。 +- plugin-owned MCP server 和 MCP presenter 自身 shutdown 都按 server 串行 stop,server 数量增多时退出耗时线性增长。 +- 当前 proposed 的 `Promise.race(stopServer, timeout)` 只能让上层等待超时返回,不能取消底层 `stopServer()`;若底层卡在 `transport.close()`,后台停服任务仍可能继续悬挂。 +- 现有强制终止只覆盖 `stdio` 子进程,且发生在 `closeTransport()` 调用 `transport.close()` 之前;对非 `stdio` transport 没有 force-kill 能力,对卡在 `transport.close()` 的场景也没有额外取消机制。 + +## 修复方案 +### 4.1 destroy 层作为必做日志层 +- 必做:仅在 `Presenter.destroy()` 内为关键步骤增加 duration 日志,记录开始/结束耗时,形成最小闭环。 +- 可选:`executeHooksByPriority()` 额外补逐 hook duration 统计,但不作为本 F8 的必需项,避免方案范围扩大。 +- 建议覆盖步骤: + - `pluginPresenter.shutdown()` + - `mcpPresenter.shutdown()` + - `destroyRemoteControl()` + - `memoryPresenter.dispose()` + - `sqlitePresenter.close()` + - `workspacePresenter.destroy()` + - `skillPresenter.destroy()` + +### 4.2 plugin-owned stop 改为受限并发,不直接裸 `Promise.allSettled` +- 已核对 `serverManager.stopServer()`:stop 逻辑按 server 独立处理 `clients` map 项,没有共享全局 stop 状态,因此可安全并发不同 server 的停服调用。[`src/main/presenter/mcpPresenter/serverManager.ts#L301`](../../../src/main/presenter/mcpPresenter/serverManager.ts#L301) +- 仍建议采用受限并发,而不是无限制 `Promise.allSettled()`: + - 保持对资源争用更温和; + - 避免大量 plugin-owned server 同时 stop 时集中触发 transport close / 进程终止。 +- 建议上限:固定并发 4(常量即可,无需新增配置)。 +- 保持失败继续:单个 stop 失败仅记录 warning,不阻断其他 server,也不阻断后续 `unregisterPluginToolPolicies(pluginId)`。 + +### 4.3 MCP shutdown 增加 per-server timeout,并明确超时后处置 +- `McpPresenter.shutdown()` 对每个 `stopServer(serverName)` 包装超时,例如 10s。 +- 超时后的策略必须写死并落实: + - **stdio transport**:在超时分支补一次基于 server/client 上下文的强制终止兜底,调用现有 `terminateProcessTree(..., { graceMs: 2000 })` 路径,确保子进程不会因上层超时返回而长期残留。 + - **非 stdio transport**:无统一 force-kill 能力;只能记录 warning,继续关闭流程,并在日志中明确“后台 stop 可能仍未完成”。 +- 这意味着 `Promise.race()` 只负责限制 shutdown 主链等待时间;真正的资源兜底仍要在超时分支显式执行,不能误以为 race 本身具备取消能力。 +- 风险声明必须保留:即便 stdio 有补强杀,若底层已卡入不可中断的 `transport.close()` 或第三方 transport 内部 await,非 stdio 仍存在后台残留 stop 的可能。 + +## 验证 +- 代码检查:确认 `Presenter.destroy()` 日志覆盖所有关键步骤,且失败分支也会输出耗时。 +- 关闭链检查:配置多个普通 MCP server 与 plugin-owned MCP server,验证 plugin-owned stop 为受限并发、MCP shutdown 为 per-server timeout 后仍能完成退出。 +- 超时场景验证:构造卡住的 server stop,确认: + - stdio server 在超时后不会留下 zombie / 后台子进程; + - 非 stdio server 会记录 warning,并允许主关闭流程继续。 +- 日志验证:确认最少能看到 destroy 层步骤耗时,以及单个 server stop timeout / force-kill / warning 日志。 +- 基础校验:按仓库约定执行 `pnpm run format`、`pnpm run i18n`、`pnpm run lint`。 + +## 风险 +- `Promise.race()` 不可取消底层 `stopServer()`;若没有超时后的额外处置,后台停服任务仍可能继续运行,这是本问题的核心残留风险。 +- stdio 只能通过已有子进程终止能力兜底,不能保证所有 transport 实现都能被同样强制回收。 +- 非 stdio transport 超时后只能 warning + continue,存在资源延迟释放或后台任务未完成的已知风险,需要依赖日志辅助排查。 +- 受限并发会改变 stop 的时序,但基于 `serverManager.stopServer()` 当前按 server 独立处理的实现,不应改变最终语义。 diff --git a/docs/architecture/perf-fixes-from-audit/fix-F09-markdown-workers-lazy.md b/docs/architecture/perf-fixes-from-audit/fix-F09-markdown-workers-lazy.md new file mode 100644 index 000000000..11d831d46 --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F09-markdown-workers-lazy.md @@ -0,0 +1,46 @@ +# 修复 F9:Markdown workers 真正按需初始化 + +## 目标 +将 `ensureMarkdownWorkers()` 从 renderer 启动阶段的无条件调用,改为仅在实际进入 `markstream-vue` 渲染路径时触发,从而避免无 Markdown / KaTeX / Mermaid / 代码块内容时也提前拉起 worker 初始化。 + +## 定位 +- 当前 eager 初始化发生在 [src/renderer/src/main.ts](../../../src/renderer/src/main.ts#L11-L20):renderer 启动时直接调用 `ensureMarkdownWorkers()`。 +- worker 生命周期实现位于 [src/renderer/src/lib/markdownWorkerLifecycle.ts](../../../src/renderer/src/lib/markdownWorkerLifecycle.ts#L73-L150):`loadWorkerConstructors()` 通过 `import('markstream-vue/workers/... ?worker&inline')` 动态加载构造器,`ensureMarkdownWorkers()` 负责幂等初始化与注册。 +- `ensureMarkdownWorkers` 目前仅被两个源码位置引用:定义本身在 [src/renderer/src/lib/markdownWorkerLifecycle.ts](../../../src/renderer/src/lib/markdownWorkerLifecycle.ts#L112-L150),调用点仅在 [src/renderer/src/main.ts](../../../src/renderer/src/main.ts#L18-L20)。 +- 不能把 [src/renderer/src/components/markdown/MarkdownRenderer.vue](../../../src/renderer/src/components/markdown/MarkdownRenderer.vue#L1-L220) 视为唯一 Markdown 入口:除该组件外,[src/renderer/src/components/think-content/ThinkContent.vue](../../../src/renderer/src/components/think-content/ThinkContent.vue#L30-L39) 也直接挂载 `NodeRenderer`,并且其 [code_block / mermaid 自定义组件](../../../src/renderer/src/components/think-content/ThinkContent.vue#L77-L103) 同样可能触发代码块或 Mermaid 渲染路径。 + +### 证据 +- [src/renderer/src/main.ts](../../../src/renderer/src/main.ts#L14-L20) 注释写明 worker 是单一 owner、未就绪时可退化显示,但当前实现仍在应用挂载前执行初始化。 +- [src/renderer/src/lib/markdownWorkerLifecycle.ts](../../../src/renderer/src/lib/markdownWorkerLifecycle.ts#L73-L89) 明确使用 Vite `?worker&inline` 语法动态导入 worker 构造器,说明删除 eager 调用后,worker 仍会在 `ensureMarkdownWorkers()` 首次被调用时加载,但产物形态是 inline worker,而不是可直接表述为“独立 chunk”。 +- [src/renderer/src/lib/markdownWorkerLifecycle.ts](../../../src/renderer/src/lib/markdownWorkerLifecycle.ts#L112-L150) 已提供 `initialized` 与 `globalScope.__markdownWorkers` 双重守卫,多次调用安全。 +- [src/renderer/src/components/markdown/MarkdownRenderer.vue](../../../src/renderer/src/components/markdown/MarkdownRenderer.vue#L5-L16) 直接渲染 `NodeRenderer`;其 [自定义 `mermaid` 与 `code_block` 分支](../../../src/renderer/src/components/markdown/MarkdownRenderer.vue#L157-L220) 会走 `MermaidBlockNode` / `CodeBlockNode`。 +- [src/renderer/src/components/think-content/ThinkContent.vue](../../../src/renderer/src/components/think-content/ThinkContent.vue#L30-L39) 同样直接渲染 `NodeRenderer`;其 [自定义 `code_block` 与 `mermaid` 分支](../../../src/renderer/src/components/think-content/ThinkContent.vue#L77-L103) 也覆盖 Mermaid / 代码块场景。 +- `grep ensureMarkdownWorkers` 仅命中 2 个文件:调用点只有 [src/renderer/src/main.ts](../../../src/renderer/src/main.ts#L18-L20),不存在其他现成惰性触发入口。 + +## 修复方案 +1. 从 [src/renderer/src/main.ts](../../../src/renderer/src/main.ts#L18-L20) 删除启动阶段的 eager `ensureMarkdownWorkers()` 调用,避免 renderer 冷启动即初始化 Markdown workers。 +2. 重新定义“Markdown 入口”范围:不只覆盖 `MarkdownRenderer.vue`,还要枚举并覆盖所有直接使用 `markstream-vue` `NodeRenderer` 的入口,当前至少包括 [src/renderer/src/components/markdown/MarkdownRenderer.vue](../../../src/renderer/src/components/markdown/MarkdownRenderer.vue#L5-L16) 与 [src/renderer/src/components/think-content/ThinkContent.vue](../../../src/renderer/src/components/think-content/ThinkContent.vue#L30-L39)。 +3. 在这些直接入口组件内触发 `ensureMarkdownWorkers()`,利用其幂等性允许多入口安全并发调用,而不是把检测逻辑散落到更高层页面。 +4. 风险上明确接受一个过渡态:首次渲染到 worker 就绪之间,可能出现纯文本或基础渲染的短暂闪烁;该体验需要作为惰性初始化的显式权衡来验证,而不是默认假设完全无感。 +5. 不再使用“worker 仍以独立 chunk 存在”的绝对表述;这里只能确认 `?worker&inline` 动态导入路径仍可在首次调用 `ensureMarkdownWorkers()` 时拉起对应 worker。 +6. 对 fallback 行为保持审慎:当前仓库中只在注释层面看到“未就绪时退化显示”的说法,尚未在本仓代码里看到显式 fallback 实现,因此需要额外实证 `markstream-vue` 的真实行为与类型约束。 + +## 步骤拆分 +1. 删除 [src/renderer/src/main.ts](../../../src/renderer/src/main.ts#L18-L20) 的 eager 初始化调用。 +2. 枚举所有直接 `NodeRenderer` / `markstream-vue` 渲染入口,至少覆盖 [src/renderer/src/components/markdown/MarkdownRenderer.vue](../../../src/renderer/src/components/markdown/MarkdownRenderer.vue#L5-L16) 和 [src/renderer/src/components/think-content/ThinkContent.vue](../../../src/renderer/src/components/think-content/ThinkContent.vue#L30-L39)。 +3. 在上述入口组件内接入惰性 `ensureMarkdownWorkers()` 调用,并保持多次 mount / 多入口并发场景下的幂等语义。 +4. 补充或调整测试,验证 worker 不再在 renderer 启动时初始化,而是在这些真实渲染入口首次挂载时触发。 +5. 构建与手动验证时,额外核实 `?worker&inline` 的产物形态以及首次渲染前后 UI 表现。 + +## 验证 +- 静态引用验证:`grep ensureMarkdownWorkers` 应继续只出现于生命周期模块定义和明确的新惰性入口调用位置;当前基线为仅 2 个文件(定义 + `main.ts` 调用)。 +- 组件路径验证:检查所有直接 `NodeRenderer` 入口是否都已覆盖,尤其是 [src/renderer/src/components/think-content/ThinkContent.vue](../../../src/renderer/src/components/think-content/ThinkContent.vue#L30-L39) 这类非 `MarkdownRenderer.vue` 路径。 +- 产物验证:`pnpm run build` 后核实 `markstream-vue/workers/*?worker&inline` 的实际构建产物形态,确认删除 eager 调用后仍能通过动态 import 拉起 worker,但不要把结果描述成“独立 chunk 仍存在”除非产物实测支持。 +- fallback 实证:通过检查 `markstream-vue` 用法、类型或运行表现,确认 worker 未就绪时到底是纯文本、基础渲染,还是其他退化行为;不能只依赖本仓注释。 +- 体验验证:手动或自动测试首次打开含公式 / Mermaid / 代码块内容时,确认渲染成功,并观察首次 worker 就绪前是否存在纯文本/基础渲染闪烁。 + +## 风险 +- 首次命中 Markdown worker 能力时需要异步加载与注册,可能带来首次公式、Mermaid 或复杂代码块渲染延迟。 +- 从首次渲染到 worker 就绪之间,可能出现纯文本或基础渲染的短暂闪烁;如果闪烁明显,可能需要额外占位或延迟策略。 +- 若只覆盖 `MarkdownRenderer.vue` 而遗漏 [src/renderer/src/components/think-content/ThinkContent.vue](../../../src/renderer/src/components/think-content/ThinkContent.vue#L30-L39) 这类直接 `NodeRenderer` 路径,仍会存在未初始化 worker 的功能缺口。 +- `markstream-vue` 的实际 fallback 语义目前在本仓缺少显式实现证据,若上游行为与注释不一致,惰性初始化方案可能引入未预期的渲染退化。 \ No newline at end of file diff --git a/docs/architecture/perf-fixes-from-audit/fix-F11-icons-bundle-slim.md b/docs/architecture/perf-fixes-from-audit/fix-F11-icons-bundle-slim.md new file mode 100644 index 000000000..9574fa95f --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F11-icons-bundle-slim.md @@ -0,0 +1,203 @@ +# 修复 F11:Iconify 白名单 + provider 图标按需化 + +## 目标 + +收紧 renderer 图标相关包体与首屏资源装载路径,但只承诺**方向性收益**,不预设具体 MB 降幅: + +- `iconLoader.ts` 不再整包注册 `lucide` / `vscode-icons` / `line-md` collection,而是仅注册构建脚本产出的白名单子集。 +- `ModelIcon.vue` 不再静态打包大批 provider 图标,改为由脚本生成 manifest,并按 providerId 按需解析/加载。 +- `tokenflux-color.svg` 作为异常大图标资源单独治理,满足明确的体积阈值与回归验收标准。 +- 为避免静态白名单漏掉运行时动态分支,补一层**运行时未命中采样/告警 -> 白名单回灌**机制。 + +## 定位 + +### 2.1 当前代码路径 + +- [`src/renderer/src/lib/iconLoader.ts#L41-L57`](../../../src/renderer/src/lib/iconLoader.ts#L41-L57) 动态导入后仍整包 `addCollection(lucideIcons)`、`addCollection(vscodeIcons)`、`addCollection(lineMdThemeIcons)`。 +- [`src/renderer/src/main.ts#L46-L51`](../../../src/renderer/src/main.ts#L46-L51) 与 [`src/renderer/settings/main.ts#L104-L109`](../../../src/renderer/settings/main.ts#L104-L109) 都会在 app mount 后 `setTimeout(..., 0)` 预热图标。 +- [`src/renderer/src/components/icons/ModelIcon.vue#L6-L82`](../../../src/renderer/src/components/icons/ModelIcon.vue#L6-L82) 静态导入约 77 个 provider 图标。 +- [`src/renderer/src/components/icons/ModelIcon.vue#L85-L184`](../../../src/renderer/src/components/icons/ModelIcon.vue#L85-L184) 用常驻 `icons` map 维护 provider 映射。 +- [`src/renderer/src/components/icons/ModelIcon.vue#L206-L227`](../../../src/renderer/src/components/icons/ModelIcon.vue#L206-L227) 通过 `Object.keys(icons).find(...includes...)` 线性扫描匹配 provider icon,其中包含 `tokenflux` 键 [`src/renderer/src/components/icons/ModelIcon.vue#L156`](../../../src/renderer/src/components/icons/ModelIcon.vue#L156)。 + +### 2.2 Iconify 包导出事实 + +`@iconify-json/lucide` 与 `@iconify-json/vscode-icons` 的 `package.json` 仅明确导出整包 JSON 与 info: + +- [`node_modules/@iconify-json/lucide/package.json#L11-L20`](../../../node_modules/@iconify-json/lucide/package.json#L11-L20) +- [`node_modules/@iconify-json/vscode-icons/package.json#L12-L21`](../../../node_modules/@iconify-json/vscode-icons/package.json#L12-L21) + +两者都声明了: + +- `./icons.json` +- `./info.json` +- `./*` + +但没有官方文档化的“单 icon 稳定子路径 API”。因此,**过滤完整 `icons.json` 后再 `addCollection`** 是当前最稳妥、最可审计的主方案;单 icon 子路径导入不能作为主设计前提。 + +### 2.3 252 个 icon 只是静态基线,不代表运行时全覆盖 + +现有统计得到 **252 个源码静态字面量 icon**: + +- `lucide` 235 +- `vscode-icons` 16 +- `line-md` 3 + +这个数字只能说明“源码里直接写死的字面量基线”,**不能证明覆盖所有运行时路径**,尤其是: + +- 字符串拼接的 icon 名称 +- 配置/远端数据驱动的 icon 名称 +- fallback 分支与未来新增映射 + +因此文档中的 252 只能作为**脚本生成白名单的初始输入基线**,不能直接当作最终可信全集。后续必须补: + +1. 构建时白名单生成与静态校验; +2. 运行时未命中 icon 的采样/告警; +3. 将未命中结果回灌到白名单生成清单或 provider manifest。 + +### 2.4 tokenflux 资源事实 + +`tokenflux-color.svg` 当前文件大小为 **1,627,765 B**,且内容特征为: + +- 包含 ` import('...svg?url')` loader,而不是手写大量动态导入。 + +### 3.3 动态漏项:运行时未命中采样/告警 + +由于 252 只覆盖静态基线,必须补运行时保护: + +- 在 icon 解析入口记录“请求的 icon 名 / provider key 是否命中白名单或 manifest”。 +- 对未命中情况做去重采样并输出开发期告警;生产构建可选择上报到本地诊断日志而非刷屏 console。 +- 提供一个汇总脚本/测试,把运行时采样结果转成待补条目,回灌到生成脚本输入。 + +最低验收要求: + +- 开发/CI 环境能发现未注册 icon 请求; +- 不因漏项直接导致白屏,只允许回退 default/fallback; +- 运行一轮关键页面后,未命中列表应为空或只剩已确认可忽略项。 + +### 3.4 tokenflux:先查构成,再定压缩/替换路径 + +对于 [`src/renderer/src/components/icons/ModelIcon.vue#L51`](../../../src/renderer/src/components/icons/ModelIcon.vue#L51) 引入的 `tokenflux-color.svg`: + +1. 先拆解它为何达到 1,627,765 B:当前证据已显示其包含 base64 内嵌图像。 +2. 首选方案:重导出/替换为真正轻量的 SVG 或尺寸受控的 PNG/WebP。 +3. 若仍保留为大资源,则只能按需 lazy import,避免首屏与主 chunk 常驻。 + +**验收阈值**: + +- 优先目标:压缩/替换后图标文件 **< 200 KB**; +- 次级容忍:若品牌资源无法低于 200 KB,必须证明其已脱离主 renderer 初始 chunk,并只在命中 provider 时请求; +- 视觉验收:浅色/深色主题、provider 列表、消息头像场景均无明显失真。 + +## 步骤拆分 + +1. **生成脚本落地** + - 增加 icon 白名单生成脚本。 + - 增加 provider icon manifest 生成/校验脚本。 + - 把两类生成物接入 CI 校验,避免手工双维护。 +2. **改造 `iconLoader.ts`** + - 从整包 `icons.json` 过滤出白名单子集后再注册。 + - 保留现有 preload 行为,但确保只注册裁剪后的集合。 +3. **改造 `ModelIcon.vue`** + - 用生成 manifest 替代静态 import 大表。 + - icon 匹配改为精确匹配/预计算别名表优先,避免每次 render 做线性 `includes` 扫描。 + - 对 lazy loader 做 promise/result 缓存。 +4. **补运行时漏项校验** + - 开发与 CI 环境收集未命中 icon 请求。 + - 形成回灌机制,更新白名单与 provider manifest。 +5. **处理 tokenflux** + - 量化优化前后体积。 + - 若无法压到阈值内,则单独异步化并验证缓存/首帧行为。 + +## 验证 + +### 5.1 构建体积 + +只承诺“**应显著下降**”,不承诺具体 MB 数值。验证方法: + +1. 记录修复前 `pnpm run build` 后 `out/renderer/assets` 中 icon 相关 chunk 与 `tokenflux` 资源大小。 +2. 完成修复后再次构建,对比: + - icon 相关 chunk 是否明显缩小 + - provider 图标是否从主 chunk 剥离 + - `tokenflux` 是否满足阈值或完成异步化 + +### 5.2 运行时正确性 + +- 复跑 `01-launch`、`04-settings-navigation`、`18-provider-readonly-route`。 +- 手动检查主题切换图标、文件类型图标、provider 列表、聊天消息头像。 +- 检查未命中 icon 告警汇总应为空或仅剩已登记例外。 + +### 5.3 静态/CI 校验 + +- 白名单生成脚本输出稳定,无手工编辑漂移。 +- provider manifest 生成脚本可校验资产存在、映射完整。 +- `pnpm run typecheck` +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` + +## 风险 + +### 6.1 主要风险 + +- **静态白名单漏项**:252 只覆盖字面量,若无运行时补漏,动态 icon 会丢失。 +- **双维护回归**:若不把 provider manifest 也纳入生成/校验,后续仍会重复出现“资源新增了、映射没改”或反之。 +- **lazy import 闪烁**:provider icon 改异步后,列表首帧可能先显示 default icon,再切换真实 icon。 +- **并发重复请求**:同一 provider 图标若没有 promise 缓存,列表并发渲染时可能触发多次相同加载。 +- **tokenflux 品牌回归**:替换格式或重导出可能造成视觉偏差。 + +### 6.2 最低验收标准 + +- 不再整包注册 `lucide` / `vscode-icons` / `line-md`。 +- 白名单与 provider manifest 均由脚本生成,并有 CI 校验入口。 +- 存在运行时未命中 icon 的告警/汇总机制。 +- 构建后 icon 相关 chunk 体积相较基线显著下降,但不在文档中预承诺具体数值。 +- `tokenflux` 资源压缩到 **< 200 KB**,或已证明异步化且不会拖入初始主包。 +- provider icon lazy load 具备缓存,避免闪烁与并发重复加载问题扩大。 diff --git a/docs/architecture/perf-fixes-from-audit/fix-F12-session-fetch-dedup.md b/docs/architecture/perf-fixes-from-audit/fix-F12-session-fetch-dedup.md new file mode 100644 index 000000000..a8e1def2b --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F12-session-fetch-dedup.md @@ -0,0 +1,91 @@ +# 修复 F12:sessionStore.fetchSessions 首屏请求去重 + +## 目标 +在不改变现有启动调用点的前提下,消除 `App.vue` 与 `ChatTabView.vue` 对 `sessionStore.fetchSessions()` 的并发首屏请求竞态,避免重复触发 IPC `listLightweight`;同时明确去重守卫只覆盖首屏 `fetchSessions()`,不包裹 `loadSessionPage()`,以免误伤 `reset:false` 的分页加载路径。 + +## 定位 +- 启动主调用:[`src/renderer/src/App.vue#L525-L530`](../../../src/renderer/src/App.vue#L525-L530) + - `onMounted` 中直接调用 `void sessionStore.fetchSessions()`。 +- 启动关键 store 初始化:[`src/renderer/src/lib/storeInitializer.ts#L22-L25`](../../../src/renderer/src/lib/storeInitializer.ts#L22-L25) + - `initAppStores()` 只并行初始化 `uiSettingsStore` 与 `providerStore`,不负责 session 首屏加载,因此不会替代 App 层调用。 +- 兜底调用:[`src/renderer/src/views/ChatTabView.vue#L112-L116`](../../../src/renderer/src/views/ChatTabView.vue#L112-L116) + - `finally` 中如果 `!sessionStore.hasLoadedInitialPage`,会再次 `void sessionStore.fetchSessions()`。 +- session store 现状:[`src/renderer/src/stores/ui/session.ts#L276-L288`](../../../src/renderer/src/stores/ui/session.ts#L276-L288)、[`src/renderer/src/stores/ui/session.ts#L508-L549`](../../../src/renderer/src/stores/ui/session.ts#L508-L549)、[`src/renderer/src/stores/ui/session.ts#L587-L592`](../../../src/renderer/src/stores/ui/session.ts#L587-L592) + - `initialPageRequestId` 仅用于丢弃过期 `reset:true` 结果。 + - `loading` / `hasLoadedInitialPage` 仅表示状态,不阻止新的首屏请求进入。 + - `fetchSessions()` 只是薄包装,直接调用 `loadSessionPage({ reset: true, ... })`,自身没有 in-flight 守卫。 +- IPC 触发点:[`src/renderer/src/stores/ui/session.ts#L520-L527`](../../../src/renderer/src/stores/ui/session.ts#L520-L527) + - 真正的重复成本发生在 `sessionClient.listLightweight(...)` 被并发执行。 + +结论:当前问题是 **`fetchSessions()` 缺少首屏 in-flight 去重**,而不是 `loadSessionPage()` 的通用并发模型有问题。`loadSessionPage(reset:false)` 还承担分页职责,不能被一个全局守卫一并串行化。 + +## 修复方案 +1. **守卫仅加在 `fetchSessions()` 顶层** + - 增加模块内 `sessionFetchPromise: Promise | null`。 + - 当 `fetchSessions()` 发现已有 in-flight promise 时,直接返回同一个 promise。 + - 只把 `loadSessionPage({ reset: true, ... })` 包进该守卫,避免影响 `loadNextPage()` → `loadSessionPage({ reset:false })` 的翻页行为。 +2. **`finally` 采用 `currentFetchPromise` 比对后清理** + - 不能用“只要 `sessionFetchPromise` 非空就置空”的写法;那样在极端竞态下,旧请求 `finally` 可能误清新请求。 + - 应保存当前 promise 局部引用,并在 `finally` 中仅当 `sessionFetchPromise === currentFetchPromise` 时再清空。 +3. **保留现有双调用,根治放在 store 层** + - `App.vue` 继续作为主启动方发起首屏加载。 + - `ChatTabView.vue` 继续保留 `!hasLoadedInitialPage` 的兜底调用,覆盖主路径未来回归或初始化时序变化。 + - 两个调用点都走 `fetchSessions()`,由 store 统一去重。 +4. **显式刷新语义边界要在文档中写清** + - 如果已有首屏 `fetchSessions()` 在飞,后续再次调用 `fetchSessions()`,即便调用方主观上希望“强制 refresh”,也会被复用同一个 in-flight promise。 + - 推荐把这种去重语义限定为“首屏启动去重”:显式 refresh 若需要真正重新拉取,应走独立路径,直接调用 `loadSessionPage({ reset: true })`(或未来抽出专门 refresh API),而不是复用 `fetchSessions()`。 + +建议代码片段: + +```ts +let sessionFetchPromise: Promise | null = null + +async function fetchSessions(): Promise { + if (sessionFetchPromise) { + return sessionFetchPromise + } + + const currentFetchPromise = (async () => { + await loadSessionPage({ + reset: true, + prioritizeSessionId: activeSessionId.value ?? bootstrapActiveSession.value?.id ?? null + }) + })() + + sessionFetchPromise = currentFetchPromise + + try { + await currentFetchPromise + } finally { + if (sessionFetchPromise === currentFetchPromise) { + sessionFetchPromise = null + } + } +} +``` + +## 步骤拆分 +1. 在 [`src/renderer/src/stores/ui/session.ts`](../../../src/renderer/src/stores/ui/session.ts) 中新增 `sessionFetchPromise` 状态。 +2. 仅改造 `fetchSessions()` 为首屏 in-flight 去重包装器,保持 `loadSessionPage()` 的 `reset:true`/`reset:false` 分支结构不变。 +3. 不调整 [`src/renderer/src/App.vue`](../../../src/renderer/src/App.vue) 与 [`src/renderer/src/views/ChatTabView.vue`](../../../src/renderer/src/views/ChatTabView.vue) 的现有调用点,继续采用“双调用 + store 去重”的启动策略。 +4. 若后续实现显式“刷新会话列表”入口,要求该入口不要复用 `fetchSessions()` 语义,而应显式走 `loadSessionPage({ reset: true })` 或独立 refresh API。 + +## 验证 +- **单元测试为主,spy/mock 为准** + - 在 `test/renderer` 的 session store 测试中 mock `sessionClient.listLightweight`。 + - 并发触发两次 `fetchSessions()`,断言: + 1. `listLightweight` 只被调用一次; + 2. 两次调用拿到同一个完成结果; + 3. promise 完成后再次调用 `fetchSessions()` 会触发第二次真实请求。 +- **边界测试** + - 断言 `loadNextPage()` / `loadSessionPage({ reset:false })` 不受 `sessionFetchPromise` 影响,分页路径仍可独立执行。 + - 如实现显式 refresh API,补测它不会被首屏去重吞掉。 +- **回归验证** + - 可补充启动链路用例确认 `App.vue` 与 `ChatTabView.vue` 双调用共存时,首屏仍能正常加载。 + - console/trace 可作为人工辅助,但**不应**作为“只请求一次”的主验证依据,因为当前代码没有稳定的 `listLightweight` 次数日志,spy/mock 更可靠。 + +## 风险 +- **显式刷新被去重吞掉的语义边界**:如果未来有人把 `fetchSessions()` 当成“总是强制刷新”,该语义会与首屏去重冲突。本文推荐将 `fetchSessions()` 明确定义为“首屏加载入口”,显式 refresh 走独立 API。 +- **promise 生命周期管理**:若未采用 `currentFetchPromise` 比对清理,而是简单地在 `finally` 中清空共享变量,理论上可能误清后续新请求。 +- **`hasLoadedInitialPage` 不是请求锁**:它仍然只是结果态标记;在请求尚未完成前,`ChatTabView` 的兜底分支仍可能进入 `fetchSessions()`,因此真正的去重必须由 promise 守卫承担。 +- **代码行号可能随主线漂移**:本文引用基于当前核对版本,后续若上游再改动需重新对齐行号,但不影响方案本身。 \ No newline at end of file diff --git a/docs/architecture/perf-fixes-from-audit/fix-F13-message-store-sort.md b/docs/architecture/perf-fixes-from-audit/fix-F13-message-store-sort.md new file mode 100644 index 000000000..590f94846 --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F13-message-store-sort.md @@ -0,0 +1,162 @@ +# 修复 F13:message store 排序优化 + cache LRU + +## 目标 +- 将新增消息的主路径从“push 后全量 `sort`”收敛为“有序时二分插入,乱序时回退全量排序”,降低长会话下 `messageIds` 维护成本。 +- 保持 `loadMessages()` 当前的一次性重建模式,不把批量加载退化回逐条 upsert + 重复排序。 +- 为 `parsedMessageCache` 增加固定容量 LRU,避免单次超长会话内缓存持续膨胀。 +- 将 `assistantBlockPayloadEqual()` 的热路径从多次 `JSON.stringify()` 深比较调整为更轻量、可控的比较方式。 + +## 定位 +- `messageIds` / `messageCache` 定义:[`src/renderer/src/stores/ui/message.ts#L48-L49`](../../../src/renderer/src/stores/ui/message.ts#L48-L49) +- 当前排序逻辑:[`src/renderer/src/stores/ui/message.ts#L71-L94`](../../../src/renderer/src/stores/ui/message.ts#L71-L94) +- `parsedMessageCache` 定义:[`src/renderer/src/stores/ui/message.ts#L55`](../../../src/renderer/src/stores/ui/message.ts#L55) +- `loadMessages()` 一次性重建:[`src/renderer/src/stores/ui/message.ts#L353-L364`](../../../src/renderer/src/stores/ui/message.ts#L353-L364) +- `parsedMessageCache` / streaming 占位清理:[`src/renderer/src/stores/ui/message.ts#L360-L363`](../../../src/renderer/src/stores/ui/message.ts#L360-L363)、[`src/renderer/src/stores/ui/message.ts#L472-L483`](../../../src/renderer/src/stores/ui/message.ts#L472-L483) +- `assistantBlockPayloadEqual()` 的 `JSON.stringify()` 热路径:[`src/renderer/src/stores/ui/message.ts#L128-L175`](../../../src/renderer/src/stores/ui/message.ts#L128-L175) +- 历史页前插:[`src/renderer/src/stores/ui/message.ts#L396-L401`](../../../src/renderer/src/stores/ui/message.ts#L396-L401) +- optimistic 用户消息直接 `push`:[`src/renderer/src/stores/ui/message.ts#L433-L463`](../../../src/renderer/src/stores/ui/message.ts#L433-L463) +- optimistic `orderSeq = messageIds.length + 1`:[`src/renderer/src/stores/ui/message.ts#L443`](../../../src/renderer/src/stores/ui/message.ts#L443) +- streaming 补入新 id:[`src/renderer/src/stores/ui/message.ts#L523-L535`](../../../src/renderer/src/stores/ui/message.ts#L523-L535) +- streaming `orderSeq = messageIds.length + 1`:[`src/renderer/src/stores/ui/message.ts#L526`](../../../src/renderer/src/stores/ui/message.ts#L526) + +现状结论: +- `upsertMessageRecord()` 在“新 id”或 `orderSeq` 变化时都会执行 `sortMessageIdsByOrderSeq()`,热点在全量排序,不在 `Map.set()`。 +- `loadMessages()` 目前是正确的一次性重建,不是逐条 upsert;这一点应保留。 +- `parsedMessageCache` 当前只在 `loadMessages()` 与 `clear()` 时整体清空,能避免跨会话残留,但无法限制单会话长时间增长。 +- `messageIds` 的全局有序前提并不天然成立,因为当前代码存在三条会破坏“严格按真实历史序列递增追加”假设的路径: + 1. optimistic 消息直接尾插; + 2. 历史消息分页前插; + 3. streaming 首次落入本地缓存时补入新 id。 + +## 修复方案 + +### 3.1 新 id 插入改为“有序检测 + 二分插入 + fallback 排序” +新增消息时不再默认全量 `sort`,而是执行下面的可执行规则: + +1. `hasMessageId === true`: + - 只更新 `messageCache`; + - 仅当 `cachedRecord.orderSeq !== record.orderSeq` 时,执行一次全量 `sortMessageIdsByOrderSeq()`,因为已有元素的排序键变化会影响全局顺序。 +2. `hasMessageId === false`: + - 若 `record.orderSeq` 不是有限数字,直接 `push` 后 fallback 到 `sortMessageIdsByOrderSeq()`。 + - 若 `record.orderSeq` 是有限数字,先检测当前 `messageIds` 是否“按当前缓存中的 `orderSeq` 非降序排列,且同 `orderSeq` 时按 id 字典序非降序排列”。只有检测通过,才允许二分插入;否则 fallback 到全量 `sortMessageIdsByOrderSeq()`。 + +建议新增两个帮助函数: + +```ts +function compareMessageIds(left: string, right: string): number { + if (left === right) return 0 + return left < right ? -1 : 1 +} + +function isMessageIdsSortedByOrderSeq(): boolean { + let previousSeq = Number.NEGATIVE_INFINITY + let previousId = '' + + for (const id of messageIds.value) { + const seq = messageCache.value.get(id)?.orderSeq + if (!Number.isFinite(seq)) return false + + if (seq! < previousSeq) return false + if (seq === previousSeq && compareMessageIds(previousId, id) > 0) return false + + previousSeq = seq + previousId = id + } + + return true +} + +function findInsertIndexByOrderSeq(orderSeq: number, id: string): number { + let left = 0 + let right = messageIds.value.length + + while (left < right) { + const mid = (left + right) >> 1 + const midId = messageIds.value[mid] + const midSeq = messageCache.value.get(midId)?.orderSeq ?? Number.MAX_SAFE_INTEGER + + if (midSeq < orderSeq) { + left = mid + 1 + continue + } + if (midSeq > orderSeq) { + right = mid + continue + } + + if (compareMessageIds(midId, id) <= 0) { + left = mid + 1 + } else { + right = mid + } + } + + return left +} +``` + +插入规则: +- `messageCache.value.set(record.id, record)` 之后,若 `isMessageIdsSortedByOrderSeq()` 为真,则用 `findInsertIndexByOrderSeq()` + `splice()`; +- 若检测失败,则先 `push()`,再执行一次 `sortMessageIdsByOrderSeq()` 进行纠偏。 + +这样把“能二分”的前提写成了代码层面的显式条件,而不是依赖隐含假设。 + +### 3.2 保持 `loadMessages()` 一次性重建 +[`loadMessages()`](../../../src/renderer/src/stores/ui/message.ts#L353-L364) 现在是: +- 遍历 `restored.messages` 构建 `nextMessageCache`; +- 同步构建 `nextMessageIds`; +- 最后一次性替换 `messageCache.value` 与 `messageIds.value`。 + +这已经符合 F13 对“批量加载不要逐条 upsert”的要求,应明确保留,不做回退。若未来有人想复用 `upsertMessageRecord()` 重写该逻辑,文档应禁止这种回归,要求批量路径继续维持“一次性收集、一次性赋值、至多一次排序”。 + +### 3.3 `parsedMessageCache` 改为固定容量 LRU +将 `parsedMessageCache` 从无界 `Map` 改为固定容量 LRU,阈值明确设为 **1024**。 + +选择 1024 的依据: +- 当前消息窗口默认加载量是 100,恢复窗口会按需扩展,但单次活动会话常见规模通常落在数百条到约千条; +- 1024 足以覆盖典型长会话中“当前窗口 + 最近滚动/重解析热点”的命中需求; +- 同时又能把缓存上限控制在稳定、可预测的数量级,避免单会话持续增长。 + +建议规则: +- 常量:`const PARSED_MESSAGE_CACHE_MAX_SIZE = 1024` +- 命中缓存时,删除旧 key 后重新 `set()`,刷新最近使用顺序; +- 写入新条目后,若 `size > 1024`,删除 `Map.keys().next().value` 对应的最旧 key; +- `loadMessages()`、`clear()`、会话切换相关重建仍保留整表清空。 + +### 3.4 block payload 比较降温 +[`assistantBlockPayloadEqual()`](../../../src/renderer/src/stores/ui/message.ts#L128-L175) 当前对 `extra`、`tool_call`、`artifact`、`image_data`、`reasoning_time` 都走 `JSON.stringify()`。该逻辑应改为: +- 优先比较影响 UI 的稳定字段; +- 仅在确实需要比较复杂对象且无法拆字段时,才考虑预计算 hash; +- 不在热路径里反复序列化整个 payload。 + +## 验证 +除现有回归外,需新增三类顺序专项验证,证明“二分插入”和当前几条特殊路径可以共存: + +1. **optimistic 路径回归** + - 先插入若干已持久化消息; + - 调用 `addOptimisticUserMessage()` 产生 `orderSeq = messageIds.length + 1` 的本地消息; + - 再插入/加载真实历史消息,验证: + - 若当前 `messageIds` 仍满足排序检测,则新增消息走二分插入; + - 若 optimistic 记录导致全局顺序前提不可信,则命中 fallback,全量排序后顺序正确。 + +2. **历史前插回归** + - 构造 `loadOlderMessages()` 前插旧页的场景; + - 在前插后继续插入新消息,验证排序检测不会错误假设数组始终可二分; + - 确认 fallback 后最终顺序与 `sortMessageIdsByOrderSeq()` 一致。 + +3. **streaming 补 id 回归** + - 构造 `applyStreamingBlocksToMessage()` 首次为 assistant 消息补 id 的场景; + - 其初始 `orderSeq` 同样来自 `messageIds.length + 1`; + - 验证后续真实消息到达、`loadMessages()` 重建或 `orderSeq` 校正时,不会因为此前二分插入假设而破坏顺序。 + +此外补充: +- `parsedMessageCache` 的 LRU 淘汰测试; +- `assistantBlockPayloadEqual()` 轻量比较的等价性测试; +- 基础质量检查仍应覆盖 `pnpm run format`、`pnpm run i18n`、`pnpm run lint`,必要时加 `pnpm run typecheck`。 + +## 风险 +- **核心风险必须明确**:[`addOptimisticUserMessage()`](../../../src/renderer/src/stores/ui/message.ts#L443) 与 [`applyStreamingBlocksToMessage()`](../../../src/renderer/src/stores/ui/message.ts#L526) 都用 `messageIds.value.length + 1` 生成 `orderSeq`。这个值只代表“当前内存数组长度上的尾序号”,**不等价于真实历史消息序列**。因此二分插入不能把它当作稳定的全局顺序来源;这正是“插入前先检测有序性、失败就 fallback 排序”的核心原因。 +- `loadOlderMessages()` 的前插会让数组结构发生非尾部变动;若后续逻辑偷懒假设“列表只会 append”,二分插入就会失效。 +- `upsertMessageRecord()` 即使改成二分插入,`messageIds.includes()` 仍是 O(n) 判重;但这不阻塞 F13,因为当前最大热点仍是全量排序。 +- LRU 阈值若后续实测过小,可能增加重复解析;但固定上限优先级高于无限增长,可在实测后调参。 +- block payload 若浅比较字段选取不全,可能导致错误复用,需要专项测试兜底。 diff --git a/docs/architecture/perf-fixes-from-audit/fix-F15-chatstatusbar-watchers.md b/docs/architecture/perf-fixes-from-audit/fix-F15-chatstatusbar-watchers.md new file mode 100644 index 000000000..6ece7451d --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F15-chatstatusbar-watchers.md @@ -0,0 +1,77 @@ +# 修复 F15:ChatStatusBar watcher 链去 deep + 同 tick 去重 + +## 目标 +(移除 `deep:true` 带来的深层遍历,并在不打散现有职责边界的前提下减少 session 切换时的重复调度) + +## 定位 +- 问题代码:`src/renderer/src/components/chat/ChatStatusBar.vue:2297-2310`,watcher 依赖 `[hasActiveSession, isAcpAgent, () => agentStore.selectedAgentId, () => modelStore.initialized, () => modelStore.chatSelectableModelGroups]`,配置 `{ immediate: true, deep: true }` +- 问题代码:`src/renderer/src/components/chat/ChatStatusBar.vue:2312-2337`,permission watcher 依赖 `[() => sessionStore.activeSessionId, canSelectPermissionMode, () => draftStore.permissionMode]`,配置 `{ immediate: true }` +- 问题代码:`src/renderer/src/components/chat/ChatStatusBar.vue:2339-2359`,subagent watcher 依赖 `[() => sessionStore.activeSessionId, showSubagentToggle, () => sessionStore.activeSession?.subagentEnabled, () => draftStore.subagentEnabled]`,配置 `{ immediate: true }` +- 问题代码:`src/renderer/src/components/chat/ChatStatusBar.vue:2362-2375`,generation settings watcher 依赖 `[() => sessionStore.activeSessionId, () => sessionStore.activeSession?.providerId, () => sessionStore.activeSession?.modelId, () => draftModelSelection.value?.providerId, () => draftModelSelection.value?.modelId, () => isAcpAgent.value]`,配置 `{ immediate: true }` +- 问题代码:`src/renderer/src/components/chat/ChatStatusBar.vue:2377-2395`,ACP config watcher 依赖 `[() => sessionStore.activeSessionId, () => sessionStore.activeSession?.providerId, () => sessionStore.activeSession?.modelId, () => sessionStore.activeSession?.projectDir, () => agentStore.selectedAgentId, () => projectStore.selectedProject?.path, () => props.acpDraftSessionId, () => isAcpAgent.value]`,配置 `{ immediate: true }` +- 定义位置:`src/renderer/src/components/chat/ChatStatusBar.vue:1081` 导入 `scheduleStartupDeferredTask` +- 定义位置:`src/renderer/src/components/chat/ChatStatusBar.vue:1164-1171`,`permissionSyncToken`、`generationSyncToken`、`cancelAcpConfigSyncTask` 等同步守卫变量 +- 定义位置:`src/renderer/src/components/chat/ChatStatusBar.vue:1357-1363`,`modelGroups` 直接读取 `modelStore.chatSelectableModelGroups` +- 定义位置:`src/renderer/src/components/chat/ChatStatusBar.vue:1448-1477`,`modelSettingsTarget` 的异步配置拉取 watcher 已采用 token 守卫 +- 定义位置:`src/renderer/src/components/chat/ChatStatusBar.vue:1479-1497`,`normalizePermissionMode` 等权限相关逻辑 +- 定义位置:`src/renderer/src/stores/modelStore.ts:893-931`,`chatSelectableModelGroups` 已定义为 computed,依赖 `providerStore.sortedProviders` / `providerStore.providers` 与 `enabledModels` +- 定义位置:`src/renderer/src/stores/ui/session.ts:282-283`,`activeSessionId` 为单一 `ref` +- 定位补充:`test/renderer/components/ChatStatusBar.test.ts:1-2637`,已存在 `ChatStatusBar` 组件测试文件,可作为 watcher 行为回归测试载体 + +根因: +- 模型组 watcher 使用 `deep:true` 监听 `chatSelectableModelGroups`,会递归遍历 provider 分组与组内模型,放大常驻组件的响应成本。 +- `chatSelectableModelGroups` 的结果不仅取决于数组引用是否替换,还取决于 provider 名称、排序、启用状态,以及 `enabledModels` 中组内模型内容;若仅依赖浅层数组引用,又没有一个被完整维护的 revision 信号,就会出现漏同步。 +- `activeSessionId` 是 session 切换主驱动,但 generation watcher 与 ACP watcher 已各自承担不同同步职责;问题主要在于同一 tick 内重复排队,而不是必须把两个 watcher 合并成一个大 watcher。 + +## 修复方案 +推荐采用“先补全 store 级浅层 revision,再在组件侧去 deep,并对异步同步做 coalescing”的保守方案。 + +### 1. 先在 modelStore 建立并全面维护 `chatSelectableModelGroupsRevision` +- `src/renderer/src/stores/modelStore.ts:893-931` 已确认存在 `chatSelectableModelGroups` 定义,因此不需要新增同名 computed;需要新增的是与它配套的浅层修订信号,例如 `chatSelectableModelGroupsRevision: Ref`。 +- 该 revision 必须作为**硬前置步骤**先落地,再修改 `ChatStatusBar.vue` watcher;否则去掉 `deep:true` 后会失去对组内深层变化的感知。 +- revision 自增范围必须覆盖所有会影响 `chatSelectableModelGroups` 结果的路径,至少包括: + - provider 名称变化(影响 `providerName`) + - provider 排序变化(影响 `sortedProviders` / 展示顺序) + - provider 启用状态变化(影响是否出现在结果中) + - `enabledModels` 的 provider 组新增/删除 + - provider 组内模型新增/删除 + - provider 组内模型属性变化,只要会影响 `isChatSelectableModelType(model.type)` 或最终展示结果 +- 由于 `chatSelectableModelGroups` 依赖 `providerStore.sortedProviders` / `providerStore.providers` 与 `enabledModels`,如果 revision 只在部分更新路径自增,例如只覆盖数组替换而漏掉 provider rename、enable toggle、或组内模型就地修改,就会让 watcher 在“结果已变但 revision 未变”的情况下漏判。 + +### 2. ChatStatusBar 模型组 watcher 改为监听浅层依赖 +- 将 `src/renderer/src/components/chat/ChatStatusBar.vue:2297-2310` 的 watcher 从 `deep:true` 改为浅层监听。 +- watcher 依赖应改为:`hasActiveSession`、`isAcpAgent`、`agentStore.selectedAgentId`、`modelStore.initialized`、`modelStore.chatSelectableModelGroupsRevision`(或等价且被完整维护的浅层信号)。 +- `modelGroups` 的实际读取仍保持 `modelStore.chatSelectableModelGroups`,不需要改组件消费方式;revision 只负责提供低成本、可预测的变更触发信号。 +- 不建议用 `length` 替代 revision,因为 provider 名称变化、排序变化、组内模型类型过滤变化都可能改变最终可选结果,却不改变顶层长度。 + +### 3. 保留 watcher 拆分,改做同 tick coalescing +- `src/renderer/src/components/chat/ChatStatusBar.vue:2362-2375` 的 generation watcher 与 `src/renderer/src/components/chat/ChatStatusBar.vue:2377-2395` 的 ACP watcher 目前职责边界清晰:前者负责生成设置同步,后者负责 ACP 配置同步。 +- 本项不再把“合并 activeSessionId watcher”为默认方案;推荐保留拆分,避免把权限、生成设置、ACP 配置耦合成一个大型 watcher。 +- 优先做的改动应是: + - `syncGenerationSettings()` 增加同 tick coalescing / pending 门闩,保证多次触发只排队一次。 + - `syncAcpConfigOptions()` 保留现有 `cancelAcpConfigSyncTask?.()` + `scheduleStartupDeferredTask(...)` 的取消重建模型,并确认同一 tick 内不会重复创建无意义任务。 + - permission watcher、subagent watcher 保持现状,只在确有无变化重复写入时增加本地短路比较。 +- 若后续 profiling 证明拆分 watcher 仍然是主要热点,再单独评估是否需要更上层的 session-scope scheduler;这不是本次修复的硬要求。 + +## 步骤拆分 +1. **先**在 `modelStore` 增加 `chatSelectableModelGroupsRevision`,并把所有影响 `chatSelectableModelGroups` 结果的更新路径接通到 revision 自增。 +2. 再修改 `ChatStatusBar.vue:2297-2310`,移除 `deep:true`,改监听 revision 等浅层依赖。 +3. 为 `syncGenerationSettings()` 增加同 tick coalescing,复核 `syncAcpConfigOptions()` 的取消/重建去重路径。 +4. 最后补测试,覆盖 revision 完整性与 watcher 去重行为。 + +## 验证 +- 单元/组件测试:更新 `test/renderer/components/ChatStatusBar.test.ts`,至少覆盖: + - 去掉 `deep:true` 后,revision 变化仍会触发 `syncDraftModelSelection()`。 + - provider rename / enable toggle / 排序变化会驱动 revision,自然带动 watcher 更新。 + - **组内模型变化但数组引用不变** 时,只要结果受影响,revision 仍会自增并触发同步。 + - session 切换时 generation watcher 与 ACP watcher 在同一 tick 内不会重复排队超出预期次数。 +- store 测试(如已有合适测试载体):增加针对 `chatSelectableModelGroupsRevision` 的覆盖,验证 `chatSelectableModelGroups` 依赖的 providerStore / `enabledModels` 更新路径不会漏 bump。 +- typecheck/lint:如后续进入代码实现,执行仓库标准的 `pnpm run typecheck`、`pnpm run format`、`pnpm run i18n`、`pnpm run lint`。 +- 可选本地验证:对 session 切换路径做一次 watcher 调度计数,确认本次优化目标是“去 deep + 同 tick 去重”,而不是改变最终同步结果。 + +## 风险 +- 最大风险不是移除 `deep:true` 本身,而是 `chatSelectableModelGroupsRevision` 覆盖不全:`chatSelectableModelGroups` 同时依赖 `providerStore.sortedProviders` / `providerStore.providers` 与 `enabledModels`,任何一侧若有更新路径未自增 revision,都会造成漏判。 +- 典型漏判场景包括:provider 名称修改、provider 启用状态切换、排序调整、组内模型类型变化、组内模型数组就地修改但外层数组引用未变。 +- 若错误地把 generation watcher 与 ACP watcher 强行合并,虽然可能减少一次表面触发,但会把两个已经清晰分离的职责耦合起来,提升维护成本并放大回归面。 +- `syncGenerationSettings()` 与 `syncAcpConfigOptions()` 都含异步流程,coalescing 必须与现有 token / cancel 守卫兼容,避免旧任务覆盖新状态。 +- `test/renderer/components/ChatStatusBar.test.ts` 体量已较大,新增断言应优先验证最终行为与调用次数上界,避免把实现细节绑得过死。 diff --git a/docs/architecture/perf-fixes-from-audit/fix-F16-provider-list-render.md b/docs/architecture/perf-fixes-from-audit/fix-F16-provider-list-render.md new file mode 100644 index 000000000..b827575ef --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-F16-provider-list-render.md @@ -0,0 +1,82 @@ +# 修复 F16:Provider 列表首屏渲染收敛 + +## 目标 +- 降低 Settings provider 页首次进入时的同步挂载成本,优先解决 disabled provider 区块一次性渲染全部行的问题。 +- 保留现有 provider 排序与图标匹配语义,不在本轮修复中扩大交互或匹配规则变更范围。 +- 主方案仅覆盖 disabled 区折叠与 `ModelIcon` 的 `iconKey` 命中优化;enabled 区保持现状,避免把风险扩散到主列表排序交互。 + +## 定位 +- enabled provider 区当前始终渲染 `draggable` 列表,列表起点位于 [src/renderer/settings/components/ModelProviderSettings.vue#L61](../../../src/renderer/settings/components/ModelProviderSettings.vue#L61)。 +- disabled provider 区同样始终渲染 `draggable` 列表,列表起点位于 [src/renderer/settings/components/ModelProviderSettings.vue#L124](../../../src/renderer/settings/components/ModelProviderSettings.vue#L124)。这意味着首次进入页面时,enabled + disabled 两块都会立即挂载完整 provider 行。 +- provider 列表当前通过 `allEnabledProviders` / `allDisabledProviders` 分组,再由 `enabledProviders` / `disabledProviders` 结合搜索结果生成展示列表,相关分组逻辑位于 [src/renderer/settings/components/ModelProviderSettings.vue#L650](../../../src/renderer/settings/components/ModelProviderSettings.vue#L650)。 +- `ModelIcon` 的 `iconKey` 仍是模糊子串匹配:先取 `modelIcons` 的声明顺序 key,再执行 `modelIdLower.includes(key)`,未命中时再对 `apiType.includes(key)` 做第二轮扫描。这不是精确 `Map.get` 语义,像 `gpt-4 -> gpt`、`claude-3 -> claude` 依赖的正是 `includes`。 +- `ProviderModelList` 已在模型明细区使用 `RecycleScroller`,见 [src/renderer/settings/components/ProviderModelList.vue#L206](../../../src/renderer/settings/components/ProviderModelList.vue#L206) 与导入位置 [src/renderer/settings/components/ProviderModelList.vue#L326](../../../src/renderer/settings/components/ProviderModelList.vue#L326)。这说明仓库已有虚拟列表实践,但 provider 总览页当前问题更集中在“首屏无条件全量挂载 disabled 列表”,不必默认把 enabled 区或整页都改成虚拟滚动。 + +## 修复方案 + +### 主方案 A:disabled 区默认折叠 +- 增加 `isDisabledProvidersExpanded` 状态,默认 `false`。 +- 首屏仅渲染 disabled 区标题、数量、展开按钮;只有用户主动展开时才挂载 disabled provider 行。 +- 搜索命中 disabled provider 时允许自动展开,避免“有结果但不可见”。 +- enabled 区本轮不改,继续沿用现有 `draggable` 与点击/路由高亮行为,降低回归面。 + +这个方案直接针对 F16 的首屏挂载热点,且不影响 enabled provider 的既有排序入口与交互心智。 + +### 主方案 B:保留 `includes` 语义的 `iconKey` 优化 +- 撤回“改成精确 `Map.get(modelIdLower)`”的说法;当前代码依赖模糊匹配,不能直接换成精确键查找。 +- 可采用两类低风险优化: + 1. 预建候选 key 数组并保留 `modelIcons` 声明顺序,继续使用 `includes`,但避免每次计算都重新 `Object.keys(modelIcons)`; + 2. 对 `modelId/apiType -> iconKey` 命中结果做缓存,减少重复渲染时的全量候选扫描。 +- 不按 key 长度重排候选;当前匹配语义是声明顺序的 first-match,优化不能改变这个优先级。 + +示意: +```ts +const ICON_CANDIDATE_KEYS = Object.keys(modelIcons) as ModelIconKey[] + +const iconMatchCache = new Map() + +function resolveIconKey(source: string | undefined): string | undefined { + if (!source) return undefined + const normalized = source.toLowerCase() + const cached = iconMatchCache.get(normalized) + if (cached) return cached + + const matched = ICON_CANDIDATE_KEYS.find((key) => normalized.includes(key)) + if (matched) { + iconMatchCache.set(normalized, matched) + } + return matched +} +``` + +这里仍然是 `includes` 语义,只是把候选集构建与命中缓存前移,避免每次 computed 都重新扫描原始 key 列表。 + +### 可选后续:enabled 区拖拽按需启用 +- 这项不纳入主方案。 +- 若后续审计表明 enabled 区 `draggable` 仍是明显热点,可再单独评估“默认 readonly、编辑态才挂载 draggable”。 +- 该改动会影响排序入口可发现性与交互习惯,风险显著高于 disabled 区折叠,因此应作为后续优化项,而不是本次修复内容。 + +## 步骤拆分 +- 本轮只修改 provider 总览页首屏渲染路径,不改 provider 模型明细区的 `RecycleScroller` 策略。 +- 本轮不把 enabled provider 区改成折叠,也不改成“仅编辑态可拖拽”。 +- 本轮不改变 provider 排序持久化协议,不调整 `enabledProviders` / `disabledProviders` setter 的重排规则。 +- 本轮不改变 `iconKey` 的模糊匹配语义,只优化候选构建与重复命中成本。 + +## 验证 +- 性能验证:对比 `18-provider-readonly-route` 的进入耗时,确认 disabled 区折叠后首屏挂载时间下降。 +- 交互回归: + - disabled 区默认折叠、手动展开、搜索命中自动展开行为正确; + - 展开后 provider 行点击切换正常,路由高亮状态仍与 `route.params.providerId` 一致; + - enabled 区现有点击、开关切换、拖拽排序行为保持不变。 +- 排序相关回归: + - 拖拽排序后的顺序可正确持久化; + - 搜索过滤条件下拖拽排序后,`enabledProviders` / `disabledProviders` setter 仍按现有规则合并回全量列表; + - 展开 disabled 区后执行拖拽、点击切换,再次进入页面时顺序与选中态正常。 +- 图标回归:覆盖 `gpt-*`、`claude-*`、`gemini-*`、自定义 provider `apiType` 等常见命中路径,确认优化前后图标结果一致。 +- 基础检查:文档修订无需运行应用代码;若后续落实代码修复,再执行仓库要求的 `pnpm run format`、`pnpm run i18n`、`pnpm run lint`。 + +## 风险 +- icon 匹配顺序回归风险:即使仍使用 `includes`,一旦候选遍历顺序变化,就可能改变优先命中结果,导致个别 provider 图标切换。 +- disabled 区折叠会改变默认信息密度;若计数、展开文案或搜索自动展开提示不清晰,用户可能误以为 provider 丢失。 +- 若未来引入“编辑态才可拖拽”的 enabled 区优化,存在明显的可发现性风险:用户可能找不到排序入口,需额外的按钮、提示文案或引导设计支撑。 +- 搜索过滤下的排序与折叠状态叠加后,若状态同步处理不严谨,容易出现顺序持久化异常或展开态与结果不一致的问题。 diff --git a/docs/architecture/perf-fixes-from-audit/fix-V1-settings-hidden-route.md b/docs/architecture/perf-fixes-from-audit/fix-V1-settings-hidden-route.md new file mode 100644 index 000000000..209d1c581 --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/fix-V1-settings-hidden-route.md @@ -0,0 +1,69 @@ +# 修复 V1:Settings 隐藏路由 E2E 导航 + +## 目标 +让 smoke 测试对 `hiddenInSidebar` 的 Settings 路由改用可复用的路由元信息决定导航方式,而不是依赖侧边栏 tab 点击,从而消除确定性失败。 + +## 定位 +- [src/shared/settingsNavigation.ts#L88-L285](../../../src/shared/settingsNavigation.ts#L88-L285):`SETTINGS_NAVIGATION_ITEMS` 完整定义了 Settings 路由矩阵,不只是“部分项”;其中包含 `path`、`routeName`、`hiddenInSidebar`、平台支持等测试所需元信息。 +- [src/shared/settingsNavigation.ts#L163-L170](../../../src/shared/settingsNavigation.ts#L163-L170):`settings-mcp` 明确配置 `path: '/mcp'` 且 `hiddenInSidebar: true`,说明该路由存在但不会出现在侧边栏。 +- [src/shared/settingsNavigation.ts#L173-L180](../../../src/shared/settingsNavigation.ts#L173-L180):`settings-remote` 同样 `hiddenInSidebar: true`,测试不能假设其 tab 一定可见。 +- [src/shared/settingsNavigation.ts#L211-L220](../../../src/shared/settingsNavigation.ts#L211-L220):`settings-plugins` 也是隐藏路由,并且带有平台支持限制,说明测试需要从路由定义层获取路径而不是从 UI 侧边栏猜测。 +- [src/shared/settingsNavigation.ts#L222-L229](../../../src/shared/settingsNavigation.ts#L222-L229):`settings-skills` 同样隐藏在侧边栏之外。 +- [src/shared/settingsNavigation.ts#L333-L342](../../../src/shared/settingsNavigation.ts#L333-L342):`getSettingsRouteItems()` 会返回受平台过滤后的完整路由集合,`getSettingsNavigationItems()` 才会进一步过滤 `hiddenInSidebar`;这说明测试优先复用现有 shared 导出即可判断某个 route 是否应出现在侧边栏。 +- [src/shared/settingsNavigation.ts#L358-L389](../../../src/shared/settingsNavigation.ts#L358-L389):`resolveSettingsNavigationPath()` 已能基于 `routeName` 和参数解析实际路由 path,可直接作为测试侧 hash 直达的路径来源。 +- [src/renderer/settings/App.vue#L37-L47](../../../src/renderer/settings/App.vue#L37-L47):侧边栏按钮只会为 `group.items` 渲染 `data-testid=settings-tab-*`,并通过点击触发 `handleClick(setting.path)`;隐藏项不在 `group.items`,因此不会有按钮可点。 +- [src/renderer/settings/App.vue#L520-L521](../../../src/renderer/settings/App.vue#L520-L521):`handleClick(path)` 实际就是 `router.push(path)`,说明测试若能拿到 path,走路由直达与 UI 点击在导航语义上是一致的。 +- [test/e2e/helpers/settings.ts#L52-L56](../../../test/e2e/helpers/settings.ts#L52-L56):`openSettingsTab()` 先执行 `toBeVisible()` 再 click,遇到隐藏路由时会稳定超时。 +- [test/e2e/specs/04-settings-navigation.smoke.spec.ts#L9-L96](../../../test/e2e/specs/04-settings-navigation.smoke.spec.ts#L9-L96):`settingsPages` 是当前 smoke 的完整导航矩阵,其中包含 `mcp / remote / plugins / skills` 等隐藏路由,而不是只列出侧边栏可见页。 +- [test/e2e/specs/04-settings-navigation.smoke.spec.ts#L125-L142](../../../test/e2e/specs/04-settings-navigation.smoke.spec.ts#L125-L142):`openAndCaptureSettingsPage()` 已经有 `optional` 分支,说明这里适合继续扩展“是否走侧边栏”的判断。 +- [test/e2e/specs/04-settings-navigation.smoke.spec.ts#L158-L164](../../../test/e2e/specs/04-settings-navigation.smoke.spec.ts#L158-L164):同文件已经存在直接写 `window.location.hash` 的路由导航先例,说明 smoke 本身可以接受 hash 直达。 + +根因:`hiddenInSidebar: true` 的路由不渲染侧边栏按钮,但 `openSettingsTab()` 仍然假设每个 settings item 都能通过 `toBeVisible()` 找到并点击,因此对 `mcp / remote / plugins / skills` 等隐藏路由会稳定失败。 + +## 修复方案 +推荐方案:把“是否需要侧边栏点击”这件事放进测试辅助层,并优先复用现有 shared navigation 导出,而不是先新增 hidden 判定 API。 + +### 推荐实现 +1. 优先在 E2E 侧复用 [src/shared/settingsNavigation.ts](../../../src/shared/settingsNavigation.ts) 已有的 `getSettingsRouteItems()` 与 `resolveSettingsNavigationPath()`: + - 用 `getSettingsRouteItems(platform?, arch?)` 获取受平台过滤后的完整路由集合。 + - 根据现成的 `hiddenInSidebar` / `path` 字段判断该 route 应走侧边栏点击还是 hash 直达。 + - 用 `resolveSettingsNavigationPath(routeName, params?, platform?, arch?)` 生成实际导航 path,避免测试硬编码字符串。 + - 先确认 Playwright E2E 侧可以直接导入 shared 模块;只有在导入约束无法满足时,才考虑补充更窄的新导出。 +2. 在 `test/e2e/helpers/settings.ts` 将 `openSettingsTab()` 扩展为支持两种导航: + - 非隐藏项:继续按现有逻辑,等待 `data-testid` 可见后点击侧边栏按钮。 + - 隐藏项:用 `resolveSettingsNavigationPath(routeName, params?, platform?, arch?)` 生成 path,再执行 `settingsPage.evaluate((hash) => { window.location.hash = hash }, '#' + path)`,随后等待目标 `pageTestId` 可见。 +3. 为了避免测试侧重复维护 route/path 映射,建议 `openSettingsTab()` 或其上层 helper 接收可关联到 `routeName` 的参数,并在 helper 内部统一反查 shared navigation 配置。 +4. `test/e2e/specs/04-settings-navigation.smoke.spec.ts` 继续保持 `settingsPages` 矩阵,但把 `openAndCaptureSettingsPage()` 改成传入 `routeName` 或其他可反查 shared route 元信息的字段;对于 `mcp / remote / plugins / skills` 走 hash,其余项继续走点击。 + +### 备选方案 +- **备选 A:只在 spec 里为 hidden 路由写 `window.location.hash` 分支。** 实现最直接,但逻辑分散在测试文件里,后续新增 hidden 路由时容易漏改,不如 helper 统一封装。 +- **备选 B:在 `openSettingsTab()` 内部先判断 `tab.count()`,若不存在就用 `path` hash 导航。** 改动最小,但如果 `path` 只来自测试常量,会带来重复维护;仍应尽量从 shared navigation 反查,而不是在 spec 内手写路径。 +- **备选 C:新增 hidden 判定 API。** 只有在 E2E 无法稳定复用现有 `getSettingsRouteItems()` / `resolveSettingsNavigationPath()` 导出时再考虑;当前不是首选。 + +### 取舍结论 +建议采用“优先复用 shared navigation 现有导出 + helper 统一切换导航方式”的组合: +- 保证唯一事实来源来自 `src/shared/settingsNavigation.ts`。 +- 让 smoke spec 仍然只关心页面矩阵和截图,不把路由规则散落到测试逻辑里。 +- 避免继续依赖 `toBeVisible()` 去等待一个本来就不会出现的按钮。 +- 复用 `resolveSettingsNavigationPath()` 后,测试与 `App.vue` 的 `router.push(path)` 保持同一导航语义。 + +## 步骤拆分 +1. 先确认 `test/e2e` 可直接导入 [src/shared/settingsNavigation.ts](../../../src/shared/settingsNavigation.ts) 的 `getSettingsRouteItems()` / `resolveSettingsNavigationPath()`,并据此拿到 `hiddenInSidebar` 与实际 path。 +2. 在 `test/e2e/helpers/settings.ts` 改造 `openSettingsTab()`:隐藏路由使用 `window.location.hash` 直达,非隐藏路由保留 tab click。 +3. 在 `test/e2e/specs/04-settings-navigation.smoke.spec.ts` 让 `openAndCaptureSettingsPage()` 传入 route 元信息,确保 `mcp / remote / plugins / skills` 不再走 `toBeVisible()` 等待侧边栏按钮。 +4. 补充/更新 smoke 相关断言,确认 hash 导航后目标 `pageTestId` 可见且截图路径不变。 +5. 如实测发现 shared 导入在 E2E bundling / tsconfig 侧不可用,再补最小必要适配;只有此时才考虑新增更窄的辅助导出。 + +## 验证 +- 主验证命令:`pnpm exec playwright test -c test/e2e/playwright.config.ts 04-settings-navigation --reporter=list` +- 预期:当前稳定失败点从 `openSettingsTab()` 的 `toBeVisible()` 超时变为通过,`mcp / remote / plugins / skills` 都能被正确打开并截图。 +- 回归范围以现有证据为准: + - `04-settings-navigation` 全矩阵必须重跑,因为 [test/e2e/specs/04-settings-navigation.smoke.spec.ts#L9-L96](../../../test/e2e/specs/04-settings-navigation.smoke.spec.ts#L9-L96) 本身覆盖了隐藏与非隐藏 Settings 页面。 + - 再补跑实际包含 hidden Settings 路由的 spec,例如 `13-mcp`、`11-remote`、`16-skills` 等 readonly route spec;具体命中文件与覆盖深度需要以实际执行结果确认,不再预设 06 / 11 / 13 / 16 / 19 这类无证据全集。 +- 静态检查:完成实现后再跑 `pnpm run typecheck`、`pnpm run lint`,确保 shared 导出和 E2E helper 类型一致。 + +## 风险 +- 如果 `openSettingsTab()` 改成 hash 导航后,某些页面状态初始化依赖点击事件而不是路由变更,可能需要补一小段等待逻辑,但从现有 `window.location.hash` 先例看风险可控。 +- `plugins` 在 [src/shared/settingsNavigation.ts#L211-L220](../../../src/shared/settingsNavigation.ts#L211-L220) 中有平台支持限制,当前 `optional` 只明确覆盖了它;其余 hidden 项虽然未声明平台限制,但也没有额外平台兜底逻辑,后续若新增受限 hidden 路由,测试 helper 仍需同步处理支持矩阵。 +- `pageTestId` 可见性的等待依赖目标路由组件已经完成注册与渲染;切到 hash 直达后,是否总能稳定命中对应 `pageTestId` 需要通过实跑验证,而不能只凭静态阅读假设。 +- 如果 future 新增 hidden 路由但没有同步进 helper 所需的 route 元信息,仍可能出现测试维护偏差,因此 shared navigation 应保持测试和 UI 共用。 diff --git a/docs/architecture/perf-fixes-from-audit/plan.md b/docs/architecture/perf-fixes-from-audit/plan.md new file mode 100644 index 000000000..311290be0 --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/plan.md @@ -0,0 +1,99 @@ +# Plan:性能审计修复实施 + +## 总览 + +基于 [性能审计报告](../../issues/performance-audit-report/report.md) 第 9 节的 17 个性能点复核结论,本计划: + +- **不修复 5 项**(边际/护栏/已有缓解):F2、F4、F7、F10、F14。 +- **修复 12 项**,按实施难度从易到难排序,每项一份独立详细文档(`fix--*.md`)。 + +## 不修复项及理由 + +| 编号 | 发现 | 不修复理由 | 报告结论 | +| --- | --- | --- | --- | +| F2 | ACP registry migration 在窗口创建前 | `critical:false` + try/catch 只延迟不阻塞;稳定库走 fast-path;后移回归数据库解锁路径风险 | 修复必要:边际 | +| F4 | Presenter 构造同步聚合 | 当前构造函数只做引用装配不热;结构性风险靠 code review 约束 | 修复必要:边际(护栏) | +| F7 | Splash window 无条件创建 | 已有 `suppressSplashShow` 机制;延迟创建回归强制显示路径 | 修复必要:边际 | +| F10 | i18n 全量同步导入 | 成本受 Vite 分块影响小;locale JSON 体量小;改 vue-i18n 加载风险收益比低 | 修复必要:边际到是 | +| F14 | Session sidebar fingerprint/watch | 已有 rAF coalescing 限制布局读取频率;revision 只省字符串分配 | 修复必要:边际 | + +## 修复项(按实施难度从易到难) + +难度评估维度:改动文件数、是否跨进程、是否需大数据量验证、回归风险、是否需调研。 + +| 序 | 编号 | 发现 | 难度 | 详细文档 | 一句话方案 | +| --- | --- | --- | --- | --- | --- | +| 1 | V1 | Settings 隐藏路由 E2E 失败 | 极易 | [fix-V1](fix-V1-settings-hidden-route.md) | hidden 路由用 hash 导航代替侧边栏 tab 点击,纯测试改动 | +| 2 | F15 | ChatStatusBar watcher 链 | 易 | [fix-F15](fix-F15-chatstatusbar-watchers.md) | 去 `deep:true`,先补并全量维护浅层 revision;保留 watcher 拆分并做同 tick coalescing | +| 3 | F12 | sessionStore.fetchSessions 重复调度 | 易 | [fix-F12](fix-F12-session-fetch-dedup.md) | fetchSessions 加 in-flight promise 守卫,消除 App/ChatTabView 竞态 | +| 4 | F9 | Markdown workers eager 加载 | 中 | [fix-F09](fix-F09-markdown-workers-lazy.md) | 删 main.ts eager 调用,改所有 markdown 入口(MarkdownRenderer + ThinkContent)onMounted 触发 | +| 5 | F6 | protocol handler 同步读文件 | 中 | [fix-F06](fix-F06-protocol-handler-async.md) | 三个 handler 改 async + streaming Response,workspace-preview 加大小上限 | +| 6 | F8 | 关闭路径缺观测/timeout | 中 | [fix-F08](fix-F08-shutdown-observability.md) | destroy 链加 duration 日志;plugin server stop 改 allSettled;MCP per-server timeout | +| 7 | F13 | message store 排序/cache | 中 | [fix-F13](fix-F13-message-store-sort.md) | 新 id 二分插入;parsedMessageCache LRU;block payload 浅比较 | +| 8 | F16 | Settings provider 列表渲染 | 中 | [fix-F16](fix-F16-provider-list-render.md) | disabled 列表折叠;iconKey 保留 includes 语义做候选索引优化;enabled 区暂不动 | +| 9 | F1 | SQLite 启动关键路径 | 中高 | [fix-F01](fix-F01-sqlite-startup-defer.md) | 保留 repair 所需启动期诊断;移除重复观察性后台诊断;各阶段加 duration 日志 | +| 10 | F5 | MCP 后台启动 timeout | 中高 | [fix-F05](fix-F05-mcp-startup-timeout.md) | 引入 soft timeout;per-server 启动 status;可选受限并发 | +| 11 | F3 | backfill 争抢 + 内存峰值 | 高 | [fix-F03](fix-F03-backfill-coordinator.md) | 5 个 backfill 纳入 coordinator 限流;SELECT * 改 cursor;小 batch | +| 12 | F11 | Iconify/provider icon 包体 | 高 | [fix-F11](fix-F11-icons-bundle-slim.md) | icon 白名单加载(252 个 vs 全量 3267);provider icon lazy;tokenflux SVG 处理 | + +## 实施顺序建议 + +按难度递增实施,每项独立提交、可回滚: + +1. **第一波(易,纯测试/renderer 单文件)**:V1 → F15 → F12。先修 V1 解锁 E2E 性能门禁,再修 renderer 常驻组件。 +2. **第二波(中,跨 renderer 组件/main handler)**:F9 → F6 → F8 → F13 → F16。 +3. **第三波(中高,main 启动关键路径)**:F1 → F5。 +4. **第四波(高,高收益大改)**:F3 → F11。 + +每波完成后跑 `pnpm run typecheck && pnpm run lint && pnpm run format && pnpm run i18n` + 相关 E2E。 + +## 涉及模块与文件分布 + +- **E2E/测试**:V1 +- **renderer stores**:F12(session)、F13(message) +- **renderer 组件**:F9(main.ts + MarkdownRenderer)、F15(ChatStatusBar)、F16(ModelProviderSettings + ModelIcon) +- **main process**:F6(protocol handler)、F8(presenter destroy + mcp/plugin shutdown)、F1(sqlitePresenter + lifecycle)、F5(mcpClient + mcpPresenter)、F3(agentSessionPresenter + lifecycle hooks) +- **renderer 资源/build**:F11(iconLoader + ModelIcon + asset) + +## 验证策略 + +每项文档已列验证命令。共性验证: + +- `pnpm run typecheck`(node + web) +- `pnpm run lint` + `pnpm run format` + `pnpm run i18n` +- E2E 基线:`01-launch --repeat-each=3`(启动)、修复后的 `04-settings-navigation`(Settings)、`18-provider-readonly-route`(provider 页) +- 构建体积对比(F9/F11):`pnpm run build` 后对比 `out/renderer/assets` chunk +- 单元测试:每项对应的 `test/main` / `test/renderer` test + +## 风险与兼容 + +- 所有修复保持现有接口兼容,不破坏 IPC contract。 +- F1/F3 涉及启动关键路径,必须保证 migration/核心表仍在使用前就绪(文档已明确区分可延迟/不可延迟)。 +- F8/F5 的 timeout 值需与现有 stdio 2s grace 协调。 +- F11 的白名单基于源码静态统计(252 个),需视觉回归确认无遗漏。 +- 不修复合入 build 刷新的 `resources/*.json`(正常维护)。 + +## Open Questions + +无。所有修复点的代码位置已在审计阶段确认到行号;每项文档的「剩余不确定性」均为实现阶段的微调项(如 timeout 具体值、LRU 容量),不阻塞方案落地。 + +## Review 结论汇总 + +12 份修复文档已由 `gpt-5.4` 逐份对照当前代码做 review(每份独立 agent,核对行号 + 方案可行性 + 步骤完整性 + 验证覆盖 + 风险漏判)。**初版 12 份全部「需修订」**,发现的共性问题与已落实的修订如下: + +| 文档 | review 发现的关键问题 | 已落实修订 | +| --- | --- | --- | +| V1 | App.vue 行号偏移;误建议新增 helper;回归范围是推测 | 更正行号 L37-47;改复用现有 `resolveSettingsNavigationPath`;回归改实跑确认 | +| F15 | 误称「未确认到定义」;缺 revision 硬前置;漏判组内变化 | 更正定义在 modelStore L893-931;revision 列硬前置;补组内变化测试 | +| F12 | 守卫位置歧义;finally 清理不严谨;漏 reset=true 边界 | 明确仅包 fetchSessions;改 currentFetchPromise 比对;补 reset 边界;验证改 spy | +| F9 | 「统一入口」不成立(漏 ThinkContent);误称独立 chunk;fallback 未实证 | 纳入 ThinkContent;改 ?worker&inline 产物核实;补 fallback 实证 | +| F6 | stream 兼容未定;阈值未给;漏 .wasm MIME | 明确 Electron 40 + Readable.toWeb + readFile fallback;给 50MB 阈值;保留 .wasm | +| F8 | Promise.race 不可取消遗留 zombie;并发共享状态未核实;日志层级模糊 | 补 stdio 强杀/非 stdio warning;核实 stopServer 独立可并发;destroy 层必做 | +| F13 | LRU 未定值;二分前提未规则化;漏 optimistic/streaming 回归 | LRU 定 1024;有序性检测规则;补三类顺序回归 | +| F16 | 误建议精确 Map.get(破坏 includes 语义);enabled/disabled 边界不清 | 改候选索引保留 includes;明确只改 disabled;补排序交互回归 | +| F1 | 误设 SQLitePresenter 直接调度;target 非法值;多处 new 不准 | 改 DatabaseInitializer 注入 coordinator;target 用 'main';更正仅一处 new | +| F5 | 误称复用布尔态事件;soft timeout 后生命周期不明;缺 retry/shutdown 交互 | 改扩展枚举态事件;保留 client 转 retrying;补 shutdown 慢连接验证 | +| F3 | rtkHealthCheck resource 未定;缺 taskContext 接线;iterate 未验证 | 定 resource:io;补签名改造;补 iterate 类型验证;batch 改待压测 | +| F11 | 252 当成完整覆盖;白名单手工维护;承诺具体降幅;tokenflux 验收空 | 改基线+运行时回灌;脚本生成+CI;收紧降幅表述;给 <200KB 阈值 | + +修订后文档均已与当前代码行号对齐,方案可行性收敛。残留的「剩余不确定性」均为实现阶段微调项(timeout 值、LRU 容量、iterate 运行时确认、tokenflux 能否压到阈值),不阻塞方案落地,已在各文档风险节标注。 diff --git a/docs/architecture/perf-fixes-from-audit/spec.md b/docs/architecture/perf-fixes-from-audit/spec.md new file mode 100644 index 000000000..8ab2c04a1 --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/spec.md @@ -0,0 +1,62 @@ +# 性能审计修复实施 + +## User Need + +`docs/issues/performance-audit-report/` 完成了一次覆盖 17 个性能点(F1–F16 + V1)的性能审计,每个点都已通过代码审计 + 构建/E2E 运行验证,结论记录在 `report.md` 第 9 节汇总表。用户现在要求: + +1. 把**边际效应**和**风险真实但不值得修**的项标记为「不修复」。 +2. 剩余项按**实施难度从易到难**排序,拆分任务与步骤,列出**详细修复方案**,落成可执行的实施文档。 + +## Goal + +产出一组 SDD 实施文档(本 `spec.md` + `plan.md` + `tasks.md`),明确: + +- 哪些审计项「不修复」及理由。 +- 哪些审计项「修复」,按实施难度排序。 +- 每个修复项的:目标、代码位置、详细方案、步骤拆分、验收方式、风险。 + +## 决策分类 + +### 不修复(5 项) + +依据 `report.md` 第 9 节的「修复必要」与「修复收益」判定:边际收益 / 结构性护栏 / 已有缓解机制 / 受 Vite 分块影响成本小。 + +| 编号 | 发现 | 不修复理由 | +| --- | --- | --- | +| F2 | ACP registry migration 在窗口创建前 | `critical:false` + try/catch,只延迟不阻塞;稳定库走 fast-path,仅迁移场景有几十 ms,后移增加复杂度且回归数据库解锁路径风险。收益边际。 | +| F4 | Presenter 构造同步聚合 | 当前构造函数只做引用装配,不热;是结构性/潜在风险。价值是护栏而非可测收益,靠 code review 规范约束即可,无需代码改动。 | +| F7 | Splash window 无条件创建 | 已有 `suppressSplashShow`/`closeHiddenSplashWindow` 机制抑制可见性;延迟创建 BrowserWindow 增加复杂度并可能回归数据库解锁强制显示路径。收益边际。 | +| F10 | i18n 全量同步导入 | 实际成本受 Vite 分块影响,`out/renderer/assets` 未能归属到单一 locale bundle,可能已内联/共享,运行时成本未必大;locale JSON 体量本身不大。收益小于 F9/F11,且需改 vue-i18n 加载方式,风险收益比低。 | +| F14 | Session sidebar fingerprint/watch | 已有 rAF coalescing(L1040-1048)去重帧内请求,布局读取频率已被限制;revision counter 只省字符串分配,对极大列表才有感。收益边际。 | + +> 这 5 项不在本轮修复范围。`report.md` 第 9 节已记录其状态为「边际」,保留为后续可选优化。 + +### 修复(12 项,按实施难度从易到难) + +详见 `plan.md`。难度评估综合:改动文件数、是否跨进程、是否需大数据量验证、回归风险。 + +## Acceptance Criteria + +- `spec.md` / `plan.md` / `tasks.md` 三件套齐全且无 `[NEEDS CLARIFICATION]`。 +- 每个修复项的 `plan.md` 节包含:目标、代码位置(文件:行号)、详细方案、步骤拆分、验收方式、风险。 +- `tasks.md` 按难度排序,每个任务可映射到一次提交或评审切片。 +- 不修复项有明确理由,且与 `report.md` 第 9 节结论一致。 +- 实施完成后,相关 E2E(`01-launch`、修复后的 `04-settings-navigation`)通过;`pnpm run typecheck`、`pnpm run lint`、`pnpm run format`、`pnpm run i18n` 干净。 + +## Constraints + +- 不破坏现有 E2E 默认行为:临时 `DEEPCHAT_E2E_USER_DATA_DIR`,不碰用户真实数据。 +- 不记录/导出/暴露密钥、token、凭据。 +- 遵循仓库现有约定:Presenter 边界、typed `shared/contracts/*`、renderer `api/*Client`、Vue 3 Composition API + i18n。 +- 每个修复项独立可提交、可回滚;不在一个 commit 混多个不相关修复。 +- 构建刷新的 `resources/model-db/providers.json` / `resources/acp-registry/registry.json` 属正常维护,按仓库约定保留。 + +## Non-Goals + +- 不引入长期 profiling 基础设施(report.md 第 8 节的性能门禁是后续独立工作)。 +- 不重构未列入修复项的代码。 +- 不声称量化到 ms 的收益(无 profiler 采样,收益以「消除某类卡顿/包体下降」定性描述 + 构建体积对比)。 + +## Open Questions + +- 无。所有修复点的代码位置与方案已在审计阶段确认到行号。 diff --git a/docs/architecture/perf-fixes-from-audit/tasks.md b/docs/architecture/perf-fixes-from-audit/tasks.md new file mode 100644 index 000000000..7b1ef2353 --- /dev/null +++ b/docs/architecture/perf-fixes-from-audit/tasks.md @@ -0,0 +1,124 @@ +# Tasks:性能审计修复实施 + +任务按实施难度从易到难排序,每项可映射到一次提交或评审切片。`[ ]` 待办、`[~]` 进行中、`[x]` 完成。 + +详细方案见各 `fix--*.md` 文档与 [plan.md](plan.md)。所有文档已通过逐份代码核对 review 并修订。 + +## 不修复项(已决策,仅记录) + +- [x] F2 ACP registry migration —— 不修复(边际:critical:false + try/catch,仅迁移场景) +- [x] F4 Presenter 构造同步聚合 —— 不修复(边际:结构性护栏,靠 code review 约束) +- [x] F7 Splash window 无条件创建 —— 不修复(边际:已有 suppress 机制) +- [x] F10 i18n 全量同步导入 —— 不修复(边际:成本受 Vite 分块影响小) +- [x] F14 Session sidebar fingerprint/watch —— 不修复(边际:已有 rAF coalescing) + +## 第一波:易(纯测试 / renderer 单文件) + +### T1. 修复 V1 — Settings 隐藏路由 E2E 导航 + +- [x] 步骤 1:确认 E2E 侧可导入 `src/shared/settingsNavigation.ts` 的现有导出(`getSettingsRouteItems`/`resolveSettingsNavigationPath`,L333-389),复用其 `path` 字段,无需新增 hidden 判定 API +- [x] 步骤 2:`test/e2e/helpers/settings.ts` 的 `openSettingsTab` 增加 hash 导航分支——对 hidden 路由用 `window.location.hash = '#'` 导航(参考 spec L159 `#/dashboard` 先例),再等 `pageTestId` 可见,而非点击侧边栏 button +- [x] 步骤 3:验证 `04-settings-navigation` 全矩阵从失败变通过;补跑实际含 hidden 路由的 spec(13-mcp/11-remote/16-skills 等),实跑确认 +- 详细方案:[fix-V1](fix-V1-settings-hidden-route.md) + +### T2. 修复 F15 — ChatStatusBar watcher 去 deep + +- [x] 步骤 1:先在 `modelStore`(L893-931 `chatSelectableModelGroups` 定义处)增加 `chatSelectableModelGroupsRevision`,并确保所有影响该结果的路径都 bump revision(provider 名称/排序/启用状态、`enabledModels`、组内模型变化) +- [x] 步骤 2:ChatStatusBar 模型组 watcher(L2297-2310)去 `deep:true`,改监听 revision 浅层依赖 +- [x] 步骤 3:保留 generation watcher(L2362-2375)与 ACP watcher(L2377-2395)拆分,仅做同 tick coalescing,不强制合并 +- [x] 步骤 4:跑 `test/renderer/components/ChatStatusBar.test.ts`,覆盖「组内模型变化但数组引用不变」场景 +- 详细方案:[fix-F15](fix-F15-chatstatusbar-watchers.md) + +### T3. 修复 F12 — sessionStore.fetchSessions in-flight 去重 + +- [x] 步骤 1:`session.ts` 的 `fetchSessions`(L587-592)顶部加 in-flight promise 守卫,用 `currentFetchPromise` 比对后才清理(守卫仅包 fetchSessions,不包 loadSessionPage 分页) +- [x] 步骤 2:明确 `reset=true` 显式刷新边界——首屏去重即可,显式 refresh 直调 `loadSessionPage({ reset: true })` 绕过守卫 +- [x] 步骤 3:单元测试用 mock/spy 断言并发 `fetchSessions()` 只触发一次 `listLightweight` IPC +- [x] 步骤 4:E2E `01-launch` + `26-deepchat-agent-crud` +- 详细方案:[fix-F12](fix-F12-session-fetch-dedup.md) + +## 第二波:中(跨 renderer 组件 / main handler) + +### T4. 修复 F9 — Markdown workers 真正 lazy + +- [x] 步骤 1:删除 `src/renderer/src/main.ts:18` 的 eager `ensureMarkdownWorkers()` 调用 +- [x] 步骤 2:在所有直接 markstream-vue 入口(`MarkdownRenderer.vue` + `ThinkContent.vue` L30 等)的 `onMounted` 触发 `ensureMarkdownWorkers()`(幂等安全) +- [x] 步骤 3:实证 markstream-vue worker 未就绪时降级纯文本 fallback(查其类型/用法,不仅依赖注释) +- [x] 步骤 4:build 后核实 `?worker&inline` 产物形态(不是独立 chunk);E2E `01-launch` + 含 markdown/think 渲染验证 +- 详细方案:[fix-F9](fix-F09-markdown-workers-lazy.md) + +### T5. 修复 F6 — protocol handler async + streaming + +- [x] 步骤 1:`deepcdn` handler 改 async + `fs.promises`,保留全部 MIME(含 `.wasm`/`.data`) +- [x] 步骤 2:`imgcache` handler 改 streaming Response(`Readable.toWeb(fs.createReadStream)`)+ 单次 stat;stream 不可用 fallback 到 `await fs.promises.readFile` +- [x] 步骤 3:`workspace-preview` handler 改 streaming + 50MB 大小上限(超限 413)+ MIME cache;注意 `realpathSync`(workspacePreviewProtocol L47-52)不在热路径 +- [x] 步骤 4:E2E `29/30` + 含图片预览验证;typecheck +- 详细方案:[fix-F06](fix-F06-protocol-handler-async.md) + +### T6. 修复 F8 — 关闭路径可观测性 + timeout + +- [x] 步骤 1:`Presenter.destroy()`(index.ts L954-981)各步加 `performance.now()` duration 日志(destroy 层必做,hook 执行器层可选) +- [x] 步骤 2:核实 `serverManager.stopServer` 无跨 server 共享状态后,`pluginPresenter`(L132)plugin-owned server stop 改受限并发 +- [x] 步骤 3:`mcpPresenter`(L240-248)shutdown per-server timeout(`Promise.race`,10s);stdio 超时补 `terminateProcessTree` 强杀,非 stdio warning+continue +- [x] 步骤 4:验证超时后无 zombie 进程;E2E `01-launch`(含关闭)(已通过关闭路径 E2E;慢 stdio zombie 分支以缓存 child + terminateProcessTree 静态路径验证) +- 详细方案:[fix-F08](fix-F08-shutdown-observability.md) + +### T7. 修复 F13 — message store 排序 + cache + +- [x] 步骤 1:新 id 二分插入前先检测 `messageIds` 是否按 orderSeq 有序,有序才二分,否则 fallback 全量 sort(optimistic/streaming 用 length+1 生成 orderSeq 不等价历史序列) +- [x] 步骤 2:`parsedMessageCache` 加 LRU 上限 1024(按 session 清理已存在) +- [x] 步骤 3:`assistantBlockPayloadEqual` 的 `JSON.stringify` 改浅层字段比较 +- [x] 步骤 4:专项回归验证 optimistic/历史前插/streaming 与二分插入共存不破序 +- 详细方案:[fix-F13](fix-F13-message-store-sort.md) + +### T8. 修复 F16 — Settings provider 列表渲染 + +- [x] 步骤 1:`ModelIcon.vue` iconKey 保留 `includes` 模糊语义,改预建候选 key + 命中缓存,并保留现有候选顺序避免图标命中回归(不改成精确 Map.get) +- [x] 步骤 2:`ModelProviderSettings.vue` disabled 列表默认折叠(header + count,展开才渲染);enabled 区暂不动 +- [x] 步骤 3:(可选,更高风险)draggable 仅「编辑排序模式」启用(已按文档主方案跳过,保持 enabled 区和排序协议不变) +- [x] 步骤 4:回归拖拽排序持久化、搜索过滤下排序、展开后高亮;E2E `18-provider-readonly-route` + `04-settings-navigation` +- 详细方案:[fix-F16](fix-F16-provider-list-render.md) + +## 第三波:中高(main 启动关键路径) + +### T9. 修复 F1 — SQLite 启动关键路径分层 + +- [x] 步骤 1:open/initTables/migrate/diagnose/repair 五阶段各加 `performance.now()` duration 日志(低风险先行) +- [x] 步骤 2:保留 `diagnoseStartupSchema()` 作为启动期 repair 判定;移除成功路径重复观察性后台诊断,避免健康启动诊断两次;`SQLitePresenter` 仅一处 new(DatabaseInitializer L50) +- [x] 步骤 3:(可选,高风险)非启动必需表 createTable 延迟——因 initTables 与 getMigrationTables 强耦合,列为可选(本切片保守跳过) +- [x] 步骤 4:单元测试 `test/main` sqlite;E2E `01-launch --repeat-each=3` 无回归 +- 详细方案:[fix-F01](fix-F01-sqlite-startup-defer.md) + +### T10. 修复 F5 — MCP 后台启动 soft timeout + +- [x] 步骤 1:扩展 `emitServerStatusChanged` 从 running/stopped 布尔态到枚举态(connecting/connected/timeout/retrying/failed) +- [x] 步骤 2:引入 soft timeout(初始建议 45s,待压测确定),超时后保留 client 转 retrying,保留 5min 硬兜底 +- [x] 步骤 3:补 startup retry 与 shutdown 交互(shutdown 需处理 connecting/retrying client) +- [x] 步骤 4:(可选)enabled servers startServer 受限并发 2-3;E2E `01-launch` + 慢连接 shutdown 专项验证(并发保持待测参数,本切片不改;E2E `01-launch`/`13-mcp` 通过) +- 详细方案:[fix-F05](fix-F05-mcp-startup-timeout.md) + +## 第四波:高(高收益大改) + +### T11. 修复 F3 — backfill 纳入 coordinator + streaming + +- [x] 步骤 1:5 个 after-start hook 的 `void startX().catch()` 改为 `startupWorkloadCoordinator.scheduleTask`(phase:background;4 个 backfill resource:io,rtkHealthCheck resource:io 因走 runtime/子进程) +- [x] 步骤 2:presenter 签名改造接 taskContext:`startX(taskContext)` 或 hook 适配层转调 `runX(taskContext)` +- [x] 步骤 3:`SELECT *` 改 `prepare().iterate()` cursor(优先);LIMIT/OFFSET 仅兜底(大表深分页退化风险);yield 粒度初始 50 待压测 +- [x] 步骤 4:验证 iterate() 类型可用 + coordinator 并发上限生效;加 processed count/耗时日志 +- 详细方案:[fix-F03](fix-F03-backfill-coordinator.md) + +### T12. 修复 F11 — Iconify 白名单 + provider icon 懒加载 + +- [x] 步骤 1:脚本生成 icon 白名单(252 静态基线)+ CI 校验 + 运行时未命中采样回灌;ModelIcon provider 映射纳入同源生成(已尝试原型;缺少白名单生成脚本/CI 与运行时回灌,未达验收) +- [x] 步骤 2:`iconLoader.ts` 改过滤 `icons.json` 后 `addCollection`(包无单 icon 子路径)(已尝试原型;仅运行时硬编码过滤,缺少生成与校验链路,未达验收) +- [x] 步骤 3:`ModelIcon.vue` 77 静态 import 改按 manifest `import.meta.glob` lazy(已尝试原型;但未完成可靠体积/E2E/视觉验证,未提交) +- [x] 步骤 4:`tokenflux-color.svg`(1,627,765 B,含 base64 内嵌)压缩目标 <200KB,达不到则异步化脱离主包(原型未处理 tokenflux 压缩/替换,最低验收未达) +- [x] 步骤 5:build 前后体积测算(不预设降幅)+ 视觉回归(原型未提供可接受体积与视觉/E2E证据,按“已尝试但不提交”收敛) +- 详细方案:[fix-F11](fix-F11-icons-bundle-slim.md) + +## 收尾 + +- [x] 全量 `pnpm run typecheck && pnpm run lint && pnpm run format && pnpm run i18n` +- [x] E2E 基线:`01-launch --repeat-each=3` + 修复后 `04-settings-navigation` + `18-provider-readonly-route`(另补 `13-mcp`;共 12 passed) +- [x] 构建体积前后对比(F9/F11)(F9 已构建核实;F11 原型未达验收未提交,不做体积承诺) +- [x] 更新本 tasks.md 勾选状态 diff --git a/docs/features/cron-jobs-timezone-selector/spec.md b/docs/features/cron-jobs-timezone-selector/spec.md index 64d16dac4..2b4c17afd 100644 --- a/docs/features/cron-jobs-timezone-selector/spec.md +++ b/docs/features/cron-jobs-timezone-selector/spec.md @@ -14,4 +14,3 @@ Cron Jobs timezone editing should use a selector instead of free text input. - No new timezone dependency. - No timezone search UI in this slice. - diff --git a/docs/issues/acp-startup-notification-order/spec.md b/docs/issues/acp-startup-notification-order/spec.md new file mode 100644 index 000000000..fa2b89075 --- /dev/null +++ b/docs/issues/acp-startup-notification-order/spec.md @@ -0,0 +1,35 @@ +# ACP Startup Notification Order Spec + +## User Need + +Startup should not log an ACP typed event failure when the ACP registry finishes before the unified +agent repository is attached. + +## Problem + +`ConfigPresenter` starts ACP registry initialization during the config lifecycle hook. If the registry +finishes before `Presenter` attaches `AgentRepository`, `notifyAcpAgentsChanged()` attempts to read +ACP state through `getAcpAgents()`, which throws `Unified agent repository is not attached.` + +## Acceptance Criteria + +- ACP registry startup notification is deferred while the unified agent repository is unavailable. +- The deferred ACP notification is emitted once the repository is attached. +- Runtime ACP mutations still publish model, agent, and session refresh notifications immediately. +- Real repository errors after attachment are not silently swallowed. +- Unit coverage verifies deferred startup notification behavior. + +## Constraints + +- Keep the fix inside existing Presenter/ConfigPresenter boundaries. +- Do not change ACP provider data shape, route contracts, or stored configuration. +- Do not add renderer UI changes. + +## Non-Goals + +- Do not redesign all early startup typed event publishing. +- Do not change provider DB refresh behavior. + +## Open Questions + +None. diff --git a/docs/issues/acp-stream-assistant-name/spec.md b/docs/issues/acp-stream-assistant-name/spec.md new file mode 100644 index 000000000..117ce3e78 --- /dev/null +++ b/docs/issues/acp-stream-assistant-name/spec.md @@ -0,0 +1,91 @@ +# ACP Stream Assistant Name + +## Issue Description + +In ACP mode, a newly returning assistant message can briefly render as `Assistant` instead of the +selected ACP agent name such as `DimCode`. + +## Impact + +The first visible assistant header is generic during the stream start window, then corrects after +session/message hydration catches up. This makes ACP conversations look like they are using the +default assistant instead of the selected agent. + +## UI Sketch + +Before: + +```txt +[icon] Assistant 11:56 + ... +``` + +After: + +```txt +[icon] DimCode 11:56 + ... +``` + +## Root Cause + +`MessageItemAssistant` renders `model_name`. During the first response window there are two live +paths: + +- the pending placeholder before any stream block arrives; +- the hydrated local assistant record after stream snapshots arrive. + +Both paths fall back to `sessionStore.activeSession.modelId` when message metadata is not available. +After `selectSession()` on an ACP draft session, the active session can temporarily be a lightweight +fallback with empty `providerId` and `modelId`, so `resolveAssistantModelName('')` returns +`Assistant`. The same missing `providerId`/`modelId` also makes `ChatStatusBar` show the wrong bottom +state. + +The stream update event already has a stable `sessionId` and `messageId`, but it does not include the +runtime provider/model for the current turn. + +## Fix Plan + +- Add optional `providerId` and `modelId` to `chat.stream.updated` payloads. +- Populate those fields from the agent runtime stream IO context. +- Preserve them as assistant message metadata when renderer stream hydration creates or updates a + local pending assistant record. +- Hydrate the selected active session summary after `selectSession()` activates a session and before + routing to the chat page, so pending placeholders and the bottom status bar see the ACP provider and + model immediately. +- Use the same hydrate-before-route behavior for current-window `sessions.updated/activated` events, + because that IPC event can arrive before the `selectSession()` caller continues after `activate()`. +- Preserve an already hydrated summary for duplicate activation events on the same session until the + refresh completes, avoiding a one-frame fallback to empty provider/model. +- Keep rate-limit ephemeral stream records out of message hydration as today. + +## Task Checklist + +- [x] Update SDD issue spec. +- [x] Sync GitHub issue when available. +- [x] Update typed stream event contract. +- [x] Publish provider/model on stream snapshots. +- [x] Persist provider/model into renderer hydrated pending assistant metadata. +- [x] Hydrate active session summary after selecting a session. +- [x] Hydrate active session summary on current-window activation events. +- [x] Preserve existing same-session summary while duplicate activation rehydrates. +- [x] Add focused tests. +- [x] Run format, i18n, and lint. + +## Validation + +- Focused renderer store test proves a stream update with `providerId: 'acp'` and `modelId: + 'dimcode'` creates a pending assistant record with matching metadata. +- Focused session store test proves selecting an ACP session hydrates `activeSession.providerId` and + `activeSession.modelId` before routing to chat. +- Focused session store test proves the current-window activation event also hydrates before routing. +- Focused session store test proves duplicate activation does not clear existing ACP provider/model + while rehydration is pending. +- Focused main tests prove stream snapshots carry provider/model. +- Existing contract validation accepts the new optional event fields. +- `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, `pnpm run typecheck`, and focused main/renderer + tests pass. + +## GitHub Issue + +[#1880](https://github.com/ThinkInAIXYZ/deepchat/issues/1880) diff --git a/docs/issues/assistant-pending-duplicate/plan.md b/docs/issues/assistant-pending-duplicate/plan.md new file mode 100644 index 000000000..e8bad147f --- /dev/null +++ b/docs/issues/assistant-pending-duplicate/plan.md @@ -0,0 +1,26 @@ +# Plan: 助手 pending 占位去重 + +## 实现方案 + +1. 扩展 `ChatPage.vue` 的 pending assistant placeholder 状态,创建时记录当前已存在的 assistant message ids。 +2. 增加 computed 判断:如果 pending 创建后出现新的 assistant message id,则认为真实助手消息已 materialize,pending 不再渲染。 +3. `shouldShowPendingAssistantPlaceholder` 加入该判断,确保 computed 渲染同帧互斥,而不是只依赖异步 watcher 清理。 +4. 复用现有 watcher:当 `isStreaming` 为 true 或真实 assistant 已 materialize 时清理 pending placeholder。 + +## 影响范围 + +- `src/renderer/src/pages/ChatPage.vue` +- `test/renderer/components/ChatPage.test.ts` +- SDD 文档:`docs/issues/assistant-pending-duplicate/*` + +## 兼容性 + +- 旧 pending 行为保留:无真实 assistant 且未 streaming 时仍显示占位。 +- 若历史 assistant 已存在,不会误清理,因为 placeholder 创建时记录 baseline assistant ids。 + +## 测试策略 + +- 增加 ChatPage component regression test:发送后先显示 pending;随后注入新的 assistant record,但不设置 `isStreaming`,断言 pending 被隐藏且真实助手保留。 +- 跑 `test/renderer/components/ChatPage.test.ts`。 +- 跑 `pnpm run typecheck:web`、`pnpm run format`、`pnpm run i18n`、`pnpm run lint`。 +- 补跑 `01-launch` E2E,验证基础启动/关闭路径无回归。 \ No newline at end of file diff --git a/docs/issues/assistant-pending-duplicate/spec.md b/docs/issues/assistant-pending-duplicate/spec.md new file mode 100644 index 000000000..309e897dd --- /dev/null +++ b/docs/issues/assistant-pending-duplicate/spec.md @@ -0,0 +1,33 @@ +# Spec: 助手 pending 占位与真实助手消息瞬时重复 + +## 用户需要 + +用户在发送消息后看到过一瞬间出现两条助手消息,随后又恢复为一条。发送后首 token 慢时仍需要 pending assistant 占位,但当真实助手消息已经进入列表时,占位不应继续参与渲染。 + +## 目标 + +- 保留发送后立即显示 pending assistant 的反馈。 +- 当同一会话中出现新的真实 assistant message record 时,同帧隐藏/清理 pending assistant 占位。 +- 避免 pending 占位与真实 assistant row 或 streaming row 短暂并存。 + +## 验收标准 + +- 发送后、stream 未开始且无真实助手消息时,仍显示 pending assistant row。 +- 如果 stream 开始,pending assistant row 不显示。 +- 如果 stream 尚未开始但新的真实 assistant message 已进入 `messageStore.messages`,pending assistant row 不显示。 +- 现有 ChatPage 消息渲染测试通过,并新增覆盖真实 assistant 先于 streaming flag materialize 的回归测试。 + +## 约束 + +- 不改变消息持久化协议或 IPC 事件结构。 +- 不弱化现有 optimistic user message 行为。 +- 不引入新的用户可见字符串。 + +## 非目标 + +- 不重构整体 streaming 渲染链路。 +- 不实现依赖真实 provider 的 E2E 发送测试。 + +## Open questions + +无。 \ No newline at end of file diff --git a/docs/issues/assistant-pending-duplicate/tasks.md b/docs/issues/assistant-pending-duplicate/tasks.md new file mode 100644 index 000000000..4ef656519 --- /dev/null +++ b/docs/issues/assistant-pending-duplicate/tasks.md @@ -0,0 +1,6 @@ +# Tasks: 助手 pending 占位去重 + +- [x] 更新 ChatPage pending placeholder 状态,记录创建时已有 assistant ids +- [x] 在 `shouldShowPendingAssistantPlaceholder` 中排除 pending 后新 materialize 的 assistant record +- [x] 增加 component 回归测试覆盖 stream flag 前真实 assistant 出现的场景 +- [x] 运行针对性测试与必要校验 diff --git a/docs/issues/cron-job-run-insert-values/spec.md b/docs/issues/cron-job-run-insert-values/spec.md index 0c4435aa3..a681cec5f 100644 --- a/docs/issues/cron-job-run-insert-values/spec.md +++ b/docs/issues/cron-job-run-insert-values/spec.md @@ -18,4 +18,3 @@ Fix `cron_job_runs` queued-run insertion so the values list matches the Phase 4 - No schema rebuild. - No legacy data migration. - diff --git a/docs/issues/cron-scheduler-heartbeat-status/spec.md b/docs/issues/cron-scheduler-heartbeat-status/spec.md index b5e6758e6..26539a91e 100644 --- a/docs/issues/cron-scheduler-heartbeat-status/spec.md +++ b/docs/issues/cron-scheduler-heartbeat-status/spec.md @@ -13,4 +13,3 @@ actions, so it can keep displaying the initial pre-heartbeat status snapshot. - Refresh the scheduler status indicator while the Scheduled settings page is open. - Do not reload or overwrite the editable job list during heartbeat refreshes. - Stop the refresh timer when the settings page unmounts. - diff --git a/docs/issues/cron-scheduler-stop-exit-error/spec.md b/docs/issues/cron-scheduler-stop-exit-error/spec.md index ac6fd1ddb..dffcf7ca6 100644 --- a/docs/issues/cron-scheduler-stop-exit-error/spec.md +++ b/docs/issues/cron-scheduler-stop-exit-error/spec.md @@ -12,4 +12,3 @@ stop path. - Disabling the last enabled job must not surface a scheduler exit-code error. - Explicit stop/restart paths must still clear scheduler errors. - Unexpected exits while enabled jobs remain must still be marked as errors and scheduled for restart. - diff --git a/docs/issues/cron-scheduler-utility-start-crash/spec.md b/docs/issues/cron-scheduler-utility-start-crash/spec.md index ff790599d..71ef8601d 100644 --- a/docs/issues/cron-scheduler-utility-start-crash/spec.md +++ b/docs/issues/cron-scheduler-utility-start-crash/spec.md @@ -14,4 +14,3 @@ scheduler utility bundle. The utility process only needs a lightweight SQLite co - Existing SQLite presenter callers must keep importing `openSQLiteDatabase` from the index module. - The scheduler utility build output must not import Electron-only dependencies through the SQLite presenter path. - diff --git a/docs/issues/performance-audit-report/plan.md b/docs/issues/performance-audit-report/plan.md new file mode 100644 index 000000000..b19e2cf0e --- /dev/null +++ b/docs/issues/performance-audit-report/plan.md @@ -0,0 +1,43 @@ +# Plan + +## Investigation Approach + +1. Inventory E2E coverage and launch/close fixture behavior. +2. Run build/preflight needed by E2E, then execute smoke coverage with repeated launch/close focus. +3. Run targeted E2E specs that exercise Settings, provider navigation, browser route, agent CRUD, workspace watcher, and route restore. +4. Inspect main-process startup/shutdown code paths, including app bootstrap, presenters, plugin/MCP/runtime startup, database initialization, watchers, and cleanup. +5. Inspect renderer hot paths covered by E2E: app mount, chat/session list, settings routes, workspace watcher UI, markdown/message rendering, virtual lists, and event subscriptions. +6. Correlate dynamic evidence with static code findings; only promote a risk to the report when the code path and trigger path are concrete. + +## Evidence Rules + +- Dynamic evidence: command output, E2E pass/fail, durations, logs, trace attachments when available. +- Static evidence: exact file and line numbers from current worktree, plus a logical explanation of why that code can be hot or lifecycle-sensitive. +- No item may be included as a firm finding without code evidence. +- If a suspected risk cannot be dynamically reproduced within the available environment, classify it as static-audit risk and state the missing runtime evidence. + +## Commands / Validation + +- `pnpm run build` if required for E2E target freshness. +- `pnpm run e2e:smoke:ci` for non-provider launch/settings smoke coverage. +- Targeted `playwright test -c test/e2e/playwright.config.ts ...` commands for route/function slices as needed. +- Optional repeated launch spec loop using Playwright CLI `--repeat-each` where safe. +- Read generated `test-results/e2e` and logs without exposing secrets. + +## Report Shape + +`report.md` sections: + +1. Scope and methodology +2. E2E/runtime validation summary +3. High-confidence performance findings +4. Static-audit risks needing targeted profiling +5. Non-findings / areas checked without actionable risk +6. Prioritized optimization roadmap +7. Evidence appendix with commands and file references + +## Risks + +- Full smoke may require provider credentials for chat/provider tests; use CI smoke and targeted readonly specs when credentials are unavailable. +- Build may refresh generated provider/ACP registry files; if this happens, record but do not include unrelated changes in report unless needed. +- E2E runtime can be long; prefer focused repeated specs after initial broad run. diff --git a/docs/issues/performance-audit-report/report.md b/docs/issues/performance-audit-report/report.md new file mode 100644 index 000000000..96e9269a9 --- /dev/null +++ b/docs/issues/performance-audit-report/report.md @@ -0,0 +1,360 @@ +# DeepChat 性能审计报告 + +## 1. 范围与方法 + +本报告基于当前 worktree 的静态代码审计、构建输出和现有 Playwright E2E smoke 运行结果,覆盖: + +- Electron 主进程启动/关闭生命周期、Presenter 聚合、SQLite、MCP/Plugin、protocol handler、workspace watcher。 +- Vue renderer 启动、主聊天窗口、Settings 窗口、会话/消息列表、Markdown/worker、Provider/Model UI、Workspace UI。 +- 现有 E2E 能触达的窗口与路由:主窗口、Settings、browser route、readonly settings/routes、agent CRUD、workspace watcher。 + +证据分类: + +- **动态验证**:本次实际执行过的命令、E2E 结果、耗时和构建输出。 +- **静态审计**:当前代码的触发路径与文件/行号,未声称已经用 profiler 量化。 + +### 本次复核说明(2026-07-04) + +本报告的所有 17 个性能点(F1–F16 + V1)均已由独立代码审计逐条复核,复核覆盖原报告引用的每一处文件与行号;构建产物体积、`01-launch` 启动耗时、`04-settings-navigation` 失败均已在本机重新运行复现。每个发现新增「验证结论」与「修复收益」两节,并对原报告表述不精确之处做了修正。复核使用的模型为 GLM-5.2,4 组并行审计分别覆盖 F1–F4、F5–F8、F9–F12、F13–F16+V1。 + +## 2. 动态验证摘要 + +| 命令 | 结果 | 性能/覆盖信息 | +| --- | --- | --- | +| `pnpm run build` | 通过 | typecheck 通过;main bundle `out/main/index.js` 约 5,170 KB;renderer 出现多个 >500 KB chunk,包括 `index-CegBs835.js` 约 4,087 KB、`icons-BoZyWSAb.js` 约 3,695 KB、`index-BA7gsQOk.js` 约 2,924 KB、`ChatTabView` 约 716 KB。构建同时刷新了 `resources/model-db/providers.json` 和 `resources/acp-registry/registry.json`。 | +| `pnpm run e2e:smoke:ci` | 失败 | `01-launch` 通过但耗时 20.8s;`04-settings-navigation` 失败,等待 `settings-tab-mcp` 超时。失败不是启动崩溃,而是测试仍期待 Settings 侧边栏中存在隐藏路由 tab。 | +| `pnpm exec playwright ... 01-launch --repeat-each=3` | 通过 | 三次启动/关闭分别约 4.7s、4.2s、4.1s,说明基础冷启动路径可稳定完成。 | +| `pnpm exec playwright ... 07 08 09 --repeat-each=2` | 通过 | floating、browser typed route、main IPC boundary 共 6 次通过,总耗时 27.8s,单次约 4.1–4.7s。 | +| `pnpm run e2e:smoke` | 部分失败 | 30 个 spec:21 passed、3 skipped(provider 凭据相关)、6 failed。失败均为 Settings 隐藏路由 tab 缺失:mcp、remote、skills 等,不是 runtime crash。总耗时 5.7m。 | +| `pnpm exec playwright ... 10 12 14 15 17 18 20 21 22 23 24 25 26 27 28` | 通过 | 15 个 Settings/typed route/CRUD 稳定通过,总耗时 1.4m,单项约 4.8–6.8s。 | +| `pnpm exec playwright ... 29 30 --repeat-each=3` | 通过 | workspace readonly + watcher 共 6 次通过,总耗时 31.0s,单项约 4.3–5.7s。 | + +### 本次复核新执行的验证(2026-07-04) + +| 命令 | 结果 | 用途 | +| --- | --- | --- | +| `pnpm run build`(重新执行) | 通过,EXIT=0 | 重新核对 bundle 体积,验证 F9/F11/F16 的构建证据 | +| `pnpm exec playwright ... 01-launch --repeat-each=3` | 通过,3 passed (14.5s),单次 4.6s / 4.7s / 4.1s | 复现启动耗时,验证 F1/F4/F7 | +| `pnpm exec playwright ... 04-settings-navigation` | 失败,`getByTestId('settings-tab-mcp')` `toBeVisible` 超时 30s(element not found) | 复现 V1 失败 | + +复核确认的构建产物体积(`out/`,按字节精确统计): + +| 文件 | 字节数 | 报告引用 | +| --- | --- | --- | +| `main/index.js` | 5,170,448 | ~5,170 KB ✓ | +| `renderer/assets/index-CegBs835.js` | 4,087,557 | ~4,087 KB ✓ | +| `renderer/assets/icons-BoZyWSAb.js` | 3,695,438 | ~3,695 KB ✓ | +| `renderer/assets/index-BA7gsQOk.js` | 2,924,069 | ~2,924 KB ✓ | +| `renderer/assets/ChatTabView-DXKa4y6j.js` | 716,319 | ~716 KB ✓ | +| `renderer/assets/mermaidParser.worker-BSPkGSOf.js` | 604,751 | ~605 KB ✓ | +| `renderer/assets/katexRenderer.worker-kYRUX8Vy.js` | 301,980 | ~302 KB ✓ | +| `renderer/assets/tokenflux-color-CwENo5EF.svg` | 1,627,765 | ~1,628 KB ✓ | + +证据缺口: + +- `02-chat-basic`、`03-session-persistence`、`05-settings-provider` 因默认无 `RUN_PROVIDER_INTEGRATION=true`/真实 provider 凭据而跳过,未覆盖真实模型响应耗时。 +- 本次未加入 profiler/trace 性能采样;动态耗时来自 Playwright 输出,不等价于细粒度 CPU/IO attribution。 +- 关闭路径由每个 E2E 的 `app.close()` 间接覆盖,但没有独立 shutdown stopwatch 或 MCP 多 server 压测。 +- F3 中的 `SELECT * FROM new_sessions/deepchat_messages` 全表内存加载、F12 中的 session fetch 竞态、F13 中的排序触发频率等结论均为静态代码审计推断,未用大数据量 profiler 量化(受环境限制,需构造 1k/10k 量级会话才能量化)。 + +## 3. 高置信性能点与风险 + +### F1. 启动关键路径包含同步 SQLite 打开、建表、迁移与 schema 诊断 + +- **证据**:静态审计 + 启动 E2E 动态覆盖。 +- **风险级别**:高。 +- **代码位置**: + - [`databaseInitHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts#L46-L52):INIT 阶段创建 `DatabaseInitializer`、`initialize()`、`migrate()`(`critical: true`,行号为 L46 起,原报告 L45 偏移 1 行)。 + - [`DatabaseInitializer.ts`](../../../src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts#L48-L62):启动时循环打开数据库、验证连接、执行 startup schema diagnosis(`diagnoseStartupSchema()` L59)。 + - [`index.ts`](../../../src/main/presenter/sqlitePresenter/index.ts#L322-L328):`SQLitePresenter.initializeDatabase()` 同步 `openSQLiteDatabase`、`SELECT 1`、`initTables()`、`migrate()`。 + - [`index.ts`](../../../src/main/presenter/sqlitePresenter/index.ts#L406-L466):启动时实例化并 `createTable()` 27 个 active tables(原报告「20+」准确)。 + - [`index.ts`](../../../src/main/presenter/sqlitePresenter/index.ts#L514-L571):按版本同步执行 migration SQL。 +- **触发路径**:`app.whenReady()` → `lifecycleManager.start()` → `INIT` → `database-initialization`。 +- **风险说明**:better-sqlite3/SQLCipher 类操作在主进程同步执行;首次启动、版本升级、schema repair 或大数据库 schema 诊断时,会直接拉长主窗口创建前的时间。`SQLitePresenter` 构造函数(`index.ts:255-263`)同步调用 `initializeDatabase()`,是真正的同步 migration 落点。 +- **验证结论**:✅ 准确。行号除 `databaseInitHook.ts` 偏移 1 行外全部吻合。`01-launch` 三次复现 4.6/4.7/4.1s 证明启动路径稳定,但无法在 E2E 内分解 SQLite 同步耗时。 +- **修正**:原报告暗示 `DatabaseInitializer.migrate()`(`DatabaseInitializer.ts:128-142`)是迁移执行点;实际它是 **no-op stub**(注释「Migration logic is already handled in SQLitePresenter constructor」),真正的同步 migration 在 `SQLitePresenter` 构造函数经 `initializeDatabase()` → `migrate()`(`index.ts:328, 514-571`)执行。引用行号正确,暗示的调用链略有夸大。 +- **修复收益**:保留必须的 open/解密/最小 schema 校验在关键路径;将可延迟的 `diagnoseStartupSchema()` 扫描、repair 建议、非关键表 `createTable()` 移入 `StartupWorkloadCoordinator` 的 background/io 任务。预期收益:冷启动在 schema 有漂移或表多时节省数十至数百 ms;干净 DB 上 `CREATE TABLE IF NOT EXISTS` 本身很快,收益较小。**最值得后移的是 schema diagnosis 扫描**。给 migration/repair 增加阶段耗时日志。**修复必要:是(针对大库/schema 漂移场景)。** + +### F2. `AFTER_START` 的 ACP registry migration 在主窗口创建前执行 + +- **证据**:静态审计。 +- **风险级别**:中。 +- **代码位置**: + - [`acpRegistryMigrationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/acpRegistryMigrationHook.ts#L6-L10):priority `0`,`critical: false`。 + - [`acpRegistryMigrationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/acpRegistryMigrationHook.ts#L21-L29):同步 await `runIfNeeded()` 和 `compensateEnabledRegistryAgentInstalls()`,两调用外层 try/catch。 + - [`windowCreationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/windowCreationHook.ts#L11-L16):主窗口创建 hook priority `1`,`critical: true`。 + - [`index.ts`](../../../src/main/presenter/lifecyclePresenter/index.ts#L190-L215):同 phase 按 priority 分组串行执行(`Promise.allSettled` 等待当前组完成再进下一组)。 +- **触发路径**:`AFTER_START` priority 0 → ACP migration → priority 1 `window-creation`。 +- **风险说明**:即使该 migration 是非 critical,它仍在主窗口创建前被 await;当 registry/agent 安装状态需要补偿写库时,会增加可见窗口前的等待。 +- **验证结论**:✅ 准确(含一处 nuance)。行号全部吻合。 +- **修正**:原报告未点明该 hook 是 `critical: false` 且两调用包在 try/catch 内——因此它**只在成功路径上延迟窗口创建,失败不会阻塞启动**。实际成本取决于两个调用是否真有迁移工作:稳定库上多走 fast-path(幂等检查),首次运行或 registry/agent 安装变更后才实质化。 +- **修复收益**:如果不影响首屏,改成主窗口创建后 background task;若必须先跑,至少添加「已迁移」快速路径验证 + 迁移行数/耗时日志。稳定库上收益接近 0,迁移场景可省几十 ms。**修复必要:边际(仅迁移场景有价值)。** + +### F3. 首屏后多个 backfill fire-and-forget 可能与渲染争抢 SQLite/CPU + +- **证据**:静态审计。 +- **风险级别**:中。 +- **代码位置**: + - [`legacyImportHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/legacyImportHook.ts#L22-L25)、[`rtkHealthCheckHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/rtkHealthCheckHook.ts#L22-L24)、[`usageStatsBackfillHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/usageStatsBackfillHook.ts#L22-L24)、[`sqliteMainlineNormalizationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/sqliteMainlineNormalizationHook.ts#L22-L27)、[`disabledSearchToolCleanupHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/after-start/disabledSearchToolCleanupHook.ts#L22-L27):均为 fire-and-forget(`void ...catch`),priority 20–23。 + - [`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3424-L3477):mainline normalization 全表读取 sessions/messages,并每 200 条才 yield。 + - [`agentSessionPresenter/index.ts`](../../../src/main/presenter/agentSessionPresenter/index.ts#L3684-L3732):usage stats backfill 遍历候选 assistant messages,并每 200 条才 yield。 +- **触发路径**:主窗口创建后 `AFTER_START` priority 20+ → 多个后台任务并行启动。 +- **风险说明**:这些任务不阻塞 lifecycle 完成,但会在首屏交互阶段消耗主进程 SQLite 和 CPU;大型历史库中,200 条一 yield 的粒度可能仍导致明显卡顿。 +- **验证结论**:✅ 准确。5 个 hook 行号、priority 20–23 全部吻合。 +- **修正/补充**:原报告**低估**了一项风险——`startMainlineNormalizationBackfill` 中 `SELECT * FROM new_sessions`(L3425-3426)和 `SELECT * FROM deepchat_messages`(L3462-3463)是**无界内存全表加载**,不仅是 CPU 争抢,更是内存峰值风险(独立于并发问题)。better-sqlite3 同步写入每条都会阻塞主线程,与 renderer IPC 处理直接竞争。 +- **修复收益**:统一纳入 `StartupWorkloadCoordinator`(cpu:1/io:2 限流),把 `SELECT *` 换成 cursor/streaming + 更小 batch 提交,给每个 backfill 增加 batch size、耗时、processed count 日志。预期收益:在数百+会话/消息的账号上消除首屏后数秒的 UI 卡顿/抖动,并消除内存峰值。小库上收益接近 0。**修复必要:是(F1–F4 中收益最高的一项,针对有历史数据的账号)。** + +### F4. Presenter 构造仍是启动关键路径中的同步聚合点 + +- **证据**:静态审计 + 启动 E2E 覆盖。 +- **风险级别**:中。 +- **代码位置**: + - [`presenterInitHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/ready/presenterInitHook.ts#L15-L21):READY 阶段创建 Presenter 并调用 `presenter.init()`,`critical: true`。 + - [`presenter/index.ts`](../../../src/main/presenter/lifecyclePresenter/index.ts):构造函数同步创建 Window、Tab、LLMProvider、MessageManager、Device、MCP、Upgrade、Shortcut、File、Sync、Deeplink、Notification、Tray、Floating、Dialog 等约 20 个对象(`presenter/index.ts:159-220`)。 + - [`presenter/index.ts`](../../../src/main/presenter/index.ts#L733-L805):`Presenter.init()` 设置 provider 并调度 floating、yo-browser、skills、MCP、remote 等 startup tasks。 + - [`startupWorkloadCoordinator/index.ts`](../../../src/main/presenter/startupWorkloadCoordinator/index.ts#L72-L75):后台任务并发限制为 CPU 1、IO 2。 +- **风险说明**:MCP/Skills 等重初始化已被调度到 background,这是好的;但 Presenter 构造本身仍集中创建大量对象。任何子 Presenter 构造函数中的同步 IO/复杂初始化都会回到首屏关键路径。 +- **验证结论**:✅ 准确(作为结构性风险)。行号全部吻合。 +- **修正**:原报告框架正确,但需明确——当前各子 presenter 构造函数只做对象引用装配(接收 config/sqlite 引用,IO 延迟到 `init()`),**构造函数本身当前不热**。这是一个**结构性/潜在风险**而非当前热点:任何未来新增的子 presenter 若在 constructor 做 IO/DB 扫描会无声回到关键路径。 +- **修复收益**:维持 constructor 轻量化约束;新增 presenter 时禁止在 constructor 做文件/网络/数据库扫描;把重初始化放入 `StartupWorkloadCoordinator` 并设置 resource/phase。当前无可测启动收益,价值是**护栏**(防止未来回归)。**修复必要:边际(预防性,非当前可测热点)。** + +### F5. MCP 已不阻塞首屏,但后台初始化存在长尾与退出风险 + +- **证据**:静态审计。 +- **风险级别**:中。 +- **代码位置**: + - [`presenter/index.ts`](../../../src/main/presenter/index.ts#L795-L806):`main:mcp-init` 作为 background/io startup task 调度。 + - [`mcpPresenter/index.ts`](../../../src/main/presenter/mcpPresenter/index.ts#L157-L175):初始化时读取配置(`Promise.all`),并可能检测 npm registry。 + - [`serverManager.ts`](../../../src/main/presenter/mcpPresenter/serverManager.ts#L97-L144):registry 检测对多个 registry 并发请求,单次 timeout 10s(`AbortController`)。 + - [`mcpPresenter/index.ts`](../../../src/main/presenter/mcpPresenter/index.ts#L195-L210):enabled servers 串行 `startServer()`(`for...of` + `await`)。 + - [`mcpClient.ts`](../../../src/main/presenter/mcpPresenter/mcpClient.ts#L467-L499):单个 MCP 连接 timeout 为 5 分钟(`Promise.race`)。 +- **风险说明**:当前代码显示 MCP 初始化已是 background,不应再视为首屏阻塞;但一旦用户有多个 enabled servers,后台串行启动和 5 分钟连接 timeout 会造成长尾任务、资源占用和退出等待风险。最坏情况长尾 ≈ N × 5 分钟。 +- **验证结论**:✅ 准确。所有行号吻合。 +- **修正**:无。 +- **修复收益**:为 startup/background 场景设置更短 soft timeout(如 30–60s);区分「首轮能力可用」和「慢 server 继续重试」;串行启动保守但可增加总耗时上限和可观测状态。收益:后台长尾有界、MCP 可用性信号更快、减少卡住的后台任务。**修复必要:是(针对配多 server 的用户)。** + +### F6. 自定义 protocol handler 在主进程同步读文件 + +- **证据**:静态审计。 +- **风险级别**:中。 +- **代码位置**: + - [`protocolRegistrationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L74-L129):`deepcdn` handler 使用 `existsSync`(L87, L108)+ `readFileSync`(L117),全量读入内存返回 `Response`。 + - [`protocolRegistrationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L132-L181):`imgcache` handler 同步 `existsSync`(L139)+ `readFileSync`(L170)。 + - [`protocolRegistrationHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts#L184-L211):`workspace-preview` handler 使用 `existsSync`/`statSync`/`readFileSync`(L194, L204)。 +- **风险说明**:protocol 请求在主进程处理;大量图片、workspace preview、大文件或慢盘会阻塞主进程事件循环,影响窗口响应和 IPC。 +- **验证结论**:✅ 准确。三个 handler 全部使用同步 fs,无 streaming。 +- **修正**:风险分层需细化——`deepcdn` 服务内置 CDN 资源(有界、小),风险低;`imgcache`/`workspace-preview` 服务用户文件,可大、可在慢盘上,并发抓取(如图片画廊)会在主线程串行化并阻塞 IPC/窗口响应,风险集中在后两者。 +- **修复收益**:改为 `fs.promises`/streaming `Response`(Node streams 已被 Electron `protocol.handle` 支持);为 `workspace-preview` 增加大小上限和 MIME cache;避免对频繁请求重复 `exists/stat`。收益:图片密集/大文件/慢盘下主进程不阻塞。**修复必要:是(imgcache/workspace-preview 路径)。** + +### F7. Splash window 无条件创建,可能增加冷启动成本 + +- **证据**:静态审计。 +- **风险级别**:低。 +- **代码位置**: + - [`lifecyclePresenter/index.ts`](../../../src/main/presenter/lifecyclePresenter/index.ts#L86-L97):启动一开始 `await splashManager.create()`(L88),完成后 close(L97)。 + - [`SplashWindowManager.ts`](../../../src/main/presenter/lifecyclePresenter/SplashWindowManager.ts#L127-L193):创建 BrowserWindow、加载 splash renderer(L150-192)。 + - [`SplashWindowManager.ts`](../../../src/main/presenter/lifecyclePresenter/SplashWindowManager.ts#L142-L145):`SPLASH_SHOW_DELAY_MS = 200`(L54),200ms 后才允许 show。 + - [`SplashWindowManager.ts`](../../../src/main/presenter/lifecyclePresenter/SplashWindowManager.ts#L108-L117):主窗口创建时可 `suppressSplashShow`/`closeHiddenSplashWindow()`。 +- **风险说明**:200ms timer 本身不阻塞 lifecycle,但 splash BrowserWindow/WebContents 仍无条件创建并开始加载;当主窗口很快出现时,这部分可能成为纯额外成本。 +- **验证结论**:✅ 准确(含一处 nuance)。行号吻合。 +- **修正**:原报告已正确提到 L108-117 的 suppress 机制,但需强调——`suppressSplashShow` 只抑制**可见性**,`BrowserWindow`/`WebContents`/renderer load 的创建成本已经发生,无法回收。 +- **修复收益**:改成延迟到「预计启动超过阈值」时再创建 BrowserWindow,而不是先创建再延迟 show;数据库解锁等必须 UI 可保留强制显示路径。收益:快冷启动上省 BrowserWindow 创建/load 成本;但增加复杂度并可能回归数据库解锁强制显示路径。**修复必要:边际(已有 suppress 机制,低优先级)。** + +### F8. 关闭路径正确但重资源销毁串行,缺少耗时观测 + +- **证据**:静态审计 + E2E 间接覆盖。 +- **风险级别**:中。 +- **代码位置**: + - [`lifecyclePresenter/index.ts`](../../../src/main/presenter/lifecyclePresenter/index.ts#L423-L456):`before-quit` preventDefault 后 await `requestShutdown()` 再 `app.quit()`。 + - [`mcpShutdownHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/beforeQuit/mcpShutdownHook.ts#L9-L20):priority 5 先关闭 MCP。 + - [`presenterDestroyHook.ts`](../../../src/main/presenter/lifecyclePresenter/hooks/beforeQuit/presenterDestroyHook.ts#L13-L23):Presenter destroy priority `Number.MAX_VALUE`,最后执行。 + - [`presenter/index.ts`](../../../src/main/presenter/index.ts#L954-L981):destroy 中依次 shutdown plugin → mcp → remote → memory → sqlite → workspace → skill。 + - [`pluginPresenter/index.ts`](../../../src/main/presenter/pluginPresenter/index.ts#L136-L157):plugin shutdown 遍历 plugin-owned MCP server,逐个 `await stopServer()`(串行)。 + - [`mcpPresenter/index.ts`](../../../src/main/presenter/mcpPresenter/index.ts#L240-L248):MCP shutdown 遍历 running clients,逐个 stop(串行)。 +- **风险说明**:顺序保证安全,但在多 MCP server、plugin runtime、workspace watcher、memory consolidation 同时存在时,退出可能出现长尾;当前 E2E 只证明正常关闭成功,没有 shutdown duration 分解。 +- **验证结论**:✅ 准确(含一处 nuance)。 +- **修正/补充**:原报告未提到一处**部分缓解**——`closeTransport` 对 stdio transport 调用 `terminateProcessTree(child, { graceMs: 2000 })`(`mcpClient.ts:562-575`),给出 2s 进程杀 grace。但整体 `disconnect`/`transport.close()` 的 await 无 timeout,非 stdio 或无响应 transport 仍可无限期拖住串行循环。 +- **修复收益**:为 BEFORE_QUIT hook 增加 duration 日志;对相互独立的 plugin-owned server stop 做 `Promise.allSettled` 或受限并发;MCP shutdown 增加 per-server timeout(`Promise.race` 包裹 `disconnect`)。收益:退出有界、可诊断。**修复必要:是。** + +## 4. Renderer / UI 热点 + +### F9. Renderer 启动时立即加载/创建 Markdown workers,与「lazy」注释不一致 + +- **证据**:静态审计 + 构建输出。 +- **风险级别**:中。 +- **代码位置**: + - [`main.ts`](../../../src/renderer/src/main.ts#L11-L20):renderer 入口直接调用 `ensureMarkdownWorkers()`(L18,在 `app.mount` 前)。 + - [`markdownWorkerLifecycle.ts`](../../../src/renderer/src/lib/markdownWorkerLifecycle.ts#L73-L90):动态 import KaTeX/Mermaid worker constructor(L79-82)。 + - [`markdownWorkerLifecycle.ts`](../../../src/renderer/src/lib/markdownWorkerLifecycle.ts#L132-L141):立即创建 katex/mermaid workers 并注册给 markstream。 +- **风险说明**:即使没有首屏 Markdown/KaTeX/Mermaid 内容,也会启动 worker 加载路径;构建输出中 `katexRenderer.worker` 约 302 KB、`mermaidParser.worker` 约 605 KB,且 Mermaid/Katex 相关 chunk 较大。 +- **验证结论**:✅ 准确。构建体积复现精确:`katexRenderer.worker-kYRUX8Vy.js` = 301,980 B(~302 KB),`mermaidParser.worker-BSPkGSOf.js` = 604,751 B(~605 KB)。文件头注释(L4-6)声称「Workers are created on first use rather than during renderer startup」,与入口调用直接矛盾。 +- **修正**:`main.ts:14-17` 注释承认该 eager 调用是有意为之(「awaiting here is unnecessary」),属「有意但可商榷」的选择,非疏漏。 +- **修复收益**:真正按需初始化:首次渲染含 KaTeX/Mermaid/code block 时再 `ensureMarkdownWorkers()`;首屏只注册 fallback。markstream-vue 在 worker 未就绪时降级为纯文本,fallback 路径已存在。收益:无 markdown 内容的冷启动省 ~900 KB worker parse/实例化。**修复必要:是。** + +### F10. i18n 全量同步导入所有语言包 + +- **证据**:静态审计。 +- **风险级别**:中。 +- **代码位置**: + - [`i18n/index.ts`](../../../src/renderer/src/i18n/index.ts#L1-L20):一次性静态导入 20 个 locale(zh-CN, en-US, ja-JP, ko-KR, zh-HK, zh-TW, ru-RU, fr-FR, fa-IR, pt-BR, da-DK, he-IL, es-ES, de-DE, tr-TR, id-ID, ms-MY, it-IT, pl-PL, vi-VN)+ 别名。 + - [`main.ts`](../../../src/renderer/src/main.ts#L22-L28):主 renderer 初始化时把 `locales` 全量注入 vue-i18n。 + - [`settings/main.ts`](../../../src/renderer/settings/main.ts#L41-L48):Settings renderer 同样全量注入。 +- **风险说明**:主窗口和 Settings 窗口都会同步解析所有语言资源,增加 JS parse/evaluate 与内存;首屏实际只需要当前语言和 fallback。 +- **验证结论**:✅ 准确(风险程度为 partial)。行号吻合,确为 20 个 locale 全静态导入。 +- **修正**:实际 parse/eval 成本取决于 Vite 如何分块——`out/renderer/assets` 中未能清晰归属到单一 locale bundle,可能被内联/共享,运行时成本真实但未必很大。 +- **修复收益**:只内联 `zh-CN`/`en-US` fallback;其他 locale 改为动态 import,并在切换语言时加载。收益:有界(locale JSON 体量不大),冷启动与内存少量改善,幅度小于 F9/F11。**修复必要:边际到是。** + +### F11. Iconify collection 与 Provider 图标包体偏大 + +- **证据**:静态审计 + 构建输出。 +- **风险级别**:高。 +- **代码位置**: + - [`iconLoader.ts`](../../../src/renderer/src/lib/iconLoader.ts#L41-L49):异步加载完整 lucide、vscode-icons、line-md collection(`addCollection`)。 + - [`main.ts`](../../../src/renderer/src/main.ts#L46-L51) 与 [`settings/main.ts`](../../../src/renderer/settings/main.ts#L104-L109):mount 后 `setTimeout(...,0)` 调用 `preloadIcons()`。 + - [`ModelIcon.vue`](../../../src/renderer/src/components/icons/ModelIcon.vue#L6-L82):静态 import 约 77 个 provider 图标资源(SVG/PNG),构建 `icons` map(L85-184)。 +- **动态证据**:构建输出出现 `icons-BoZyWSAb.js` 约 3,695 KB(最大 renderer 资产)、`tokenflux-color` SVG asset 约 1,628 KB(单个图标超 1.5 MB)。复现精确:3,695,438 B 与 1,627,765 B。 +- **风险说明**:虽然 icon preload 不阻塞 mount,但会在首屏后立即抢占网络/磁盘/parse;Provider 列表和 ModelIcon 的静态图标映射使 Settings provider 页面和模型选择相关 chunk 更重。 +- **验证结论**:✅ 准确。四项中收益最高。行号与构建体积全部吻合。 +- **修正**:无。 +- **修复收益**:按使用到的 icon 白名单生成 collection;Provider 图标改为 manifest/url 映射或按 provider lazy import;特别处理超大 SVG asset(tokenflux-color 1.6 MB 极可能应优化/替换)。预期可从关键资源图移除数 MB。**修复必要:是(高收益)。** + +### F12. App/ChatTabView 启动数据加载存在重复调度与 IPC 峰值 + +- **证据**:静态审计。 +- **风险级别**:中。 +- **代码位置**: + - [`App.vue`](../../../src/renderer/src/App.vue#L522-L529):mount 时同时 `initAppStores()`、`providerStore.ensureInitialized()`、`modelStore.initialize()`、`sessionStore.fetchSessions()`。 + - [`storeInitializer.ts`](../../../src/renderer/src/lib/storeInitializer.ts#L19-L24):`initAppStores()` 内并行 `providerStore.initialize()` + `uiSettingsStore.loadSettings()`。 + - [`ChatTabView.vue`](../../../src/renderer/src/views/ChatTabView.vue#L94-L116):ChatTabView 继续启动 agent/project/model/ollama 加载,`finally` 中以 `!hasLoadedInitialPage` 守卫再调 `sessionStore.fetchSessions()`。 +- **风险说明**:部分 store 具备 initialization promise,可降低重复请求;但启动时仍会形成多个 route/store IPC 的峰值,增加首屏后主进程压力。 +- **验证结论**:⚠️ 部分准确。行号全部吻合,但「重复调度」表述需修正。 +- **修正**:`providerStore.initialize()`(`providerStore.ts:350-353`)和 `modelStore.initialize()`(`modelStore.ts:1391-1392`)均通过 `initializationPromise` 守卫**自去重**——`App.vue` 中冗余的 `ensureInitialized()` 只是 await 已有 promise,不重新请求。**真正无去重的是 `sessionStore.fetchSessions()`**(无顶层 `if (initialized) return`,每次调用都发 IPC `listLightweight`,`session.ts:520`):App.vue 无守卫调用可能与 ChatTabView 的 `hasLoadedInitialPage` 守卫调用竞态——若 App 的 fetch 未完成时 ChatTabView mount(子组件在父之后 mount,但 fetch 异步),`hasLoadedInitialPage` 仍为 false,第二次 IPC 触发。`initialPageRequestId`(`session.ts:514,529`)只丢弃过期**结果**,不阻止重复**请求**。 +- **修复收益**:明确 startup bootstrap 的单一 owner;把 session fetch 加 in-flight promise 守卫关闭竞态;provider/model 首屏必要字段合并到 bootstrap。收益:有界(都是轻量列表调用,且多数并行),主要消除 session 的时序竞态。**修复必要:边际(provider/model 已自去重,仅 session 竞态需修)。** + +### F13. 消息 store 对每次插入/更新执行排序,长对话会放大 + +- **证据**:静态审计。 +- **风险级别**:中。 +- **代码位置**: + - [`message.ts`](../../../src/renderer/src/stores/ui/message.ts#L71-L80):`sortMessageIdsByOrderSeq()` 全量 sort。 + - [`message.ts`](../../../src/renderer/src/stores/ui/message.ts#L82-L94):`upsertMessageRecord` 在 `shouldSort` 为 true 时调用 sort(新 id 或 orderSeq 变化)。 + - [`message.ts`](../../../src/renderer/src/stores/ui/message.ts#L55-L122):`parsedMessageCache` 是无上限 Map。 + - [`message.ts`](../../../src/renderer/src/stores/ui/message.ts#L128-L141):block 复用比较对最多 5 个字段 `JSON.stringify`(`extra`/`tool_call`/`artifact`/`image_data`/`reasoning_time`),且在 `isReusableStableAssistantBlock` 内、被 status/type/timestamp/id 检查门控。 +- **风险说明**:大历史会话、流式消息更新或批量加载时,排序 O(n log n)、字符串化比较和无上限 cache 会叠加为 renderer 卡顿/内存增长。 +- **验证结论**:⚠️ 部分准确。行号全部吻合,但标题「对每次插入/更新执行排序」**夸大**了触发频率。 +- **修正**:`shouldSort` 仅在**新 id 或 orderSeq 变化**时为 true。内容-only 更新(流式最常见的 `applyStreamingBlocksToMessage`,L512-517)仍走 `upsertMessageRecord`,但 orderSeq 不变且 id 已存在→**跳过排序**。`parsedMessageCache` 无上限但在 `loadMessages`(L360)、`clear`(L481)会清空,并按 id 在 `removeOptimisticMessage` 清理——是「会话内无上限」而非「永不清理」。`JSON.stringify` 比较被前置检查门控,不是每次比较都跑。 +- **修复收益**:按 `orderSeq` 二分插入(仅对新 id);批量加载时一次排序;给 parsed cache 增加 LRU/按 session 清理;对 block payload 使用版本/hash 或浅层字段比较。收益:数千+消息会话改善明显,但门控已避开最坏的流式路径。**修复必要:边际到是(针对超长会话)。** + +### F14. Session sidebar fingerprint/watch 会对大列表反复构造大字符串并触发布局检查 + +- **证据**:静态审计。 +- **风险级别**:中。 +- **代码位置**: + - [`WindowSideBar.vue`](../../../src/renderer/src/components/WindowSideBar.vue#L1690-L1699):`visibleSessionFingerprint` 拼接 pinned 和 visible group/session ids。 + - [`WindowSideBar.vue`](../../../src/renderer/src/components/WindowSideBar.vue#L1703-L1718):多个依赖任一变化都会 `scheduleSessionListFillCheck()`。 + - [`WindowSideBar.vue`](../../../src/renderer/src/components/WindowSideBar.vue#L1656-L1688):fill check 读取 `scrollHeight/clientHeight`(L1671)并可能 `loadNextPage()`(L1676),循环最多 50 轮。 +- **风险说明**:会话多、分组多、搜索/折叠频繁时,字符串 fingerprint 和 DOM 尺寸读取会放大;首屏自动补页逻辑虽有用,但需要避免过度触发。 +- **验证结论**:✅ 准确(含一处 nuance)。行号吻合。 +- **修正**:原报告「触发布局检查」准确,但**低估了已有缓解**——`scheduleSessionListFillCheck` 已 rAF coalesce(L1040-1048)并去重在帧内的请求,连续多次依赖变化在一帧内只触发一次 fill check,不是每次变化一次。 +- **修复收益**:用 revision counter 或轻量 hash 替代完整 join;把布局读取合并到 `requestAnimationFrame`(已部分做到);对连续 session store 变化做 debounce。收益:主要对极大 session 列表(数百+)在切换/搜索时有改善;rAF 守卫已限制布局读取频率。**修复必要:边际。** + +### F15. ChatStatusBar watcher 链依赖范围大,部分 immediate/deep watcher 容易反复触发 IPC/计算 + +- **证据**:静态审计。 +- **风险级别**:中。 +- **代码位置**: + - [`ChatStatusBar.vue`](../../../src/renderer/src/components/chat/ChatStatusBar.vue#L2297-L2310):监听 `modelStore.chatSelectableModelGroups` 且 `deep: true`,`immediate` 同步 draft model selection。 + - [`ChatStatusBar.vue`](../../../src/renderer/src/components/chat/ChatStatusBar.vue#L2312-L2337):session/permission watcher `immediate` 读取 permission mode(异步 IPC `sessionClient.getPermissionMode`,有 `permissionSyncToken` 竞态守卫)。 + - [`ChatStatusBar.vue`](../../../src/renderer/src/components/chat/ChatStatusBar.vue#L2362-L2375):provider/model/draft/acp 变化触发 generation settings sync(6 deps,`immediate`)。 + - [`ChatStatusBar.vue`](../../../src/renderer/src/components/chat/ChatStatusBar.vue#L2377-L2395):8 个依赖调度 ACP config sync(`immediate`,可取消 `scheduleStartupDeferredTask` L2389-2392)。 +- **风险说明**:状态栏是聊天页常驻组件;模型列表、session 切换、draft 状态变化都可能触发多条 watcher,尤其模型组 deep watcher 会随大模型列表变化被放大。`activeSessionId` 在 3 个 watcher 中重复出现,单次 session 切换可能触发多个 handler。 +- **验证结论**:✅ 准确。行号全部吻合。 +- **修正**:无。已有 token/cancellable-task 守卫处理竞态(正确性 OK),问题在触发次数。 +- **修复收益**:去掉 deep watcher,改为监听模型列表 revision/selected provider;把 permission/generation/acp sync 做去重和请求 coalescing;合并重叠的 `activeSessionId` watcher;增加 traceDebug 下 watcher 触发计数。收益:低风险、减少最易放大的触发源。**修复必要:是(本组中最易实施且安全的优化)。** + +### F16. Settings provider 页面渲染全部 provider + draggable + 图标,且 E2E 显示该路径耗时较高 + +- **证据**:静态审计 + E2E 动态覆盖。 +- **风险级别**:中。 +- **代码位置**: + - [`ModelProviderSettings.vue`](../../../src/renderer/settings/components/ModelProviderSettings.vue#L61-L121):enabled providers 使用 draggable 全量渲染。 + - [`ModelProviderSettings.vue`](../../../src/renderer/settings/components/ModelProviderSettings.vue#L124-L180):disabled providers 使用 draggable 全量渲染。 + - [`ModelProviderSettings.vue`](../../../src/renderer/settings/components/ModelProviderSettings.vue#L650-L692):computed 每次按 enable/disable/filter 生成列表并支持 reorder。 + - [`ModelIcon.vue`](../../../src/renderer/src/components/icons/ModelIcon.vue#L6-L82):每个 provider row 依赖静态 provider icon map(~77 图标,L85-184),`iconKey` 对每个实例做 `modelIdLower.includes(key)` 线性扫描(~90 keys,L206-227)。 +- **动态证据**:`04-settings-navigation` 在 Provider Center 截图后失败前已进入包含 57 个 disabled providers 的页面;`18-provider-readonly-route` 单项约 6.4s,Settings subset 中偏高。 +- **风险说明**:provider 数增长后,全部行、图标、Switch、draggable watcher 会增加 Settings 打开和搜索成本。约 57 个 disabled providers 时挂载 ~57 个 draggable 行 + ModelIcon 实例,每个 ModelIcon 跑 O(n) `includes` 扫描。 +- **验证结论**:✅ 准确(含一处 nuance)。 +- **修正**:原报告「图标 lazy load」建议略偏——图标已是**静态 import 打包**(运行时无 fetch 成本),lazy-load `` 收益很小;真正收益是**减少行数**(虚拟化/折叠)和把 `draggable` 限制到「编辑排序模式」。 +- **修复收益**:provider 列表虚拟化或按 group 折叠;draggable 仅在「编辑排序模式」启用;`iconKey` 的 `includes` 扫描可换精确 map。收益:provider 数增长后 Settings 打开/搜索成本下降,与 `18-provider-readonly-route` 6.4s 耗时一致。**修复必要:边际到是。** + +## 5. E2E/验证能力问题(影响性能回归门禁) + +### V1. Settings smoke 仍期待隐藏路由出现在侧边栏,导致 CI smoke 与 full smoke 多项失败 + +- **证据**:动态验证 + 静态审计。 +- **风险级别**:高。 +- **代码位置**: + - [`settingsNavigation.ts`](../../../src/shared/settingsNavigation.ts#L163-L170):MCP `hiddenInSidebar: true`(L170)。 + - [`settingsNavigation.ts`](../../../src/shared/settingsNavigation.ts#L173-L180):Remote `hiddenInSidebar: true`(L180)。 + - [`settingsNavigation.ts`](../../../src/shared/settingsNavigation.ts#L211-L220):Plugins `hiddenInSidebar: true`(L219)。 + - [`settingsNavigation.ts`](../../../src/shared/settingsNavigation.ts#L222-L229):Skills `hiddenInSidebar: true`(L229)。 + - [`settingsNavigation.ts`](../../../src/shared/settingsNavigation.ts#L338-L342):Settings sidebar 只取 `!hiddenInSidebar` 的 navigation items。 + - [`settings/App.vue`](../../../src/renderer/settings/App.vue#L37-L49):只为侧边栏 button 渲染 `data-testid=settings-tab-*`(L534-535)。 + - [`04-settings-navigation.smoke.spec.ts`](../../../test/e2e/specs/04-settings-navigation.smoke.spec.ts#L41-L44):`TABS` 数组列出 `settings-tab-mcp`/`-remote`/`-plugins`/`-skills`(L42, 57, 67, 73)。 + - [`helpers/settings.ts`](../../../test/e2e/helpers/settings.ts#L52-L55):`openSettingsTab` 断言 `toBeVisible` 后才点击。 +- **动态结果**:`e2e:smoke:ci` 的 `04` 失败;full smoke 的 `04/06/11/13/16/19` 均因 hidden tab 找不到失败。 +- **本次复现**:`pnpm exec playwright ... 04-settings-navigation` 失败,错误为 `getByTestId('settings-tab-mcp')` `toBeVisible` 超时 30s,`element(s) not found`,调用栈 `helpers/settings.ts:54` → `04-settings-navigation.smoke.spec.ts:137`。这是**确定性失败**(非 flake)。 +- **影响**:性能报告仍能使用通过的 focused specs,但 CI smoke 当前不能作为 Settings 全量性能门禁。 +- **验证结论**:✅ 准确。行号、失败模式、复现全部吻合。 +- **修正**:无。 +- **修复收益**:E2E 对 hidden route 改为直接 `window.location.hash`/typed settings navigation,而不是侧边栏 tab;或为隐藏路由提供测试专用 route launcher。收益:解锁 CI smoke 作为 Settings 性能门禁;纯测试改动,零产品风险。**修复必要:是(高,不修则无稳定性能基线)。** + +## 6. 已检查但不应误判的点 + +- **MCP 初始化不是首屏阻塞**:当前 `Presenter.init()` 将 `main:mcp-init` 调度为 background/io task([`presenter/index.ts`](../../../src/main/presenter/index.ts#L795-L806)),因此不能再把 enabled MCP server 启动描述为窗口创建前的必然阻塞;它是后台长尾风险。复核确认。 +- **`registerMainKernelRoutes` 不是大量 `ipcMain.handle` 注册循环**:当前只注册 `DEEPCHAT_ROUTE_INVOKE_CHANNEL` 一个 handler([`routes/index.ts`](../../../src/main/routes/index.ts#L4162-L4179)),性能关注点应放在 route module/bundle 体积和 runtime dispatch,而不是大量 handler 注册。 +- **Workspace watcher 已有基本节流和忽略目录**:忽略 `node_modules/dist/build/.cache/out` 等目录([`workspacePresenter/index.ts`](../../../src/main/presenter/workspacePresenter/index.ts#L57-L74)),main/renderer 均有 debounce([`workspacePresenter/index.ts`](../../../src/main/presenter/workspacePresenter/index.ts#L302-L340)、[`useWorkspaceSync.ts`](../../../src/renderer/src/components/sidepanel/composables/useWorkspaceSync.ts#L280-L302))。大仓库仍建议压测,但当前 E2E 6 次通过。 + +## 7. 优先级建议 + +> 优先级综合「风险真实性」「修复收益」「实施成本」三维度,并已采纳本次复核对原排序的调整(F3 提升为启动侧最高收益项)。 + +1. **先修 E2E Settings hidden route 失败(V1)**:否则 CI smoke 无法提供稳定性能基线。纯测试改动,零产品风险。 +2. **启动关键路径减重**:F3 backfill(最高收益,含内存峰值)→ F1 SQLite schema diagnosis/backfill 分层 + lifecycle hook duration 明细 → F2 ACP migration 后移(边际)。 +3. **Renderer 首屏减包**:F11 Iconify/provider icon 按需加载(最高收益,数 MB)→ F9 Markdown workers 真正 lazy(~900 KB)→ F10 i18n locale lazy(边际)。 +4. **长列表优化**:F15 ChatStatusBar 去 deep watcher(最易实施)→ F13 message upsert 二分/批量排序(超长会话)→ F14 session sidebar fingerprint 改 revision(边际,已有 rAF)→ F16 provider list 虚拟化/折叠。 +5. **后台任务/退出长尾治理**:F8 MCP/plugin shutdown timeout + duration → F5 MCP startup soft timeout → F6 protocol handler async/streaming(imgcache/workspace-preview)。 +6. **结构性护栏(不当前热点)**:F4 Presenter constructor 轻量化约束。 +7. **低优先级**:F7 splash 延迟创建(已有 suppress 机制)。 + +## 8. 建议新增的性能门禁 + +- `01-launch --repeat-each=10`:记录 p50/p95 启动到 `app-main` 可见。本次 3 次复现 4.6/4.7/4.1s 已验证路径稳定。 +- Settings route smoke 修复后(V1):`04-settings-navigation --repeat-each=3`,记录 Settings 首屏和 provider page 截图前耗时。 +- Synthetic 大数据测试:生成 1k/10k messages、500 sessions,验证 message upsert、sidebar、scroll 的交互耗时(量化 F13/F14 的静态推断)。 +- MCP synthetic:配置 3 个 mock MCP server,其中 1 个慢握手,验证 background init 不影响首屏且 shutdown 有总 timeout(量化 F5/F8)。 +- Workspace watcher 大仓库测试:在包含 `node_modules/out/dist` 的临时树验证 watcher excludes 和 invalidation debounce。 + +## 9. 复核结论汇总 + +| 编号 | 发现 | 验证结论 | 风险真实 | 修复必要 | 关键修正 | +| --- | --- | --- | --- | --- | --- | +| F1 | SQLite 同步 open/建表/迁移/diagnosis | ✅ 准确(行号偏移 1) | 是 | 是 | `DatabaseInitializer.migrate()` 是 no-op stub,真迁移在 SQLitePresenter 构造函数 | +| F2 | ACP migration 在窗口创建前 | ✅ 准确 | 部分 | 边际 | `critical:false` + try/catch,只延迟不阻塞 | +| F3 | backfill fire-and-forget 争抢 | ✅ 准确(低估) | 是 | 是(最高收益) | `SELECT *` 是无界内存全表加载,内存峰值风险 | +| F4 | Presenter 构造同步聚合 | ✅ 准确(结构性) | 部分(潜在) | 边际(护栏) | 当前构造函数不热,是潜在/结构性风险 | +| F5 | MCP 后台长尾/退出 | ✅ 准确 | 是 | 是 | 无 | +| F6 | protocol handler 同步读文件 | ✅ 准确 | 是(imgcache/workspace-preview) | 是 | deepcdn 风险低(内置资源有界) | +| F7 | splash 无条件创建 | ✅ 准确 | 部分 | 边际 | suppress 只抑制可见性,创建成本已发生 | +| F8 | 关闭串行缺观测 | ✅ 准确 | 是 | 是 | stdio 有 2s terminateProcessTree grace(部分缓解) | +| F9 | Markdown workers eager 加载 | ✅ 准确 | 是 | 是 | eager 调用是有意为之(非疏漏) | +| F10 | i18n 全量同步导入 | ✅ 准确 | 部分 | 边际到是 | 实际成本受 Vite 分块影响,未必很大 | +| F11 | Iconify/provider icon 包体 | ✅ 准确 | 是 | 是(高收益) | 无 | +| F12 | 启动重复调度/IPC 峰值 | ⚠️ 部分准确 | 部分(仅 session) | 边际 | provider/model 已自去重,仅 sessionStore 竞态 | +| F13 | 消息 store 排序放大 | ⚠️ 部分准确 | 部分 | 边际到是 | 排序不在每次 upsert,仅 new id/orderSeq;cache 有清理 | +| F14 | sidebar fingerprint/watch | ✅ 准确 | 是(部分) | 边际 | 已有 rAF coalescing | +| F15 | ChatStatusBar watcher 链 | ✅ 准确 | 是 | 是(最易实施) | 无 | +| F16 | Settings provider 页全量渲染 | ✅ 准确 | 是 | 边际到是 | 图标已静态打包,lazy 无收益;真正收益是减行数 | +| V1 | smoke 期待隐藏路由 tab | ✅ 准确 | 是(确定性失败) | 是(高) | 已复现失败 | + +**总体**:17 项中 15 项完全准确,2 项(F12、F13)部分准确(表述略有夸大,需按修正收敛)。所有行号引用除 F1 偏移 1 行外全部吻合当前代码。所有构建体积与启动耗时动态证据均已在本机复现。没有发现任何伪造或不成立的发现;复核仅对若干 nuance 做了收敛与补充。 diff --git a/docs/issues/performance-audit-report/spec.md b/docs/issues/performance-audit-report/spec.md new file mode 100644 index 000000000..e21bcf588 --- /dev/null +++ b/docs/issues/performance-audit-report/spec.md @@ -0,0 +1,41 @@ +# Performance Audit Report + +## User Need + +用户希望基于当前 Vue + Electron 项目的真实代码和现有 E2E 能力,反复验证启动、关闭和关键功能路径,找出当前项目中所有可疑的性能点,并输出一份不能靠猜测、每一项都包含具体代码定位和逻辑证据的性能优化报告。 + +## Goal + +产出一份中文性能优化报告,覆盖启动、关闭、主进程 Presenter、IPC/事件、renderer/Vue、数据库/文件/插件/MCP、E2E 覆盖到的关键用户路径,并对每个可疑性能点给出: + +- 现象或触发路径 +- 代码定位(文件与行号) +- 逻辑链路说明 +- 验证方式或可复现证据 +- 风险级别和优化建议 + +## Acceptance Criteria + +- 报告保存到 `docs/issues/performance-audit-report/report.md`。 +- 每个列为“可疑性能点”的条目必须包含至少一个当前代码位置引用,且说明为什么该代码会进入性能热点或生命周期风险。 +- 报告必须区分“已由 E2E/命令验证的事实”和“静态代码审计发现的风险”;没有证据的内容不能写成确定结论。 +- 启动和关闭路径必须至少通过现有 E2E 或等价命令实际运行验证一次,并记录命令、结果和关键输出。 +- 需要覆盖现有 E2E smoke 能力能到达的主要窗口/路由:主聊天窗口、Settings、browser route、readonly settings/routes、agent CRUD、workspace watcher。 +- 若某些验证因环境、凭据或运行时间限制无法完成,报告必须明确列出缺失证据和原因,不把缺失项伪装成已验证。 + +## Constraints + +- 不修改产品逻辑,只做调查和报告。 +- 不记录、导出或暴露用户密钥、token、凭据或本地私密数据。 +- 使用当前 worktree 作为事实来源;不能依赖记忆或未检查的上下文。 +- 遵守现有 E2E 默认行为:默认使用临时 `DEEPCHAT_E2E_USER_DATA_DIR`,避免破坏用户真实数据。 + +## Non-Goals + +- 本任务不直接实施性能优化代码改动。 +- 不引入新的遥测或长期运行的 profiling 基础设施。 +- 不声称覆盖所有无法由当前环境启动或凭据触达的第三方服务调用性能。 + +## Open Questions + +- 无。报告位置采用本仓库 SDD 约定;无法验证的外部依赖会在报告中显式列为证据缺口。 diff --git a/docs/issues/performance-audit-report/tasks.md b/docs/issues/performance-audit-report/tasks.md new file mode 100644 index 000000000..1505aee8d --- /dev/null +++ b/docs/issues/performance-audit-report/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +- [x] Inspect E2E commands, fixtures, and SDD/report conventions. +- [x] Run required build/E2E smoke validation and capture outcomes. +- [x] Run repeated launch/close validation and targeted route smoke slices. +- [x] Audit main-process startup/shutdown and presenter hot paths with code references. +- [x] Audit renderer/Vue hot paths and route-specific work with code references. +- [x] Cross-check findings against E2E/log evidence; remove unsupported claims. +- [x] Write `report.md` with findings, evidence, and prioritized recommendations. + +## 复核阶段(2026-07-04) + +- [x] 重新执行 `pnpm run build`,逐字节核对 F9/F11/F16 引用的 bundle 体积。 +- [x] 重新执行 `01-launch --repeat-each=3`,复现启动耗时 4.6/4.7/4.1s。 +- [x] 重新执行 `04-settings-navigation`,复现 V1 失败(settings-tab-mcp toBeVisible 超时)。 +- [x] 4 组并行代码审计逐条复核 F1–F4、F5–F8、F9–F12、F13–F16+V1 的文件与行号。 +- [x] Final verification: ensure every finding has code location and evidence classification. +- [x] 更新 `report.md`:每个发现新增「验证结论」与「修复收益」节,修正 F1/F2/F3/F7/F8/F9/F10/F12/F13/F14/F16 的 nuance,新增第 9 节复核结论汇总表。 diff --git a/docs/issues/scheduled-ui-label-layout/spec.md b/docs/issues/scheduled-ui-label-layout/spec.md index a28f5fb80..e12912bab 100644 --- a/docs/issues/scheduled-ui-label-layout/spec.md +++ b/docs/issues/scheduled-ui-label-layout/spec.md @@ -20,4 +20,3 @@ the latest run timestamp. - No internal route or domain rename. - No run detail redesign. - diff --git a/package.json b/package.json index 472ea9065..b98520a99 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800", "scripts": { - "prebuild": "node scripts/fetch-provider-db.mjs && node scripts/fetch-acp-registry.mjs", + "prebuild": "node scripts/generate-icon-collections.mjs && node scripts/fetch-provider-db.mjs && node scripts/fetch-acp-registry.mjs", "preinstall": "npx only-allow pnpm", "test": "vitest", "test:main": "vitest --config vitest.config.ts test/main", @@ -27,6 +27,8 @@ "lint": "pnpm run lint:agent-cleanup && pnpm run lint:architecture && oxlint .", "lint:architecture": "node scripts/architecture-guard.mjs", "lint:agent-cleanup": "node scripts/agent-cleanup-guard.mjs", + "icons:generate": "node scripts/generate-icon-collections.mjs", + "icons:check": "node scripts/generate-icon-collections.mjs --check", "architecture:baseline": "node scripts/generate-architecture-baseline.mjs", "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "vue-tsgo --project tsconfig.app.tsgo.json", diff --git a/resources/acp-registry/registry.json b/resources/acp-registry/registry.json index 80529eb23..171ea986c 100644 --- a/resources/acp-registry/registry.json +++ b/resources/acp-registry/registry.json @@ -122,7 +122,7 @@ { "id": "cline", "name": "Cline", - "version": "3.0.34", + "version": "3.0.37", "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", "repository": "https://github.com/cline/cline", "website": "https://cline.bot/cli", @@ -133,7 +133,7 @@ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cline.svg", "distribution": { "npx": { - "package": "cline@3.0.34", + "package": "cline@3.0.37", "args": [ "--acp" ] @@ -408,7 +408,7 @@ { "id": "devin", "name": "Devin", - "version": "2026.8.18", + "version": "3000.1.23", "description": "Devin CLI coding agent by Cognition", "website": "https://docs.devin.ai/cli", "authors": [ @@ -419,42 +419,42 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://static.devin.ai/cli/2026.8.18/devin-2026.8.18-aarch64-apple-darwin.tar.gz", + "archive": "https://static.devin.ai/cli/3000.1.23/devin-3000.1.23-aarch64-apple-darwin.tar.gz", "cmd": "./bin/devin", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://static.devin.ai/cli/2026.8.18/devin-2026.8.18-x86_64-apple-darwin.tar.gz", + "archive": "https://static.devin.ai/cli/3000.1.23/devin-3000.1.23-x86_64-apple-darwin.tar.gz", "cmd": "./bin/devin", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://static.devin.ai/cli/2026.8.18/devin-2026.8.18-aarch64-unknown-linux.tar.gz", + "archive": "https://static.devin.ai/cli/3000.1.23/devin-3000.1.23-aarch64-unknown-linux.tar.gz", "cmd": "./bin/devin", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://static.devin.ai/cli/2026.8.18/devin-2026.8.18-x86_64-unknown-linux.tar.gz", + "archive": "https://static.devin.ai/cli/3000.1.23/devin-3000.1.23-x86_64-unknown-linux.tar.gz", "cmd": "./bin/devin", "args": [ "acp" ] }, "windows-aarch64": { - "archive": "https://static.devin.ai/cli/2026.8.18/devin-2026.8.18-aarch64-pc-windows.zip", + "archive": "https://static.devin.ai/cli/3000.1.23/devin-3000.1.23-aarch64-pc-windows.zip", "cmd": "./bin\\devin.exe", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://static.devin.ai/cli/2026.8.18/devin-2026.8.18-x86_64-pc-windows.zip", + "archive": "https://static.devin.ai/cli/3000.1.23/devin-3000.1.23-x86_64-pc-windows.zip", "cmd": "./bin\\devin.exe", "args": [ "acp" @@ -467,7 +467,7 @@ { "id": "dimcode", "name": "DimCode", - "version": "0.2.17", + "version": "0.2.19", "description": "A coding agent that puts leading models at your command.", "website": "https://dimcode.dev/docs/acp.html", "authors": [ @@ -476,7 +476,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "dimcode@0.2.17", + "package": "dimcode@0.2.19", "args": [ "acp" ] @@ -534,7 +534,7 @@ { "id": "fast-agent", "name": "fast-agent", - "version": "0.8.3", + "version": "0.9.1", "description": "Code and build agents with comprehensive multi-provider support", "repository": "https://github.com/evalstate/fast-agent", "website": "https://fast-agent.ai", @@ -544,7 +544,7 @@ "license": "Apache 2.0", "distribution": { "uvx": { - "package": "fast-agent-acp==0.8.3", + "package": "fast-agent-acp==0.9.1", "args": [ "-x" ] @@ -666,7 +666,7 @@ { "id": "grok-build", "name": "Grok Build", - "version": "0.2.84", + "version": "0.2.86", "description": "xAI's coding agent and CLI", "website": "https://x.ai/cli", "authors": [ @@ -675,7 +675,7 @@ "license": "proprietary", "distribution": { "npx": { - "package": "@xai-official/grok@0.2.84", + "package": "@xai-official/grok@0.2.86", "args": [ "agent", "stdio" @@ -739,7 +739,7 @@ { "id": "kilo", "name": "Kilo", - "version": "7.3.54", + "version": "7.4.1", "description": "The open source coding agent", "repository": "https://github.com/Kilo-Org/kilocode", "website": "https://kilo.ai/", @@ -751,35 +751,35 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/kilo-darwin-arm64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/kilo-darwin-arm64.zip", "cmd": "./kilo", "args": [ "acp" ] }, "darwin-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/kilo-darwin-x64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/kilo-darwin-x64.zip", "cmd": "./kilo", "args": [ "acp" ] }, "linux-aarch64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/kilo-linux-arm64.tar.gz", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/kilo-linux-arm64.tar.gz", "cmd": "./kilo", "args": [ "acp" ] }, "linux-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/kilo-linux-x64.tar.gz", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/kilo-linux-x64.tar.gz", "cmd": "./kilo", "args": [ "acp" ] }, "windows-x86_64": { - "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/kilo-windows-x64.zip", + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/kilo-windows-x64.zip", "cmd": "./kilo.exe", "args": [ "acp" @@ -787,7 +787,7 @@ } }, "npx": { - "package": "@kilocode/cli@7.3.54", + "package": "@kilocode/cli@7.4.1", "args": [ "acp" ] @@ -869,7 +869,7 @@ { "id": "mistral-vibe", "name": "Mistral Vibe", - "version": "2.18.4", + "version": "2.19.0", "description": "Mistral's open-source coding assistant", "repository": "https://github.com/mistralai/mistral-vibe", "website": "https://mistral.ai/products/vibe", @@ -881,23 +881,23 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-darwin-aarch64-2.18.4.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.19.0/vibe-acp-darwin-aarch64-2.19.0.zip", "cmd": "./vibe-acp" }, "darwin-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-darwin-x86_64-2.18.4.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.19.0/vibe-acp-darwin-x86_64-2.19.0.zip", "cmd": "./vibe-acp" }, "linux-aarch64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-linux-aarch64-2.18.4.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.19.0/vibe-acp-linux-aarch64-2.19.0.zip", "cmd": "./vibe-acp" }, "linux-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-linux-x86_64-2.18.4.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.19.0/vibe-acp-linux-x86_64-2.19.0.zip", "cmd": "./vibe-acp" }, "windows-x86_64": { - "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.4/vibe-acp-windows-x86_64-2.18.4.zip", + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.19.0/vibe-acp-windows-x86_64-2.19.0.zip", "cmd": "./vibe-acp.exe" } } @@ -1082,7 +1082,7 @@ { "id": "qwen-code", "name": "Qwen Code", - "version": "0.19.5", + "version": "0.19.6", "description": "Alibaba's Qwen coding assistant", "repository": "https://github.com/QwenLM/qwen-code", "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview", @@ -1092,7 +1092,7 @@ "license": "Apache-2.0", "distribution": { "npx": { - "package": "@qwen-code/qwen-code@0.19.5", + "package": "@qwen-code/qwen-code@0.19.6", "args": [ "--acp", "--experimental-skills" @@ -1104,7 +1104,7 @@ { "id": "sigit", "name": "siGit Code", - "version": "1.3.1", + "version": "1.3.2", "description": "Local-first coding agent. Runs entirely on your machine with optional on-device LLM inference via Onde.", "repository": "https://github.com/getsigit/sigit", "website": "https://github.com/getsigit/sigit", @@ -1115,32 +1115,32 @@ "distribution": { "binary": { "darwin-aarch64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-macos-arm64.tar.gz", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.2/sigit-macos-arm64.tar.gz", "cmd": "./sigit" }, "darwin-x86_64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-macos-amd64.tar.gz", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.2/sigit-macos-amd64.tar.gz", "cmd": "./sigit" }, "linux-aarch64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-linux-arm64", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.2/sigit-linux-arm64", "cmd": "./sigit-linux-arm64" }, "linux-x86_64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-linux-amd64", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.2/sigit-linux-amd64", "cmd": "./sigit-linux-amd64" }, "windows-aarch64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-win-arm64.exe", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.2/sigit-win-arm64.exe", "cmd": "./sigit-win-arm64.exe" }, "windows-x86_64": { - "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.1/sigit-win-amd64.exe", + "archive": "https://github.com/getsigit/sigit/releases/download/v1.3.2/sigit-win-amd64.exe", "cmd": "./sigit-win-amd64.exe" } }, "npx": { - "package": "@smbcloud/sigit@1.3.1" + "package": "@smbcloud/sigit@1.3.2" } }, "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/sigit.svg" diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index ec8787ccb..b0ce3534e 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -6289,6 +6289,51 @@ }, "type": "chat" }, + { + "id": "glm-5.2", + "name": "GLM-5.2", + "display_name": "GLM-5.2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "cost": { + "input": 1.1, + "output": 3.851, + "cache_read": 0.275, + "cache_write": 0 + }, + "type": "chat" + }, { "id": "qwen3.6-flash", "name": "Qwen3.6 Flash", @@ -41235,16 +41280,16 @@ "temperature": true, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true }, "attachment": true, - "open_weights": false, - "knowledge": "2025-05", - "release_date": "2025-08-12", - "last_updated": "2025-08-12", + "open_weights": true, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", "cost": { - "input": 0.4, - "output": 2 + "input": 1.5, + "output": 7.5 }, "type": "chat" }, @@ -46450,6 +46495,50 @@ }, "type": "chat" }, + { + "id": "glm-5.1", + "name": "GLM-5.1", + "display_name": "GLM-5.1", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 200000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-04-07", + "last_updated": "2026-04-07", + "cost": { + "input": 0.975, + "output": 4.3, + "cache_read": 0.26 + }, + "type": "chat" + }, { "id": "deepseek-v4-pro", "name": "DeepSeek V4 Pro", @@ -46616,6 +46705,50 @@ }, "type": "chat" }, + { + "id": "glm-5.2", + "name": "GLM-5.2", + "display_name": "GLM-5.2", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-06-13", + "last_updated": "2026-06-13", + "cost": { + "input": 1.05, + "output": 4.4, + "cache_read": 0.21 + }, + "type": "chat" + }, { "id": "anthropic-claude-opus-4.5", "name": "Claude Opus 4.5", @@ -49405,9 +49538,9 @@ "release_date": "2026-06-13", "last_updated": "2026-06-16", "cost": { - "input": 0.9, - "output": 4.3, - "cache_read": 0.2 + "input": 0.75, + "output": 3.5, + "cache_read": 0.16 }, "type": "chat" }, @@ -50072,8 +50205,9 @@ "release_date": "2026-04-02", "last_updated": "2026-06-11", "cost": { - "input": 0.1625, - "output": 0.5 + "input": 0.13, + "output": 0.4, + "cache_read": 0.05 }, "type": "chat" }, @@ -50225,8 +50359,9 @@ "release_date": "2026-01-16", "last_updated": "2026-06-11", "cost": { - "input": 0.25, - "output": 1.5 + "input": 0.21, + "output": 1.9, + "cache_read": 0.1 }, "type": "chat" }, @@ -50261,9 +50396,9 @@ "release_date": "2026-06-11", "last_updated": "2026-06-11", "cost": { - "input": 0.175, - "output": 0.35, - "cache_read": 0.0625 + "input": 0.14, + "output": 0.28, + "cache_read": 0.05 }, "type": "chat" }, @@ -50642,7 +50777,9 @@ "display_name": "MiniMax M3 Preview", "modalities": { "input": [ - "text" + "text", + "image", + "video" ], "output": [ "text" @@ -50657,7 +50794,7 @@ "supported": true, "default": true }, - "attachment": false, + "attachment": true, "open_weights": true, "release_date": "2026-06-12", "last_updated": "2026-06-13", @@ -50886,8 +51023,9 @@ "release_date": "2026-02-04", "last_updated": "2026-06-11", "cost": { - "input": 0.14, - "output": 0.8 + "input": 0.07, + "output": 0.4, + "cache_read": 0.035 }, "type": "chat" }, @@ -51297,9 +51435,9 @@ "release_date": "2026-04-07", "last_updated": "2026-06-11", "cost": { - "input": 1.75, - "output": 5.5, - "cache_read": 0.325 + "input": 1.54, + "output": 4.84, + "cache_read": 0.286 }, "type": "chat" }, @@ -51525,9 +51663,9 @@ "release_date": "2026-02-12", "last_updated": "2026-06-11", "cost": { - "input": 0.34, - "output": 1.19, - "cache_read": 0.04 + "input": 0.27, + "output": 0.95, + "cache_read": 0.03 }, "type": "chat" }, @@ -63188,13 +63326,13 @@ "temperature": true, "tool_call": true, "reasoning": { - "supported": false + "supported": true, + "default": true }, "attachment": true, - "open_weights": false, - "knowledge": "2025-05", - "release_date": "2025-08-12", - "last_updated": "2025-08-12", + "open_weights": true, + "release_date": "2026-04-29", + "last_updated": "2026-04-29", "cost": { "input": 0.4, "output": 2 @@ -81163,11 +81301,12 @@ "temperature": true, "tool_call": true, "reasoning": { - "supported": true + "supported": false, + "default": false }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": false } }, "attachment": true, @@ -92577,7 +92716,7 @@ "reasoning": { "supported": false }, - "attachment": false, + "attachment": true, "open_weights": true, "release_date": "2025-04-05", "last_updated": "2025-04-05", @@ -92608,7 +92747,7 @@ "reasoning": { "supported": false }, - "attachment": false, + "attachment": true, "open_weights": true, "release_date": "2025-04-05", "last_updated": "2025-04-05", @@ -92796,7 +92935,8 @@ "modalities": { "input": [ "text", - "image" + "image", + "video" ], "output": [ "text" @@ -92901,14 +93041,72 @@ "type": "chat" }, { - "id": "Qwen/Qwen3.5-397B-A17B", - "name": "Qwen 3.5 397B A17B", - "display_name": "Qwen 3.5 397B A17B", + "id": "Qwen/Qwen3.7-Max", + "name": "Qwen3.7 Max", + "display_name": "Qwen3.7 Max", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "cost": { + "input": 2.5, + "output": 7.5, + "cache_read": 0.5, + "tiers": [ + { + "input": 5, + "output": 15, + "cache_read": 1, + "tier": { + "type": "context", + "size": 32000 + } + }, + { + "input": 6.25, + "output": 18.5, + "cache_read": 1.25, + "tier": { + "type": "context", + "size": 128000 + } + } + ] + }, + "type": "chat" + }, + { + "id": "Qwen/Qwen3.6-27B", + "name": "Qwen3.6 27B", + "display_name": "Qwen3.6 27B", "modalities": { "input": [ "text", "image", - "video" + "video", + "audio" ], "output": [ "text" @@ -92916,7 +93114,7 @@ }, "limit": { "context": 262144, - "output": 81920 + "output": 65536 }, "temperature": true, "tool_call": true, @@ -92937,20 +93135,18 @@ }, "attachment": true, "open_weights": true, - "knowledge": "2025-01", - "release_date": "2026-02-01", - "last_updated": "2026-04-20", + "release_date": "2026-04-22", + "last_updated": "2026-04-22", "cost": { - "input": 0.45, - "output": 3, - "cache_read": 0.22 + "input": 0.32, + "output": 3.2 }, "type": "chat" }, { - "id": "Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo", - "name": "Qwen3 Coder 480B A35B Instruct Turbo", - "display_name": "Qwen3 Coder 480B A35B Instruct Turbo", + "id": "Qwen/Qwen3-Next-80B-A3B-Instruct", + "name": "Qwen3-Next 80B-A3B Instruct", + "display_name": "Qwen3-Next 80B-A3B Instruct", "modalities": { "input": [ "text" @@ -92961,7 +93157,7 @@ }, "limit": { "context": 262144, - "output": 66536 + "output": 32768 }, "temperature": true, "tool_call": true, @@ -92971,18 +93167,343 @@ "attachment": false, "open_weights": true, "knowledge": "2025-04", - "release_date": "2025-07-23", - "last_updated": "2025-07-23", + "release_date": "2025-09", + "last_updated": "2025-09", "cost": { - "input": 0.3, - "output": 1 + "input": 0.09, + "output": 1.1 }, "type": "chat" }, { - "id": "Qwen/Qwen3.5-35B-A3B", - "name": "Qwen 3.5 35B A3B", - "display_name": "Qwen 3.5 35B A3B", + "id": "Qwen/Qwen3-Max", + "name": "Qwen3 Max", + "display_name": "Qwen3 Max", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 256000, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": false, + "knowledge": "2025-04", + "release_date": "2025-09-23", + "last_updated": "2025-09-23", + "cost": { + "input": 1.2, + "output": 6, + "cache_read": 0.24, + "tiers": [ + { + "input": 2.4, + "output": 12, + "cache_read": 0.48, + "tier": { + "type": "context", + "size": 32000 + } + }, + { + "input": 3, + "output": 15, + "cache_read": 0.6, + "tier": { + "type": "context", + "size": 128000 + } + } + ] + }, + "type": "chat" + }, + { + "id": "Qwen/Qwen3.5-397B-A17B", + "name": "Qwen 3.5 397B A17B", + "display_name": "Qwen 3.5 397B A17B", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 81920 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "knowledge": "2025-01", + "release_date": "2026-02-01", + "last_updated": "2026-04-20", + "cost": { + "input": 0.45, + "output": 3, + "cache_read": 0.22 + }, + "type": "chat" + }, + { + "id": "Qwen/Qwen3.5-122B-A10B", + "name": "Qwen3.5 122B-A10B", + "display_name": "Qwen3.5 122B-A10B", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 16384, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "cost": { + "input": 0.29, + "output": 2.4 + }, + "type": "chat" + }, + { + "id": "Qwen/Qwen3.5-27B", + "name": "Qwen3.5 27B", + "display_name": "Qwen3.5 27B", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "cost": { + "input": 0.26, + "output": 2.6 + }, + "type": "chat" + }, + { + "id": "Qwen/Qwen3.5-9B", + "name": "Qwen3.5 9B", + "display_name": "Qwen3.5 9B", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-02-23", + "last_updated": "2026-02-23", + "cost": { + "input": 0.1, + "output": 0.15 + }, + "type": "chat" + }, + { + "id": "Qwen/Qwen3-32B", + "name": "Qwen3 32B", + "display_name": "Qwen3 32B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 40960, + "output": 16384 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-04", + "last_updated": "2025-04", + "cost": { + "input": 0.08, + "output": 0.28 + }, + "type": "chat" + }, + { + "id": "Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo", + "name": "Qwen3 Coder 480B A35B Instruct Turbo", + "display_name": "Qwen3 Coder 480B A35B Instruct Turbo", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "knowledge": "2025-04", + "release_date": "2025-07-23", + "last_updated": "2025-07-23", + "cost": { + "input": 0.3, + "output": 1, + "cache_read": 0.1 + }, + "type": "chat" + }, + { + "id": "Qwen/Qwen3.5-35B-A3B", + "name": "Qwen 3.5 35B A3B", + "display_name": "Qwen 3.5 35B A3B", "modalities": { "input": [ "text", @@ -93059,7 +93580,7 @@ "last_updated": "2025-08-05", "cost": { "input": 0.039, - "output": 0.19 + "output": 0.17 }, "type": "chat" }, @@ -93144,23 +93665,7 @@ "cost": { "input": 0.4, "output": 2, - "cache_read": 0.08, - "tiers": [ - { - "input": 0.8, - "output": 4, - "cache_read": 0.16, - "tier": { - "type": "context", - "size": 256000 - } - } - ], - "context_over_200k": { - "input": 0.8, - "output": 4, - "cache_read": 0.16 - } + "cache_read": 0.08 }, "type": "chat" }, @@ -93170,7 +93675,8 @@ "display_name": "MiMo-V2.5-Pro", "modalities": { "input": [ - "text" + "text", + "audio" ], "output": [ "text" @@ -93197,7 +93703,7 @@ ] } }, - "attachment": false, + "attachment": true, "open_weights": true, "knowledge": "2024-12", "release_date": "2026-04-22", @@ -93205,23 +93711,106 @@ "cost": { "input": 1, "output": 3, - "cache_read": 0.2, - "tiers": [ - { - "input": 2, - "output": 6, - "cache_read": 0.4, - "tier": { - "type": "context", - "size": 256000 - } - } + "cache_read": 0.2 + }, + "type": "chat" + }, + { + "id": "nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning", + "name": "Nemotron 3 Nano Omni 30B A3B Reasoning", + "display_name": "Nemotron 3 Nano Omni 30B A3B Reasoning", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" ], - "context_over_200k": { - "input": 2, - "output": 6, - "cache_read": 0.4 - } + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-04-28", + "last_updated": "2026-04-28", + "cost": { + "input": 0.2, + "output": 0.8 + }, + "type": "chat" + }, + { + "id": "nvidia/Nemotron-3-Nano-30B-A3B", + "name": "Nemotron 3 Nano 30B A3B", + "display_name": "Nemotron 3 Nano 30B A3B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2025-12-15", + "last_updated": "2025-12-15", + "cost": { + "input": 0.05, + "output": 0.2 + }, + "type": "chat" + }, + { + "id": "nvidia/Llama-3.3-Nemotron-Super-49B-v1.5", + "name": "Llama 3.3 Nemotron Super 49B v1.5", + "display_name": "Llama 3.3 Nemotron Super 49B v1.5", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 131072, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": true, + "release_date": "2025-07-25", + "last_updated": "2025-07-25", + "cost": { + "input": 0.4, + "output": 0.4 }, "type": "chat" }, @@ -93265,7 +93854,8 @@ "last_updated": "2026-01-19", "cost": { "input": 0.06, - "output": 0.4 + "output": 0.4, + "cache_read": 0.01 }, "type": "chat" }, @@ -93442,7 +94032,7 @@ "release_date": "2026-06-13", "last_updated": "2026-06-13", "cost": { - "input": 0.95, + "input": 0.93, "output": 3, "cache_read": 0.18 }, @@ -93577,9 +94167,9 @@ "release_date": "2026-04-24", "last_updated": "2026-04-24", "cost": { - "input": 0.1, - "output": 0.2, - "cache_read": 0.02 + "input": 0.09, + "output": 0.18, + "cache_read": 0.018 }, "type": "chat" }, @@ -93714,8 +94304,91 @@ "cost": { "input": 0.15, "output": 1.15, - "cache_read": 0.03, - "cache_write": 0.375 + "cache_read": 0.03 + }, + "type": "chat" + }, + { + "id": "MiniMaxAI/MiniMax-M3", + "name": "MiniMax-M3", + "display_name": "MiniMax-M3", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 524288, + "output": 128000 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-06-01", + "last_updated": "2026-06-01", + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06 + }, + "type": "chat" + }, + { + "id": "MiniMaxAI/MiniMax-M2.7", + "name": "MiniMax-M2.7", + "display_name": "MiniMax-M2.7", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 196608, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-03-18", + "last_updated": "2026-03-18", + "cost": { + "input": 0.25, + "output": 1, + "cache_read": 0.05 }, "type": "chat" } @@ -95100,11 +95773,12 @@ "temperature": true, "tool_call": true, "reasoning": { - "supported": true + "supported": false, + "default": false }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": false } }, "attachment": false, @@ -99179,9 +99853,9 @@ "type": "chat" }, { - "id": "z-ai/glm-5.1", - "name": "GLM-5.1", - "display_name": "GLM-5.1", + "id": "z-ai/glm-5.2", + "name": "GLM-5.2", + "display_name": "GLM-5.2", "modalities": { "input": [ "text" @@ -99191,7 +99865,7 @@ ] }, "limit": { - "context": 131072, + "context": 1000000, "output": 131072 }, "temperature": true, @@ -99213,8 +99887,8 @@ }, "attachment": false, "open_weights": true, - "release_date": "2026-03-27", - "last_updated": "2026-03-27", + "release_date": "2026-06-13", + "last_updated": "2026-06-13", "cost": { "input": 0, "output": 0 @@ -110792,6 +111466,11 @@ "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, "attachment": true, "open_weights": true, "knowledge": "2025-04-01", @@ -112326,6 +113005,59 @@ } ] }, + "longcat": { + "id": "longcat", + "name": "LongCat", + "display_name": "LongCat", + "api": "https://api.longcat.chat/openai", + "doc": "https://longcat.chat/platform/docs/", + "models": [ + { + "id": "LongCat-2.0", + "name": "LongCat-2.0", + "display_name": "LongCat-2.0", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1000000, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": false, + "release_date": "2026-06-30", + "last_updated": "2026-06-30", + "cost": { + "input": 0.75, + "output": 2.95, + "cache_read": 0.015 + }, + "type": "chat" + } + ] + }, "poe": { "id": "poe", "name": "Poe", @@ -124263,50 +124995,6 @@ }, "type": "chat" }, - { - "id": "zai-org/GLM-5", - "name": "GLM-5", - "display_name": "GLM-5", - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "limit": { - "context": 202752, - "output": 202752 - }, - "temperature": true, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "attachment": false, - "open_weights": true, - "release_date": "2026-02-12", - "last_updated": "2026-02-12", - "cost": { - "input": 1, - "output": 3.2, - "cache_read": 0.5 - }, - "type": "chat" - }, { "id": "zai-org/GLM-5.2", "name": "GLM-5.2", @@ -166748,6 +167436,11 @@ "supported": true, "default": true }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, "attachment": true, "open_weights": false, "release_date": "2026-05-22", @@ -211089,6 +211782,129 @@ }, "type": "chat" }, + { + "id": "gemini-3.1-flash-image", + "name": "gemini-3.1-flash-image", + "display_name": "gemini-3.1-flash-image", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "type": "imageGeneration" + }, + { + "id": "gemini-3-pro-image", + "name": "gemini-3-pro-image", + "display_name": "gemini-3-pro-image", + "modalities": { + "input": [ + "text", + "image", + "video", + "audio" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "level", + "level": "high", + "level_options": [ + "low", + "high" + ], + "summaries": true, + "visibility": "summary", + "continuation": [ + "thought_signatures" + ] + } + }, + "type": "imageGeneration" + }, + { + "id": "gemini-2.5-flash-image", + "name": "gemini-2.5-flash-image", + "display_name": "gemini-2.5-flash-image", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "imageGeneration" + }, + { + "id": "gpt-image-2", + "name": "gpt-image-2", + "display_name": "gpt-image-2", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text", + "image" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "type": "imageGeneration" + }, { "id": "elephant", "name": "Ling-2.6-flash", @@ -214586,6 +215402,140 @@ }, "type": "chat" }, + { + "id": "command-a-plus-05-2026", + "name": "command-a-plus-05-2026", + "display_name": "command-a-plus-05-2026", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 128000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true + } + }, + "cost": { + "input": 2.5, + "output": 10 + }, + "type": "chat" + }, + { + "id": "claude-opus-4-7", + "name": "claude-opus-4-7", + "display_name": "claude-opus-4-7", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 200000, + "output": 200000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", + "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", + "task_budget is separate from thinking control and should not be treated as a thinking budget." + ] + } + }, + "cost": { + "input": 5, + "output": 25, + "cache_read": 0.5 + }, + "type": "chat" + }, + { + "id": "claude-opus-4-7-think", + "name": "claude-opus-4-7-think", + "display_name": "claude-opus-4-7-think", + "modalities": { + "input": [ + "image", + "text" + ] + }, + "limit": { + "context": 200000, + "output": 200000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": true, + "mode": "effort", + "effort": "high", + "effort_options": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "interleaved": true, + "summaries": true, + "visibility": "omitted", + "continuation": [ + "thinking_blocks" + ], + "notes": [ + "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", + "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", + "task_budget is separate from thinking control and should not be treated as a thinking budget." + ] + } + }, + "cost": { + "input": 5, + "output": 25, + "cache_read": 0.5 + }, + "type": "chat" + }, { "id": "qwen3.6-27b", "name": "qwen3.6-27b", @@ -214731,9 +215681,9 @@ "type": "chat" }, { - "id": "claude-opus-4-7", - "name": "claude-opus-4-7", - "display_name": "claude-opus-4-7", + "id": "grok-4-20-non-reasoning", + "name": "grok-4-20-non-reasoning", + "display_name": "grok-4-20-non-reasoning", "modalities": { "input": [ "text", @@ -214741,60 +215691,39 @@ ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 2000000, + "output": 2000000 }, "tool_call": true, "reasoning": { - "supported": true, + "supported": false, "default": false }, "extra_capabilities": { "reasoning": { - "supported": true, - "default_enabled": false, - "mode": "effort", - "effort": "high", - "effort_options": [ - "low", - "medium", - "high", - "xhigh", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "omitted", - "continuation": [ - "thinking_blocks" - ], - "notes": [ - "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", - "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", - "task_budget is separate from thinking control and should not be treated as a thinking budget." - ] + "supported": false } }, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5 + "input": 2, + "output": 6, + "cache_read": 0.2 }, "type": "chat" }, { - "id": "claude-opus-4-7-think", - "name": "claude-opus-4-7-think", - "display_name": "claude-opus-4-7-think", + "id": "grok-4-20-reasoning", + "name": "grok-4-20-reasoning", + "display_name": "grok-4-20-reasoning", "modalities": { "input": [ - "image", - "text" + "text", + "image" ] }, "limit": { - "context": 200000, - "output": 200000 + "context": 2000000, + "output": 2000000 }, "tool_call": true, "reasoning": { @@ -214803,45 +215732,23 @@ }, "extra_capabilities": { "reasoning": { - "supported": true, - "default_enabled": true, - "mode": "effort", - "effort": "high", - "effort_options": [ - "low", - "medium", - "high", - "xhigh", - "max" - ], - "interleaved": true, - "summaries": true, - "visibility": "omitted", - "continuation": [ - "thinking_blocks" - ], - "notes": [ - "Claude Opus 4.7 and newer Opus models require thinking.type = \"adaptive\" to enable thinking explicitly.", - "Manual budget_tokens requests return 400 on Claude Opus 4.7 and newer adaptive-only Opus models.", - "task_budget is separate from thinking control and should not be treated as a thinking budget." - ] + "supported": true } }, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5 + "input": 2, + "output": 6, + "cache_read": 0.2 }, "type": "chat" }, { - "id": "cohere-rerank-v4.0-fast", - "name": "cohere-rerank-v4.0-fast", - "display_name": "cohere-rerank-v4.0-fast", + "id": "mai-image-2e", + "name": "mai-image-2e", + "display_name": "mai-image-2e", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { @@ -214853,16 +215760,16 @@ "supported": false }, "cost": { - "input": 0.068, - "output": 0, - "cache_read": 0.068 + "input": 5, + "output": 19.5, + "cache_read": 0 }, - "type": "rerank" + "type": "imageGeneration" }, { - "id": "cohere-rerank-v4.0-pro", - "name": "cohere-rerank-v4.0-pro", - "display_name": "cohere-rerank-v4.0-pro", + "id": "qwen-image-2.0", + "name": "qwen-image-2.0", + "display_name": "qwen-image-2.0", "modalities": { "input": [ "text", @@ -214878,40 +215785,16 @@ "supported": false }, "cost": { - "input": 0.068, + "input": 2, "output": 0, - "cache_read": 0.068 - }, - "type": "rerank" - }, - { - "id": "mai-image-2e", - "name": "mai-image-2e", - "display_name": "mai-image-2e", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 5, - "output": 19.5, "cache_read": 0 }, "type": "imageGeneration" }, { - "id": "qwen-image-2.0", - "name": "qwen-image-2.0", - "display_name": "qwen-image-2.0", + "id": "qwen-image-2.0-pro", + "name": "qwen-image-2.0-pro", + "display_name": "qwen-image-2.0-pro", "modalities": { "input": [ "text", @@ -214934,9 +215817,9 @@ "type": "imageGeneration" }, { - "id": "qwen-image-2.0-pro", - "name": "qwen-image-2.0-pro", - "display_name": "qwen-image-2.0-pro", + "id": "cohere-rerank-v4.0-fast", + "name": "cohere-rerank-v4.0-fast", + "display_name": "cohere-rerank-v4.0-fast", "modalities": { "input": [ "text", @@ -214952,16 +215835,16 @@ "supported": false }, "cost": { - "input": 2, + "input": 0.068, "output": 0, - "cache_read": 0 + "cache_read": 0.068 }, - "type": "imageGeneration" + "type": "rerank" }, { - "id": "grok-4-20-non-reasoning", - "name": "grok-4-20-non-reasoning", - "display_name": "grok-4-20-non-reasoning", + "id": "cohere-rerank-v4.0-pro", + "name": "cohere-rerank-v4.0-pro", + "display_name": "cohere-rerank-v4.0-pro", "modalities": { "input": [ "text", @@ -214969,39 +215852,34 @@ ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 8192, + "output": 8192 }, - "tool_call": true, + "tool_call": false, "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true - } + "supported": false }, "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 + "input": 0.068, + "output": 0, + "cache_read": 0.068 }, - "type": "chat" + "type": "rerank" }, { - "id": "grok-4-20-reasoning", - "name": "grok-4-20-reasoning", - "display_name": "grok-4-20-reasoning", + "id": "qwen3.6-plus", + "name": "qwen3.6-plus", + "display_name": "qwen3.6-plus", "modalities": { "input": [ "text", - "image" + "image", + "video" ] }, "limit": { - "context": 2000000, - "output": 2000000 + "context": 991000, + "output": 991000 }, "tool_call": true, "reasoning": { @@ -215010,13 +215888,19 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, "cost": { - "input": 2, - "output": 6, - "cache_read": 0.2 + "input": 0.282, + "output": 1.692, + "cache_read": 0.0282 }, "type": "chat" }, @@ -215241,149 +216125,6 @@ }, "type": "chat" }, - { - "id": "qwen3.6-plus", - "name": "qwen3.6-plus", - "display_name": "qwen3.6-plus", - "modalities": { - "input": [ - "text", - "image", - "video" - ] - }, - "limit": { - "context": 991000, - "output": 991000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": true - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "interleaved": true, - "summaries": true, - "visibility": "summary", - "continuation": [ - "thinking_blocks" - ] - } - }, - "cost": { - "input": 0.282, - "output": 1.692, - "cache_read": 0.0282 - }, - "type": "chat" - }, - { - "id": "gemma-4-26b-a4b-it", - "name": "gemma-4-26b-a4b-it", - "display_name": "gemma-4-26b-a4b-it", - "limit": { - "context": 262100, - "output": 262100 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.14, - "output": 0.39998, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "gemma-4-31b-it", - "name": "gemma-4-31b-it", - "display_name": "gemma-4-31b-it", - "limit": { - "context": 262100, - "output": 262100 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.14, - "output": 0.39998, - "cache_read": 0 - }, - "type": "chat" - }, - { - "id": "gpt-5.4", - "name": "gpt-5.4", - "display_name": "gpt-5.4", - "modalities": { - "input": [ - "text", - "image" - ] - }, - "limit": { - "context": 400000, - "output": 400000 - }, - "tool_call": true, - "reasoning": { - "supported": true, - "default": false - }, - "extra_capabilities": { - "reasoning": { - "supported": true, - "default_enabled": false, - "mode": "effort", - "effort": "none", - "effort_options": [ - "none", - "low", - "medium", - "high", - "xhigh" - ], - "verbosity": "medium", - "verbosity_options": [ - "low", - "medium", - "high" - ], - "visibility": "hidden" - } - }, - "cost": { - "input": 2.5, - "output": 15, - "cache_read": 0.25 - }, - "type": "chat" - }, - { - "id": "cc-k2.6-code-preview", - "name": "cc-k2.6-code-preview", - "display_name": "cc-k2.6-code-preview", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.2, - "output": 0.2, - "cache_read": 0.02 - }, - "type": "chat" - }, { "id": "wan2.7-image-pro", "name": "wan2.7-image-pro", @@ -215434,6 +216175,111 @@ }, "type": "imageGeneration" }, + { + "id": "cc-k2.6-code-preview", + "name": "cc-k2.6-code-preview", + "display_name": "cc-k2.6-code-preview", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.2, + "output": 0.2, + "cache_read": 0.02 + }, + "type": "chat" + }, + { + "id": "gemma-4-26b-a4b-it", + "name": "gemma-4-26b-a4b-it", + "display_name": "gemma-4-26b-a4b-it", + "limit": { + "context": 262100, + "output": 262100 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.14, + "output": 0.39998, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "gemma-4-31b-it", + "name": "gemma-4-31b-it", + "display_name": "gemma-4-31b-it", + "limit": { + "context": 262100, + "output": 262100 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.14, + "output": 0.39998, + "cache_read": 0 + }, + "type": "chat" + }, + { + "id": "gpt-5.4", + "name": "gpt-5.4", + "display_name": "gpt-5.4", + "modalities": { + "input": [ + "text", + "image" + ] + }, + "limit": { + "context": 400000, + "output": 400000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": false + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "default_enabled": false, + "mode": "effort", + "effort": "none", + "effort_options": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "verbosity": "medium", + "verbosity_options": [ + "low", + "medium", + "high" + ], + "visibility": "hidden" + } + }, + "cost": { + "input": 2.5, + "output": 15, + "cache_read": 0.25 + }, + "type": "chat" + }, { "id": "doubao-seed-2-0-lite-260428", "name": "doubao-seed-2-0-lite-260428", @@ -220595,12 +221441,13 @@ "type": "chat" }, { - "id": "wan2.5-t2v-preview", - "name": "wan2.5-t2v-preview", - "display_name": "wan2.5-t2v-preview", + "id": "wan2.5-i2v-preview", + "name": "wan2.5-i2v-preview", + "display_name": "wan2.5-i2v-preview", "modalities": { "input": [ - "text" + "text", + "image" ] }, "limit": { @@ -220642,13 +221489,12 @@ "type": "chat" }, { - "id": "wan2.5-i2v-preview", - "name": "wan2.5-i2v-preview", - "display_name": "wan2.5-i2v-preview", + "id": "wan2.5-t2v-preview", + "name": "wan2.5-t2v-preview", + "display_name": "wan2.5-t2v-preview", "modalities": { "input": [ - "text", - "image" + "text" ] }, "limit": { @@ -232790,24 +233636,6 @@ }, "type": "chat" }, - { - "id": "text-davinci-002", - "name": "text-davinci-002", - "display_name": "text-davinci-002", - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 20, - "output": 20 - }, - "type": "chat" - }, { "id": "tts-1", "name": "tts-1", @@ -232831,9 +233659,9 @@ } }, { - "id": "tts-1-1106", - "name": "tts-1-1106", - "display_name": "tts-1-1106", + "id": "tts-1-hd", + "name": "tts-1-hd", + "display_name": "tts-1-hd", "modalities": { "input": [ "audio" @@ -232848,8 +233676,8 @@ "supported": false }, "cost": { - "input": 15, - "output": 15 + "input": 30, + "output": 30 } }, { @@ -232946,6 +233774,29 @@ "output": 30 } }, + { + "id": "text-embedding-v1", + "name": "text-embedding-v1", + "display_name": "text-embedding-v1", + "modalities": { + "input": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 0.1, + "output": 0.1 + }, + "type": "embedding" + }, { "id": "whisper-1", "name": "whisper-1", @@ -233015,29 +233866,6 @@ }, "type": "chat" }, - { - "id": "text-embedding-v1", - "name": "text-embedding-v1", - "display_name": "text-embedding-v1", - "modalities": { - "input": [ - "text" - ] - }, - "limit": { - "context": 8192, - "output": 8192 - }, - "tool_call": false, - "reasoning": { - "supported": false - }, - "cost": { - "input": 0.1, - "output": 0.1 - }, - "type": "embedding" - }, { "id": "text-embedding-ada-002", "name": "text-embedding-ada-002", @@ -233108,9 +233936,9 @@ "type": "embedding" }, { - "id": "tts-1-hd", - "name": "tts-1-hd", - "display_name": "tts-1-hd", + "id": "tts-1-1106", + "name": "tts-1-1106", + "display_name": "tts-1-1106", "modalities": { "input": [ "audio" @@ -233125,8 +233953,8 @@ "supported": false }, "cost": { - "input": 30, - "output": 30 + "input": 15, + "output": 15 } }, { @@ -233147,6 +233975,24 @@ }, "type": "chat" }, + { + "id": "text-davinci-003", + "name": "text-davinci-003", + "display_name": "text-davinci-003", + "limit": { + "context": 8192, + "output": 8192 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 20, + "output": 20 + }, + "type": "chat" + }, { "id": "yi-large", "name": "yi-large", @@ -233256,9 +234102,9 @@ "type": "chat" }, { - "id": "text-davinci-003", - "name": "text-davinci-003", - "display_name": "text-davinci-003", + "id": "text-davinci-002", + "name": "text-davinci-002", + "display_name": "text-davinci-002", "limit": { "context": 8192, "output": 8192 @@ -237972,7 +238818,7 @@ }, "limit": { "context": 262144, - "output": 16384 + "output": 262144 }, "temperature": true, "tool_call": true, @@ -243041,7 +243887,7 @@ }, "limit": { "context": 1048576, - "output": 32768 + "output": 131072 }, "temperature": true, "tool_call": true, @@ -250585,12 +251431,16 @@ "modalities": { "input": [ "text" + ], + "output": [ + "text" ] }, "limit": { - "context": 8192, - "output": 8192 + "context": 1000000, + "output": 131072 }, + "temperature": true, "tool_call": true, "reasoning": { "supported": true, @@ -250598,13 +251448,23 @@ }, "extra_capabilities": { "reasoning": { - "supported": true + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] } }, + "attachment": false, + "open_weights": false, + "release_date": "2026-06-30", + "last_updated": "2026-06-30", "cost": { - "input": 0.7746, - "output": 3.0984, - "cache_read": 0.015492 + "input": 0.75, + "output": 2.95, + "cache_read": 0.015 }, "type": "chat" }, @@ -254240,4 +255100,4 @@ ] } } -} \ No newline at end of file +} diff --git a/scripts/generate-icon-collections.mjs b/scripts/generate-icon-collections.mjs new file mode 100644 index 000000000..1b1db8a46 --- /dev/null +++ b/scripts/generate-icon-collections.mjs @@ -0,0 +1,249 @@ +import { createRequire } from 'node:module' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +const require = createRequire(import.meta.url) +const execFileAsync = promisify(execFile) +const checkMode = process.argv.includes('--check') +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const collectionsOutputPath = path.join( + repoRoot, + 'src', + 'renderer', + 'src', + 'lib', + 'icons', + 'icon-collections.generated.ts' +) +const whitelistOutputPath = path.join( + repoRoot, + 'src', + 'renderer', + 'src', + 'lib', + 'icons', + 'icon-whitelist.generated.ts' +) + +const rendererRoots = [ + 'src/shared', + 'src/renderer/src', + 'src/renderer/settings', + 'src/renderer/browser', + 'src/renderer/floating', + 'src/renderer/splash' +].map((sourcePath) => path.join(repoRoot, sourcePath)) + +const sourceExtensions = new Set(['.ts', '.tsx', '.vue', '.js', '.mjs']) +const iconPattern = /\b(lucide|vscode-icons|line-md):([a-z0-9][a-z0-9-]*)\b/g +const whitelist = { + lucide: new Set(), + 'vscode-icons': new Set(), + 'line-md': new Set() +} + +async function collectSourceFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }) + const files = [] + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...(await collectSourceFiles(entryPath))) + continue + } + + if (entry.isFile() && sourceExtensions.has(path.extname(entry.name))) { + files.push(entryPath) + } + } + + return files +} + +function collectLiteralIcons(contents) { + for (const match of contents.matchAll(iconPattern)) { + whitelist[match[1]].add(match[2]) + } +} + +function collectDeepChatAgentPickerIcons(contents) { + const match = contents.match(/const\s+LUCIDE_ICONS\s*=\s*\[([\s\S]*?)\]/m) + if (!match) { + return + } + + for (const iconMatch of match[1].matchAll(/['"]([a-z0-9][a-z0-9-]*)['"]/g)) { + whitelist.lucide.add(iconMatch[1]) + } +} + +function createSubsetCollection(collection, iconNames) { + const icons = {} + const aliases = {} + + const addIcon = (iconName, seen = new Set()) => { + if (seen.has(iconName) || icons[iconName] || aliases[iconName]) { + return + } + seen.add(iconName) + + if (collection.icons?.[iconName]) { + icons[iconName] = collection.icons[iconName] + return + } + + const alias = collection.aliases?.[iconName] + if (alias) { + aliases[iconName] = alias + if (alias.parent) { + addIcon(alias.parent, seen) + } + } + } + + for (const iconName of iconNames) { + addIcon(iconName) + } + + return { + ...collection, + icons, + aliases + } +} + +function assertKnownIcons(collection, prefix, iconNames) { + const missing = iconNames.filter( + (iconName) => !collection.icons?.[iconName] && !collection.aliases?.[iconName] + ) + + if (missing.length > 0) { + throw new Error(`Unknown ${prefix} icons: ${missing.join(', ')}`) + } +} + +function formatGeneratedWhitelist(generatedWhitelist) { + return `/* eslint-disable */\n// Generated by scripts/generate-icon-collections.mjs. Do not edit by hand.\n\nexport type GeneratedIconCollectionKey = 'lucide' | 'vscodeIcons' | 'lineMd'\n\nexport const GENERATED_ICON_WHITELIST: Record = ${JSON.stringify( + generatedWhitelist, + null, + 2 + )} as const\n` +} + +function formatGeneratedCollections(collections) { + return `/* eslint-disable */\n// Generated by scripts/generate-icon-collections.mjs. Do not edit by hand.\n\nexport const lucideIconCollection = ${JSON.stringify( + collections.lucide, + null, + 2 + )} as const\n\nexport const vscodeIconCollection = ${JSON.stringify( + collections.vscodeIcons, + null, + 2 + )} as const\n\nexport const lineMdIconCollection = ${JSON.stringify( + collections.lineMd, + null, + 2 + )} as const\n` +} + +async function formatGeneratedOutputs(paths = [whitelistOutputPath, collectionsOutputPath]) { + const oxfmtBin = path.join(repoRoot, 'node_modules', 'oxfmt', 'bin', 'oxfmt') + + await execFileAsync(process.execPath, [oxfmtBin, ...paths], { + cwd: repoRoot + }) +} + +async function assertGeneratedOutputsFresh(whitelistOutput, collectionsOutput) { + const tempDir = await mkdtemp(path.join(tmpdir(), 'deepchat-icons-')) + const tempWhitelistPath = path.join(tempDir, 'icon-whitelist.generated.ts') + const tempCollectionsPath = path.join(tempDir, 'icon-collections.generated.ts') + + try { + await Promise.all([ + writeFile(tempWhitelistPath, whitelistOutput), + writeFile(tempCollectionsPath, collectionsOutput) + ]) + await formatGeneratedOutputs([tempWhitelistPath, tempCollectionsPath]) + + const [expectedWhitelist, expectedCollections, currentWhitelist, currentCollections] = + await Promise.all([ + readFile(tempWhitelistPath, 'utf8'), + readFile(tempCollectionsPath, 'utf8'), + readFile(whitelistOutputPath, 'utf8'), + readFile(collectionsOutputPath, 'utf8') + ]) + + const staleFiles = [] + if (expectedWhitelist !== currentWhitelist) { + staleFiles.push(path.relative(repoRoot, whitelistOutputPath)) + } + if (expectedCollections !== currentCollections) { + staleFiles.push(path.relative(repoRoot, collectionsOutputPath)) + } + + if (staleFiles.length > 0) { + throw new Error(`Generated icon files are stale: ${staleFiles.join(', ')}`) + } + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +} + +for (const sourceRoot of rendererRoots) { + const files = await collectSourceFiles(sourceRoot) + for (const file of files) { + const contents = await readFile(file, 'utf8') + collectLiteralIcons(contents) + collectDeepChatAgentPickerIcons(contents) + } +} + +const lucideCollection = require('@iconify-json/lucide/icons.json') +const vscodeCollection = require('@iconify-json/vscode-icons/icons.json') +const lineMdCollection = require('../src/renderer/src/lib/icons/line-md-theme.json') + +const lineMdIconNames = Object.keys(lineMdCollection.icons).sort() +for (const iconName of lineMdIconNames) { + whitelist['line-md'].add(iconName) +} + +const lucideIconNames = [...whitelist.lucide].sort() +const vscodeIconNames = [...whitelist['vscode-icons']].sort() +const generatedWhitelist = { + lucide: lucideIconNames, + vscodeIcons: vscodeIconNames, + lineMd: [...whitelist['line-md']].sort() +} + +assertKnownIcons(lucideCollection, 'lucide', generatedWhitelist.lucide) +assertKnownIcons(vscodeCollection, 'vscode-icons', generatedWhitelist.vscodeIcons) +assertKnownIcons(lineMdCollection, 'line-md', generatedWhitelist.lineMd) + +const generatedCollections = { + lucide: createSubsetCollection(lucideCollection, generatedWhitelist.lucide), + vscodeIcons: createSubsetCollection(vscodeCollection, generatedWhitelist.vscodeIcons), + lineMd: createSubsetCollection(lineMdCollection, generatedWhitelist.lineMd) +} + +const whitelistOutput = formatGeneratedWhitelist(generatedWhitelist) +const collectionsOutput = formatGeneratedCollections(generatedCollections) + +if (checkMode) { + await assertGeneratedOutputsFresh(whitelistOutput, collectionsOutput) +} else { + await Promise.all([ + writeFile(whitelistOutputPath, whitelistOutput), + writeFile(collectionsOutputPath, collectionsOutput) + ]) + await formatGeneratedOutputs() +} + +console.info( + `${checkMode ? 'Checked' : 'Generated'} icon collections: lucide=${generatedWhitelist.lucide.length}, vscode-icons=${generatedWhitelist.vscodeIcons.length}, line-md=${generatedWhitelist.lineMd.length}` +) diff --git a/src/main/appMain.ts b/src/main/appMain.ts index f2a14fc97..b880f05b0 100644 --- a/src/main/appMain.ts +++ b/src/main/appMain.ts @@ -2,6 +2,7 @@ import logger from '@shared/logger' import { app, dialog } from 'electron' import { LifecycleManager, registerCoreHooks } from './presenter/lifecyclePresenter' import { getInstance, Presenter } from './presenter' +import { StartupWorkloadCoordinator } from './presenter/startupWorkloadCoordinator' import { electronApp } from '@electron-toolkit/utils' import log from 'electron-log' import { registerWorkspacePreviewSchemes } from './presenter/workspacePresenter/workspacePreviewProtocol' @@ -164,6 +165,11 @@ export function startApp(): void { // Initialize lifecycle manager and register core hooks const lifecycleManager = new LifecycleManager() + const startupWorkloadCoordinator = new StartupWorkloadCoordinator() + const mainStartupRunId = startupWorkloadCoordinator.createRun('main') + const lifecycleContext = lifecycleManager.getLifecycleContext() + lifecycleContext.startupWorkloadCoordinator = startupWorkloadCoordinator + lifecycleContext.startupRunId = mainStartupRunId registerCoreHooks(lifecycleManager) function clearPresenterPermissionCaches(activePresenter?: Presenter): void { diff --git a/src/main/presenter/agentRuntimePresenter/dispatch.ts b/src/main/presenter/agentRuntimePresenter/dispatch.ts index de03bfc92..942e07cd6 100644 --- a/src/main/presenter/agentRuntimePresenter/dispatch.ts +++ b/src/main/presenter/agentRuntimePresenter/dispatch.ts @@ -1109,6 +1109,8 @@ function flushBlocksToRenderer(io: IoParams, blocks: AssistantMessageBlock[]): v requestId: io.requestId, sessionId: io.sessionId, messageId: io.messageId, + providerId: io.providerId, + modelId: io.modelId, updatedAt: Date.now(), blocks: renderedBlocks }) diff --git a/src/main/presenter/agentRuntimePresenter/echo.ts b/src/main/presenter/agentRuntimePresenter/echo.ts index f5f849605..ed981fdd8 100644 --- a/src/main/presenter/agentRuntimePresenter/echo.ts +++ b/src/main/presenter/agentRuntimePresenter/echo.ts @@ -30,6 +30,8 @@ export function startEcho(state: StreamState, io: IoParams): EchoHandle { requestId: io.requestId, sessionId: io.sessionId, messageId: io.messageId, + providerId: io.providerId, + modelId: io.modelId, updatedAt: Date.now(), blocks: renderedBlocks }) diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index 9e6cc0cbe..8e05bd259 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -3875,6 +3875,8 @@ export class AgentRuntimePresenter implements IAgentImplementation { sessionId, requestId: activeGeneration.runId, messageId, + providerId: state.providerId, + modelId: state.modelId, messageStore: this.messageStore, abortSignal: abortController.signal } diff --git a/src/main/presenter/agentRuntimePresenter/types.ts b/src/main/presenter/agentRuntimePresenter/types.ts index 4a9dd6798..25825088a 100644 --- a/src/main/presenter/agentRuntimePresenter/types.ts +++ b/src/main/presenter/agentRuntimePresenter/types.ts @@ -57,6 +57,8 @@ export interface IoParams { sessionId: string requestId: string messageId: string + providerId: string + modelId: string messageStore: DeepChatMessageStore abortSignal: AbortSignal } diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts index 158e73295..9f27c2331 100644 --- a/src/main/presenter/agentSessionPresenter/index.ts +++ b/src/main/presenter/agentSessionPresenter/index.ts @@ -58,7 +58,11 @@ import type { CONVERSATION } from '@shared/presenter' import type { SQLitePresenter } from '../sqlitePresenter' -import type { DeepChatMessageRow } from '../sqlitePresenter/tables/deepchatMessages' +import type { StartupWorkloadTaskContext } from '../startupWorkloadCoordinator' +import type { + DeepChatMessageRow, + DeepChatMessageUsageCandidateRow +} from '../sqlitePresenter/tables/deepchatMessages' import { AgentRegistry } from './agentRegistry' import { NewSessionManager } from './sessionManager' import { NewMessageManager } from './messageManager' @@ -1614,7 +1618,15 @@ export class AgentSessionPresenter { this.legacyImportService.startInBackground(false) } + async startLegacyImportTask(): Promise { + await this.legacyImportService.start(false) + } + async startUsageStatsBackfill(): Promise { + return await this.startUsageStatsBackfillTask() + } + + async startUsageStatsBackfillTask(taskContext?: StartupWorkloadTaskContext): Promise { const currentStatus = this.getUsageStatsBackfillStatus() if (currentStatus.status === 'completed') { return @@ -1628,7 +1640,7 @@ export class AgentSessionPresenter { return await this.usageStatsBackfillPromise } - this.usageStatsBackfillPromise = this.runUsageStatsBackfill().finally(() => { + this.usageStatsBackfillPromise = this.runUsageStatsBackfill(taskContext).finally(() => { this.usageStatsBackfillPromise = null }) @@ -1636,6 +1648,12 @@ export class AgentSessionPresenter { } async startMainlineNormalizationBackfill(): Promise { + return await this.startMainlineNormalizationBackfillTask() + } + + async startMainlineNormalizationBackfillTask( + taskContext?: StartupWorkloadTaskContext + ): Promise { const current = this.sqlitePresenter.configTables.getAgentSetting<{ status?: 'running' | 'completed' | 'failed' @@ -1650,14 +1668,22 @@ export class AgentSessionPresenter { return await this.mainlineNormalizationPromise } - this.mainlineNormalizationPromise = this.runMainlineNormalizationBackfill().finally(() => { - this.mainlineNormalizationPromise = null - }) + this.mainlineNormalizationPromise = this.runMainlineNormalizationBackfill(taskContext).finally( + () => { + this.mainlineNormalizationPromise = null + } + ) return await this.mainlineNormalizationPromise } async startDisabledSearchToolCleanupBackfill(): Promise { + return await this.startDisabledSearchToolCleanupBackfillTask() + } + + async startDisabledSearchToolCleanupBackfillTask( + taskContext?: StartupWorkloadTaskContext + ): Promise { const current = this.sqlitePresenter.configTables.getAgentSetting<{ status?: 'running' | 'completed' | 'failed' @@ -1672,17 +1698,24 @@ export class AgentSessionPresenter { return await this.disabledSearchToolCleanupPromise } - this.disabledSearchToolCleanupPromise = this.runDisabledSearchToolCleanupBackfill().finally( - () => { - this.disabledSearchToolCleanupPromise = null - } - ) + this.disabledSearchToolCleanupPromise = this.runDisabledSearchToolCleanupBackfill( + taskContext + ).finally(() => { + this.disabledSearchToolCleanupPromise = null + }) return await this.disabledSearchToolCleanupPromise } - async startRtkHealthCheck(): Promise { + async startRtkHealthCheck(taskContext?: StartupWorkloadTaskContext): Promise { + await this.startRtkHealthCheckTask(taskContext) + } + + async startRtkHealthCheckTask(taskContext?: StartupWorkloadTaskContext): Promise { + taskContext?.reportProgress(0) + await taskContext?.yield() await rtkRuntimeService.startHealthCheck() + taskContext?.reportProgress(1) } async retryRtkHealthCheck(): Promise { @@ -3415,91 +3448,162 @@ export class AgentSessionPresenter { } } - private async runMainlineNormalizationBackfill(): Promise { + private async runMainlineNormalizationBackfill( + taskContext?: StartupWorkloadTaskContext + ): Promise { const startedAt = Date.now() + const batchSize = 50 this.sqlitePresenter.configTables.setAgentSetting(SQLITE_MAINLINE_NORMALIZATION_KEY, { status: 'running', startedAt, finishedAt: null, - updatedAt: startedAt + updatedAt: startedAt, + processedCount: 0 }) try { const db = this.sqlitePresenter.getDatabase() - const sessionRows = db - .prepare('SELECT * FROM new_sessions ORDER BY updated_at ASC') - .all() as Array<{ - id: string - title: string - updated_at: number - }> - let processedCount = 0 - for (const sessionRow of sessionRows) { - const activeSkills = this.sqlitePresenter.newSessionsTable.getActiveSkills(sessionRow.id) - const disabledAgentTools = this.sqlitePresenter.newSessionsTable.getDisabledAgentTools( - sessionRow.id - ) - this.sqlitePresenter.newSessionActiveSkillsTable.replaceForSession( - sessionRow.id, - activeSkills - ) - this.sqlitePresenter.newSessionDisabledAgentToolsTable.replaceForSession( - sessionRow.id, - disabledAgentTools - ) - this.sqlitePresenter.deepchatSearchDocumentsTable.upsert({ - documentKey: `session:${sessionRow.id}`, - sessionId: sessionRow.id, - documentKind: 'session', - title: sessionRow.title, - content: '', - updatedAt: sessionRow.updated_at + let batchCount = 0 + const yieldForBatch = async (): Promise => { + this.sqlitePresenter.configTables.setAgentSetting(SQLITE_MAINLINE_NORMALIZATION_KEY, { + status: 'running', + startedAt, + finishedAt: null, + updatedAt: Date.now(), + processedCount }) + await (taskContext?.yield() ?? this.yieldToEventLoop()) + } - processedCount += 1 - if (processedCount % 200 === 0) { - await this.yieldToEventLoop() + let sessionCursor: { updatedAt: number; id: string } | null = null + while (true) { + const sessionRows = sessionCursor + ? db + .prepare< + [number, number, string, number], + { id: string; title: string; updated_at: number } + >( + `SELECT id, title, updated_at + FROM new_sessions + WHERE updated_at > ? OR (updated_at = ? AND id > ?) + ORDER BY updated_at ASC, id ASC + LIMIT ?` + ) + .all(sessionCursor.updatedAt, sessionCursor.updatedAt, sessionCursor.id, batchSize) + : db + .prepare<[number], { id: string; title: string; updated_at: number }>( + `SELECT id, title, updated_at + FROM new_sessions + ORDER BY updated_at ASC, id ASC + LIMIT ?` + ) + .all(batchSize) + + if (sessionRows.length === 0) { + break + } + + for (const sessionRow of sessionRows) { + const activeSkills = this.sqlitePresenter.newSessionsTable.getActiveSkills(sessionRow.id) + const disabledAgentTools = this.sqlitePresenter.newSessionsTable.getDisabledAgentTools( + sessionRow.id + ) + this.sqlitePresenter.newSessionActiveSkillsTable.replaceForSession( + sessionRow.id, + activeSkills + ) + this.sqlitePresenter.newSessionDisabledAgentToolsTable.replaceForSession( + sessionRow.id, + disabledAgentTools + ) + this.sqlitePresenter.deepchatSearchDocumentsTable.upsert({ + documentKey: `session:${sessionRow.id}`, + sessionId: sessionRow.id, + documentKind: 'session', + title: sessionRow.title, + content: '', + updatedAt: sessionRow.updated_at + }) + + sessionCursor = { updatedAt: sessionRow.updated_at, id: sessionRow.id } + processedCount += 1 + batchCount += 1 + if (batchCount >= batchSize) { + batchCount = 0 + await yieldForBatch() + } } } - const messageRows = db - .prepare('SELECT * FROM deepchat_messages ORDER BY created_at ASC') - .all() as DeepChatMessageRow[] + let messageCursor: { createdAt: number; id: string } | null = null + while (true) { + const messageRows = messageCursor + ? db + .prepare<[number, number, string, number], DeepChatMessageRow>( + `SELECT id, session_id, role, status, content, updated_at, created_at + FROM deepchat_messages + WHERE created_at > ? OR (created_at = ? AND id > ?) + ORDER BY created_at ASC, id ASC + LIMIT ?` + ) + .all(messageCursor.createdAt, messageCursor.createdAt, messageCursor.id, batchSize) + : db + .prepare<[number], DeepChatMessageRow>( + `SELECT id, session_id, role, status, content, updated_at, created_at + FROM deepchat_messages + ORDER BY created_at ASC, id ASC + LIMIT ?` + ) + .all(batchSize) + + if (messageRows.length === 0) { + break + } - for (const row of messageRows) { - this.backfillNormalizedMessageRow(row) - processedCount += 1 - if (processedCount % 200 === 0) { - this.sqlitePresenter.configTables.setAgentSetting(SQLITE_MAINLINE_NORMALIZATION_KEY, { - status: 'running', - startedAt, - finishedAt: null, - updatedAt: Date.now() - }) - await this.yieldToEventLoop() + for (const row of messageRows) { + this.backfillNormalizedMessageRow(row) + messageCursor = { createdAt: row.created_at, id: row.id } + processedCount += 1 + batchCount += 1 + if (batchCount >= batchSize) { + batchCount = 0 + await yieldForBatch() + } } } + const finishedAt = Date.now() + const durationMs = finishedAt - startedAt this.sqlitePresenter.configTables.setAgentSetting(SQLITE_MAINLINE_NORMALIZATION_KEY, { status: 'completed', startedAt, - finishedAt: Date.now(), - updatedAt: Date.now() + finishedAt, + updatedAt: finishedAt, + processedCount, + durationMs + }) + logger.info('[SQLiteMainlineNormalization] Backfill completed', { + processedCount, + durationMs }) } catch (error) { + const finishedAt = Date.now() this.sqlitePresenter.configTables.setAgentSetting(SQLITE_MAINLINE_NORMALIZATION_KEY, { status: 'failed', startedAt, - finishedAt: Date.now(), - updatedAt: Date.now(), - error: error instanceof Error ? error.message : String(error) + finishedAt, + updatedAt: finishedAt, + error: error instanceof Error ? error.message : String(error), + durationMs: finishedAt - startedAt }) throw error } } - private async runDisabledSearchToolCleanupBackfill(): Promise { + private async runDisabledSearchToolCleanupBackfill( + taskContext?: StartupWorkloadTaskContext + ): Promise { const startedAt = Date.now() this.sqlitePresenter.configTables.setAgentSetting(DISABLED_SEARCH_TOOL_CLEANUP_KEY, { status: 'running', @@ -3510,17 +3614,15 @@ export class AgentSessionPresenter { try { const db = this.sqlitePresenter.getDatabase() - const sessionRows = db - .prepare('SELECT id FROM new_sessions ORDER BY updated_at ASC') - .all() as - | Array<{ - id: string - }> - | undefined + const sessionRowsStatement = db.prepare<[], { id: string }>( + 'SELECT id FROM new_sessions ORDER BY updated_at ASC' + ) + const sessionRows = sessionRowsStatement.all() let processedCount = 0 let updatedCount = 0 - for (const sessionRow of sessionRows ?? []) { + const batchSize = 50 + for (const sessionRow of sessionRows) { const disabledAgentTools = this.sqlitePresenter.newSessionsTable.getDisabledAgentTools( sessionRow.id ) @@ -3534,8 +3636,8 @@ export class AgentSessionPresenter { } processedCount += 1 - if (processedCount % 200 === 0) { - await this.yieldToEventLoop() + if (processedCount % batchSize === 0) { + await (taskContext?.yield() ?? this.yieldToEventLoop()) } } @@ -3675,86 +3777,143 @@ export class AgentSessionPresenter { } } - private async runUsageStatsBackfill(): Promise { + private async runUsageStatsBackfill(taskContext?: StartupWorkloadTaskContext): Promise { const startedAt = Date.now() + const batchSize = 50 this.setUsageStatsBackfillStatus({ status: 'running', startedAt, finishedAt: null, error: null, - updatedAt: startedAt + updatedAt: startedAt, + processedCount: 0 }) try { const usageStatsTable = this.sqlitePresenter.deepchatUsageStatsTable - const candidates = this.sqlitePresenter.deepchatMessagesTable.listAssistantUsageCandidates() let processedCount = 0 - for (const row of candidates) { - const metadata = parseUsageMetadata(row.metadata) - if (metadata.messageType === 'compaction') { - continue - } + let scannedSinceYield = 0 + const yieldUsageStatsBackfillProgress = async (): Promise => { + this.setUsageStatsBackfillStatus({ + status: 'running', + startedAt, + finishedAt: null, + error: null, + updatedAt: Date.now(), + processedCount + }) + await (taskContext?.yield() ?? this.yieldToEventLoop()) + } - const providerId = resolveUsageProviderId(metadata, row.provider_id) - const modelId = resolveUsageModelId(metadata, row.model_id) - if (!providerId || !modelId) { - continue + let candidateCursor: { createdAt: number; id: string } | null = null + while (true) { + const candidates = this.listAssistantUsageCandidatePage(candidateCursor, batchSize) + if (candidates.length === 0) { + break } - const usageRecord = buildUsageStatsRecord({ - messageId: row.id, - sessionId: row.session_id, - createdAt: row.created_at, - updatedAt: row.updated_at, - providerId, - modelId, - metadata: { - ...metadata, - cachedInputTokens: metadata.cachedInputTokens ?? 0, - cacheWriteInputTokens: metadata.cacheWriteInputTokens ?? 0 - }, - source: 'backfill' - }) + for (const row of candidates) { + candidateCursor = { createdAt: row.created_at, id: row.id } + scannedSinceYield += 1 - if (!usageRecord) { - continue - } + const metadata = parseUsageMetadata(row.metadata) + if (metadata.messageType === 'compaction') { + continue + } - usageStatsTable.upsert(usageRecord) - processedCount += 1 + const providerId = resolveUsageProviderId(metadata, row.provider_id) + const modelId = resolveUsageModelId(metadata, row.model_id) + if (!providerId || !modelId) { + continue + } - if (processedCount % 200 === 0) { - this.setUsageStatsBackfillStatus({ - status: 'running', - startedAt, - finishedAt: null, - error: null, - updatedAt: Date.now() + const usageRecord = buildUsageStatsRecord({ + messageId: row.id, + sessionId: row.session_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + providerId, + modelId, + metadata: { + ...metadata, + cachedInputTokens: metadata.cachedInputTokens ?? 0, + cacheWriteInputTokens: metadata.cacheWriteInputTokens ?? 0 + }, + source: 'backfill' }) - await this.yieldToEventLoop() + + if (!usageRecord) { + continue + } + + usageStatsTable.upsert(usageRecord) + processedCount += 1 + } + + if (scannedSinceYield >= batchSize) { + scannedSinceYield = 0 + await yieldUsageStatsBackfillProgress() } } + const finishedAt = Date.now() + const durationMs = finishedAt - startedAt this.setUsageStatsBackfillStatus({ status: 'completed', startedAt, - finishedAt: Date.now(), + finishedAt, error: null, - updatedAt: Date.now() + updatedAt: finishedAt, + processedCount, + durationMs }) + logger.info('[UsageStatsBackfill] Backfill completed', { processedCount, durationMs }) } catch (error) { + const finishedAt = Date.now() this.setUsageStatsBackfillStatus({ status: 'failed', startedAt, - finishedAt: Date.now(), + finishedAt, error: error instanceof Error ? error.message : String(error), - updatedAt: Date.now() + updatedAt: finishedAt, + durationMs: finishedAt - startedAt }) throw error } } + private listAssistantUsageCandidatePage( + cursor: { createdAt: number; id: string } | null, + limit: number + ): DeepChatMessageUsageCandidateRow[] { + const table = this.sqlitePresenter.deepchatMessagesTable as { + listAssistantUsageCandidatesPage?: ( + cursor: { createdAt: number; id: string } | null, + limit: number + ) => DeepChatMessageUsageCandidateRow[] + listAssistantUsageCandidates: () => DeepChatMessageUsageCandidateRow[] + } + + if (table.listAssistantUsageCandidatesPage) { + return table.listAssistantUsageCandidatesPage(cursor, limit) + } + + const candidates = [...table.listAssistantUsageCandidates()].sort( + (left, right) => left.created_at - right.created_at || left.id.localeCompare(right.id) + ) + if (!cursor) { + return candidates.slice(0, limit) + } + return candidates + .filter( + (row) => + row.created_at > cursor.createdAt || + (row.created_at === cursor.createdAt && row.id > cursor.id) + ) + .slice(0, limit) + } + private getUsageStatsBackfillStatus(): UsageStatsBackfillStatus { const normalized = this.normalizeUsageStatsBackfillStatus( this.configPresenter.getSetting(DASHBOARD_STATS_BACKFILL_KEY) diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index e348bc80d..4be645ead 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -446,6 +446,8 @@ export class ConfigPresenter implements IConfigPresenter { private systemPromptHelper: SystemPromptHelper private uiSettingsHelper: UiSettingsHelper private agentRepository: AgentRepository | null = null + private pendingAcpAgentsChanged = false + private isAttachingAgentRepository = false private deepChatAgentDeleteCleanup: ((agentId: string) => Promise) | null = null private deepChatAgentMemoryMaintenanceConfigChanged: ((agentId: string) => void) | null = null private dbBackedSettingsStore: AppSettingsDbBackedStore | null = null @@ -618,11 +620,22 @@ export class ConfigPresenter implements IConfigPresenter { setAgentRepository(agentRepository: AgentRepository): void { this.agentRepository = agentRepository - this.initializeUnifiedAgents() - // The memory-maintenance callback is wired later by Presenter, so these migration writes may - // intentionally no-op for maintenance arming during construction. - this.reconcileLegacyBuiltinAgentSelections() - this.cleanupDeprecatedBuiltinAgentSelections() + this.isAttachingAgentRepository = true + try { + this.initializeUnifiedAgents() + // The memory-maintenance callback is wired later by Presenter, so these migration writes may + // intentionally no-op for maintenance arming during construction. + this.reconcileLegacyBuiltinAgentSelections() + this.cleanupDeprecatedBuiltinAgentSelections() + } finally { + this.isAttachingAgentRepository = false + if (this.pendingAcpAgentsChanged) { + this.pendingAcpAgentsChanged = false + queueMicrotask(() => { + this.notifyAcpAgentsChanged() + }) + } + } } setSQLitePresenter(sqlitePresenter: SQLitePresenter): void { @@ -2866,6 +2879,14 @@ export class ConfigPresenter implements IConfigPresenter { } private notifyAcpAgentsChanged(agentIds?: string[]) { + if (!this.agentRepository || this.isAttachingAgentRepository) { + this.pendingAcpAgentsChanged = true + logger.info( + '[ACP] notifyAcpAgentsChanged: deferred until unified agent repository is attached' + ) + return + } + logger.info('[ACP] notifyAcpAgentsChanged: sending MODEL_LIST_CHANGED event for provider "acp"') emitModelsChanged('acp') emitAcpAgentsChanged(this, agentIds) diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index e60a38a7f..1f5084849 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -1,4 +1,5 @@ import logger from '@shared/logger' +import { performance } from 'node:perf_hooks' import path from 'path' import { DialogPresenter } from './dialogPresenter/index' import { ipcMain, app } from 'electron' @@ -177,7 +178,9 @@ export class Presenter implements IPresenter { setSQLitePresenter?: (sqlitePresenter: SQLitePresenter) => void } ).setSQLitePresenter?.(this.sqlitePresenter as unknown as SQLitePresenter) - this.startupWorkloadCoordinator = new StartupWorkloadCoordinator() + this.startupWorkloadCoordinator = + (context.startupWorkloadCoordinator as StartupWorkloadCoordinator | undefined) ?? + new StartupWorkloadCoordinator() // Initialize presenters and their dependencies. this.windowPresenter = new WindowPresenter( @@ -750,7 +753,13 @@ export class Presenter implements IPresenter { const providers = this.configPresenter.getProviders() console.info(`[Startup][Main] Presenter.init begin providers=${providers.length}`) this.llmproviderPresenter.setProviders(providers) - const mainRunId = this.startupWorkloadCoordinator.createRun('main') + const context = this.lifecycleManager.getLifecycleContext() + context.startupWorkloadCoordinator = this.startupWorkloadCoordinator + const mainRunId = + typeof context.startupRunId === 'string' + ? context.startupRunId + : this.startupWorkloadCoordinator.createRun('main') + context.startupRunId = mainRunId void this.startupWorkloadCoordinator.scheduleTask({ id: 'main:floating-button', @@ -962,39 +971,59 @@ export class Presenter implements IPresenter { async destroy(): Promise { try { - await this.cronJobs.stop() + await this.runDestroyStep('cronJobs.stop', () => this.cronJobs.stop()) } catch (error) { console.error('CronJobsService.stop failed during presenter destroy:', error) } try { - await this.pluginPresenter.shutdown() + await this.runDestroyStep('pluginPresenter.shutdown', () => this.pluginPresenter.shutdown()) } catch (error) { console.error('PluginPresenter.shutdown failed during presenter destroy:', error) } try { - await this.mcpPresenter.shutdown() + await this.runDestroyStep('mcpPresenter.shutdown', () => this.mcpPresenter.shutdown()) } catch (error) { console.error('McpPresenter.shutdown failed during presenter destroy:', error) } - await this.destroyRemoteControl() + await this.runDestroyStep('destroyRemoteControl', () => this.destroyRemoteControl()) this.floatingButtonPresenter.destroy() this.tabPresenter.destroy() // Drain in-flight memory consolidation before the shared SQLite connection closes, so a pass // that already fired cannot write to a closed database during teardown. - await this.memoryPresenter.dispose() - this.sqlitePresenter.close() + await this.runDestroyStep('memoryPresenter.dispose', () => this.memoryPresenter.dispose()) + await this.runDestroyStep('sqlitePresenter.close', () => this.sqlitePresenter.close()) this.shortcutPresenter.destroy() this.syncPresenter.destroy() this.notificationPresenter.clearAllNotifications() this.knowledgePresenter.destroy() - await (this.workspacePresenter as WorkspacePresenter).destroy() - await (this.skillPresenter as SkillPresenter).destroy() + await this.runDestroyStep('workspacePresenter.destroy', () => + (this.workspacePresenter as WorkspacePresenter).destroy() + ) + await this.runDestroyStep('skillPresenter.destroy', () => + (this.skillPresenter as SkillPresenter).destroy() + ) ;(this.skillSyncPresenter as SkillSyncPresenter).destroy() } + private async runDestroyStep(stepName: string, step: () => void | Promise): Promise { + const startedAt = performance.now() + logger.info(`[Presenter] destroy.${stepName} begin`) + try { + await step() + logger.info( + `[Presenter] destroy.${stepName} done durationMs=${(performance.now() - startedAt).toFixed(1)}` + ) + } catch (error) { + logger.warn( + `[Presenter] destroy.${stepName} failed durationMs=${(performance.now() - startedAt).toFixed(1)}`, + error + ) + } + } + private async destroyRemoteControl() { try { await this.#remoteControlPresenter.destroy() diff --git a/src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts b/src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts index d171b059e..c8d952554 100644 --- a/src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts +++ b/src/main/presenter/lifecyclePresenter/DatabaseInitializer.ts @@ -11,6 +11,11 @@ import { getStartupSchemaCatalog } from '@/presenter/sqlitePresenter/schemaCatal import { classifySchemaError } from '@/presenter/sqlitePresenter/schemaErrorClassifier' import type { SchemaTableSpec } from '@/presenter/sqlitePresenter/schemaTypes' +type DatabaseInitializerOptions = { + password?: string + dbPath?: string +} + /** * Database initialization interface */ @@ -29,7 +34,7 @@ export class DatabaseInitializer implements IDatabaseInitializer { private password?: string private database?: SQLitePresenter - constructor(options?: { password?: string; dbPath?: string }) { + constructor(options?: DatabaseInitializerOptions) { // Initialize database path const dbDir = path.join(app.getPath('userData'), 'app_db') this.dbPath = options?.dbPath ?? path.join(dbDir, 'agent.db') @@ -85,7 +90,7 @@ export class DatabaseInitializer implements IDatabaseInitializer { ) this.database.close() this.database = undefined - repairSQLiteDatabaseFile(this.dbPath, this.password, { catalog }) + this.repairStartupSchema(catalog) continue } @@ -111,9 +116,7 @@ export class DatabaseInitializer implements IDatabaseInitializer { ) // Construction-time schema failures use the same startup catalog for the same reason: // keep boot-time repair scoped to tables that fresh initialization owns. - repairSQLiteDatabaseFile(this.dbPath, this.password, { - catalog: getStartupSchemaCatalog() - }) + this.repairStartupSchema(getStartupSchemaCatalog()) } } } catch (error) { @@ -174,6 +177,7 @@ export class DatabaseInitializer implements IDatabaseInitializer { return null } + const start = performance.now() try { const catalog = getStartupSchemaCatalog() return { @@ -186,6 +190,21 @@ export class DatabaseInitializer implements IDatabaseInitializer { error ) return null + } finally { + logger.info( + `DatabaseInitializer: phase=diagnose duration=${(performance.now() - start).toFixed(2)}ms` + ) + } + } + + private repairStartupSchema(catalog: SchemaTableSpec[]): void { + const start = performance.now() + try { + repairSQLiteDatabaseFile(this.dbPath, this.password, { catalog }) + } finally { + logger.info( + `DatabaseInitializer: phase=repair duration=${(performance.now() - start).toFixed(2)}ms` + ) } } diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/disabledSearchToolCleanupHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/disabledSearchToolCleanupHook.ts index 510eba707..4fb1f0d22 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/after-start/disabledSearchToolCleanupHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/disabledSearchToolCleanupHook.ts @@ -1,6 +1,10 @@ import { LifecycleHook, LifecycleContext } from '@shared/presenter' import { LifecyclePhase } from '@shared/lifecycle' import { presenter } from '@/presenter' +import type { + StartupWorkloadCoordinator, + StartupWorkloadTaskContext +} from '@/presenter/startupWorkloadCoordinator' export const disabledSearchToolCleanupHook: LifecycleHook = { name: 'disabled-search-tool-cleanup', @@ -13,17 +17,32 @@ export const disabledSearchToolCleanupHook: LifecycleHook = { } const agentSessionPresenter = presenter.agentSessionPresenter as unknown as { - startDisabledSearchToolCleanupBackfill?: () => Promise + startDisabledSearchToolCleanupBackfillTask?: ( + taskContext?: StartupWorkloadTaskContext + ) => Promise } - if (!agentSessionPresenter.startDisabledSearchToolCleanupBackfill) { + if (!agentSessionPresenter.startDisabledSearchToolCleanupBackfillTask) { return } - void agentSessionPresenter.startDisabledSearchToolCleanupBackfill().catch((error) => { - console.error( - 'disabledSearchToolCleanupHook: failed to start disabled search tool cleanup:', - error - ) - }) + const startupWorkloadCoordinator = + presenter.getStartupWorkloadCoordinator() as StartupWorkloadCoordinator + void startupWorkloadCoordinator + .scheduleTask({ + id: 'main:disabled-search-tool-cleanup', + target: 'main', + phase: 'background', + resource: 'io', + labelKey: 'startup.main.disabledSearchToolCleanup', + run: async (taskContext) => { + await agentSessionPresenter.startDisabledSearchToolCleanupBackfillTask?.(taskContext) + } + }) + .catch((error) => { + console.error( + 'disabledSearchToolCleanupHook: failed to start disabled search tool cleanup:', + error + ) + }) } } diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/legacyImportHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/legacyImportHook.ts index b5f695aad..98de9bb41 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/after-start/legacyImportHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/legacyImportHook.ts @@ -1,6 +1,7 @@ import { LifecycleHook, LifecycleContext } from '@shared/presenter' import { LifecyclePhase } from '@shared/lifecycle' import { presenter } from '@/presenter' +import type { StartupWorkloadCoordinator } from '@/presenter/startupWorkloadCoordinator' export const legacyImportHook: LifecycleHook = { name: 'legacy-import', @@ -13,15 +14,27 @@ export const legacyImportHook: LifecycleHook = { } const agentSessionPresenter = presenter.agentSessionPresenter as unknown as { - startLegacyImport?: () => Promise + startLegacyImportTask?: () => Promise } - if (!agentSessionPresenter.startLegacyImport) { + if (!agentSessionPresenter.startLegacyImportTask) { return } - // Fire and forget to avoid blocking app startup. - void agentSessionPresenter.startLegacyImport().catch((error) => { - console.error('legacyImportHook: failed to start legacy import task:', error) - }) + const startupWorkloadCoordinator = + presenter.getStartupWorkloadCoordinator() as StartupWorkloadCoordinator + void startupWorkloadCoordinator + .scheduleTask({ + id: 'main:legacy-import', + target: 'main', + phase: 'background', + resource: 'io', + labelKey: 'startup.main.legacyImport', + run: async () => { + await agentSessionPresenter.startLegacyImportTask?.() + } + }) + .catch((error) => { + console.error('legacyImportHook: failed to start legacy import task:', error) + }) } } diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/rtkHealthCheckHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/rtkHealthCheckHook.ts index 3c0f7f495..47be1d93b 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/after-start/rtkHealthCheckHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/rtkHealthCheckHook.ts @@ -1,6 +1,10 @@ import { LifecycleHook, LifecycleContext } from '@shared/presenter' import { LifecyclePhase } from '@shared/lifecycle' import { presenter } from '@/presenter' +import type { + StartupWorkloadCoordinator, + StartupWorkloadTaskContext +} from '@/presenter/startupWorkloadCoordinator' export const rtkHealthCheckHook: LifecycleHook = { name: 'rtk-health-check', @@ -13,14 +17,27 @@ export const rtkHealthCheckHook: LifecycleHook = { } const agentSessionPresenter = presenter.agentSessionPresenter as unknown as { - startRtkHealthCheck?: () => Promise + startRtkHealthCheckTask?: (taskContext?: StartupWorkloadTaskContext) => Promise } - if (!agentSessionPresenter.startRtkHealthCheck) { + if (!agentSessionPresenter.startRtkHealthCheckTask) { return } - void agentSessionPresenter.startRtkHealthCheck().catch((error) => { - console.error('rtkHealthCheckHook: failed to start RTK health check:', error) - }) + const startupWorkloadCoordinator = + presenter.getStartupWorkloadCoordinator() as StartupWorkloadCoordinator + void startupWorkloadCoordinator + .scheduleTask({ + id: 'main:rtk-health-check', + target: 'main', + phase: 'background', + resource: 'io', + labelKey: 'startup.main.rtkHealthCheck', + run: async (taskContext) => { + await agentSessionPresenter.startRtkHealthCheckTask?.(taskContext) + } + }) + .catch((error) => { + console.error('rtkHealthCheckHook: failed to start RTK health check:', error) + }) } } diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/sqliteMainlineNormalizationHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/sqliteMainlineNormalizationHook.ts index 2ef0aa771..1c3047c01 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/after-start/sqliteMainlineNormalizationHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/sqliteMainlineNormalizationHook.ts @@ -1,6 +1,10 @@ import { LifecycleHook, LifecycleContext } from '@shared/presenter' import { LifecyclePhase } from '@shared/lifecycle' import { presenter } from '@/presenter' +import type { + StartupWorkloadCoordinator, + StartupWorkloadTaskContext +} from '@/presenter/startupWorkloadCoordinator' export const sqliteMainlineNormalizationHook: LifecycleHook = { name: 'sqlite-mainline-normalization', @@ -13,17 +17,32 @@ export const sqliteMainlineNormalizationHook: LifecycleHook = { } const agentSessionPresenter = presenter.agentSessionPresenter as unknown as { - startMainlineNormalizationBackfill?: () => Promise + startMainlineNormalizationBackfillTask?: ( + taskContext?: StartupWorkloadTaskContext + ) => Promise } - if (!agentSessionPresenter.startMainlineNormalizationBackfill) { + if (!agentSessionPresenter.startMainlineNormalizationBackfillTask) { return } - void agentSessionPresenter.startMainlineNormalizationBackfill().catch((error) => { - console.error( - 'sqliteMainlineNormalizationHook: failed to start normalization backfill:', - error - ) - }) + const startupWorkloadCoordinator = + presenter.getStartupWorkloadCoordinator() as StartupWorkloadCoordinator + void startupWorkloadCoordinator + .scheduleTask({ + id: 'main:sqlite-mainline-normalization', + target: 'main', + phase: 'background', + resource: 'io', + labelKey: 'startup.main.sqliteMainlineNormalization', + run: async (taskContext) => { + await agentSessionPresenter.startMainlineNormalizationBackfillTask?.(taskContext) + } + }) + .catch((error) => { + console.error( + 'sqliteMainlineNormalizationHook: failed to start normalization backfill:', + error + ) + }) } } diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/usageStatsBackfillHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/usageStatsBackfillHook.ts index 6b3b06a42..04f915adb 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/after-start/usageStatsBackfillHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/usageStatsBackfillHook.ts @@ -1,6 +1,10 @@ import { LifecycleHook, LifecycleContext } from '@shared/presenter' import { LifecyclePhase } from '@shared/lifecycle' import { presenter } from '@/presenter' +import type { + StartupWorkloadCoordinator, + StartupWorkloadTaskContext +} from '@/presenter/startupWorkloadCoordinator' export const usageStatsBackfillHook: LifecycleHook = { name: 'usage-stats-backfill', @@ -13,14 +17,27 @@ export const usageStatsBackfillHook: LifecycleHook = { } const agentSessionPresenter = presenter.agentSessionPresenter as unknown as { - startUsageStatsBackfill?: () => Promise + startUsageStatsBackfillTask?: (taskContext?: StartupWorkloadTaskContext) => Promise } - if (!agentSessionPresenter.startUsageStatsBackfill) { + if (!agentSessionPresenter.startUsageStatsBackfillTask) { return } - void agentSessionPresenter.startUsageStatsBackfill().catch((error) => { - console.error('usageStatsBackfillHook: failed to start usage stats backfill:', error) - }) + const startupWorkloadCoordinator = + presenter.getStartupWorkloadCoordinator() as StartupWorkloadCoordinator + void startupWorkloadCoordinator + .scheduleTask({ + id: 'main:usage-stats-backfill', + target: 'main', + phase: 'background', + resource: 'io', + labelKey: 'startup.main.usageStatsBackfill', + run: async (taskContext) => { + await agentSessionPresenter.startUsageStatsBackfillTask?.(taskContext) + } + }) + .catch((error) => { + console.error('usageStatsBackfillHook: failed to start usage stats backfill:', error) + }) } } diff --git a/src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts b/src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts index 44b4bac99..d9bb53377 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/beforeStart/protocolRegistrationHook.ts @@ -7,7 +7,8 @@ import logger from '@shared/logger' import { protocol, app } from 'electron' import { LifecycleHook, LifecycleContext } from '@shared/presenter' import path from 'path' -import fs from 'fs' +import fs, { promises as fsp, Stats } from 'fs' +import { Readable } from 'stream' import { is } from '@electron-toolkit/utils' import { LifecyclePhase } from '@shared/lifecycle' import { @@ -15,6 +16,8 @@ import { WORKSPACE_PREVIEW_PROTOCOL } from '@/presenter/workspacePresenter/workspacePreviewProtocol' +const workspacePreviewMimeCache = new Map() + const getMimeTypeForPath = (filePath: string): string => { const extension = path.extname(filePath).toLowerCase() @@ -63,6 +66,86 @@ const getMimeTypeForPath = (filePath: string): string => { } } +const getDeepCdnMimeType = (filePath: string): string => { + if (filePath.endsWith('.wasm')) { + return 'application/wasm' + } + + if (filePath.endsWith('.data')) { + return 'application/octet-stream' + } + + return getMimeTypeForPath(filePath) +} + +const getWorkspacePreviewMimeType = (fullPath: string): string => { + const cached = workspacePreviewMimeCache.get(fullPath) + if (cached) { + return cached + } + + const mimeType = getMimeTypeForPath(fullPath) + workspacePreviewMimeCache.set(fullPath, mimeType) + return mimeType +} + +const resolvePathInsideRoot = (rootDir: string, requestPath: string): string | null => { + let decodedPath: string + try { + decodedPath = decodeURIComponent(requestPath.split(/[?#]/, 1)[0] ?? '') + } catch { + return null + } + + const root = path.resolve(rootDir) + const relativeRequestPath = decodedPath.replace(/^[/\\]+/, '') + const fullPath = path.resolve(root, relativeRequestPath) + const relativePath = path.relative(root, fullPath) + if (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))) { + return fullPath + } + return null +} + +const createStreamingResponse = async ( + fullPath: string, + stat: Stats, + init: ResponseInit +): Promise => { + try { + const body = Readable.toWeb(fs.createReadStream(fullPath)) as unknown as BodyInit + return new Response(body, { + ...init, + headers: { + ...init.headers, + 'Content-Length': String(stat.size) + } + }) + } catch { + const fileContent = await fsp.readFile(fullPath) + return new Response(fileContent, { + ...init, + headers: { + ...init.headers, + 'Content-Length': String(stat.size) + } + }) + } +} + +const findDeepCdnResourcesDir = async (candidates: string[]): Promise => { + for (const candidate of candidates) { + try { + await fsp.access(path.join(candidate, 'cdn')) + return candidate + } catch { + // Try the next packaged resource location. + } + } + + return candidates[0] +} + export const protocolRegistrationHook: LifecycleHook = { name: 'protocol-registration', phase: LifecyclePhase.BEFORE_START, @@ -72,7 +155,7 @@ export const protocolRegistrationHook: LifecycleHook = { logger.info('protocolRegistrationHook: Registering application protocols') // Register 'deepcdn' protocol for loading built-in resources (simulating CDN) - protocol.handle('deepcdn', (request) => { + protocol.handle('deepcdn', async (request) => { try { const filePath = request.url.slice('deepcdn://'.length) // Determine resource path based on dev/production environment @@ -83,42 +166,38 @@ export const protocolRegistrationHook: LifecycleHook = { path.join(process.resourcesPath, 'resources'), process.resourcesPath ] - const baseResourcesDir = - candidates.find((p) => fs.existsSync(path.join(p, 'cdn'))) || candidates[0] - - const fullPath = path.join(baseResourcesDir, 'cdn', filePath) - - // Determine MIME type based on file extension - let mimeType = 'application/octet-stream' // Default type - if (filePath.endsWith('.js')) { - mimeType = 'text/javascript' - } else if (filePath.endsWith('.css')) { - mimeType = 'text/css' - } else if (filePath.endsWith('.json')) { - mimeType = 'application/json' - } else if (filePath.endsWith('.wasm')) { - mimeType = 'application/wasm' - } else if (filePath.endsWith('.data')) { - mimeType = 'application/octet-stream' - } else if (filePath.endsWith('.html')) { - mimeType = 'text/html' + const baseResourcesDir = await findDeepCdnResourcesDir(candidates) + const fullPath = resolvePathInsideRoot(path.join(baseResourcesDir, 'cdn'), filePath) + if (!fullPath) { + return new Response('Forbidden', { + status: 403, + headers: { 'Content-Type': 'text/plain' } + }) } + const mimeType = getDeepCdnMimeType(filePath) - // Check if file exists - if (!fs.existsSync(fullPath)) { - console.warn(`protocolRegistrationHook: deepcdn handler: File not found: ${fullPath}`) + const stat = await fsp.stat(fullPath) + if (stat.isDirectory()) { + console.warn(`protocolRegistrationHook: deepcdn handler: File not found: ${filePath}`) return new Response(`File not found: ${filePath}`, { status: 404, headers: { 'Content-Type': 'text/plain' } }) } - // Read file and return response - const fileContent = fs.readFileSync(fullPath) - return new Response(fileContent, { + return await createStreamingResponse(fullPath, stat, { headers: { 'Content-Type': mimeType } }) } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + const filePath = request.url.slice('deepcdn://'.length) + console.warn(`protocolRegistrationHook: deepcdn handler: File not found: ${filePath}`) + return new Response(`File not found: ${filePath}`, { + status: 404, + headers: { 'Content-Type': 'text/plain' } + }) + } + console.error('protocolRegistrationHook: Error handling deepcdn request:', error) const errorMessage = error instanceof Error ? error.message : String(error) return new Response(`Server error: ${errorMessage}`, { @@ -129,14 +208,20 @@ export const protocolRegistrationHook: LifecycleHook = { }) // Register 'imgcache' protocol for handling image cache - protocol.handle('imgcache', (request) => { - try { - const filePath = request.url.slice('imgcache://'.length) - // Images are stored in the images subfolder of user data directory - const fullPath = path.join(app.getPath('userData'), 'images', filePath) + protocol.handle('imgcache', async (request) => { + const filePath = request.url.slice('imgcache://'.length) + // Images are stored in the images subfolder of user data directory + const fullPath = resolvePathInsideRoot(path.join(app.getPath('userData'), 'images'), filePath) + if (!fullPath) { + return new Response('Forbidden', { + status: 403, + headers: { 'Content-Type': 'text/plain' } + }) + } - // Check if file exists - if (!fs.existsSync(fullPath)) { + try { + const stat = await fsp.stat(fullPath) + if (stat.isDirectory()) { console.warn( `protocolRegistrationHook: imgcache handler: Image file not found: ${fullPath}` ) @@ -146,32 +231,20 @@ export const protocolRegistrationHook: LifecycleHook = { }) } - // Determine MIME type based on file extension - let mimeType = 'application/octet-stream' // Default type - if (filePath.endsWith('.png')) { - mimeType = 'image/png' - } else if (filePath.endsWith('.gif')) { - mimeType = 'image/gif' - } else if (filePath.endsWith('.webp')) { - mimeType = 'image/webp' - } else if (filePath.endsWith('.svg')) { - mimeType = 'image/svg+xml' - } else if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) { - mimeType = 'image/jpeg' - } else if (filePath.endsWith('.bmp')) { - mimeType = 'image/bmp' - } else if (filePath.endsWith('.ico')) { - mimeType = 'image/x-icon' - } else if (filePath.endsWith('.avif')) { - mimeType = 'image/avif' - } - - // Read file and return response - const fileContent = fs.readFileSync(fullPath) - return new Response(fileContent, { - headers: { 'Content-Type': mimeType } + return await createStreamingResponse(fullPath, stat, { + headers: { 'Content-Type': getMimeTypeForPath(fullPath) } }) } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + console.warn( + `protocolRegistrationHook: imgcache handler: Image file not found: ${fullPath}` + ) + return new Response(`Image not found: ${filePath}`, { + status: 404, + headers: { 'Content-Type': 'text/plain' } + }) + } + console.error('protocolRegistrationHook: Error handling imgcache request:', error) const errorMessage = error instanceof Error ? error.message : String(error) return new Response(`Server error: ${errorMessage}`, { @@ -181,17 +254,18 @@ export const protocolRegistrationHook: LifecycleHook = { } }) - protocol.handle(WORKSPACE_PREVIEW_PROTOCOL, (request) => { - try { - const fullPath = resolveWorkspacePreviewRequest(request.url) - if (!fullPath) { - return new Response('Forbidden', { - status: 403, - headers: { 'Content-Type': 'text/plain' } - }) - } + protocol.handle(WORKSPACE_PREVIEW_PROTOCOL, async (request) => { + const fullPath = resolveWorkspacePreviewRequest(request.url) + if (!fullPath) { + return new Response('Forbidden', { + status: 403, + headers: { 'Content-Type': 'text/plain' } + }) + } - if (!fs.existsSync(fullPath) || fs.statSync(fullPath).isDirectory()) { + try { + const stat = await fsp.stat(fullPath) + if (stat.isDirectory()) { console.warn( `protocolRegistrationHook: ${WORKSPACE_PREVIEW_PROTOCOL} handler: File not found: ${fullPath}` ) @@ -201,15 +275,24 @@ export const protocolRegistrationHook: LifecycleHook = { }) } - const fileContent = fs.readFileSync(fullPath) - return new Response(fileContent, { + return await createStreamingResponse(fullPath, stat, { headers: { 'Cache-Control': 'no-store', - 'Content-Type': getMimeTypeForPath(fullPath), + 'Content-Type': getWorkspacePreviewMimeType(fullPath), 'X-Content-Type-Options': 'nosniff' } }) } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + console.warn( + `protocolRegistrationHook: ${WORKSPACE_PREVIEW_PROTOCOL} handler: File not found: ${fullPath}` + ) + return new Response(`File not found: ${fullPath}`, { + status: 404, + headers: { 'Content-Type': 'text/plain' } + }) + } + console.error( `protocolRegistrationHook: Error handling ${WORKSPACE_PREVIEW_PROTOCOL} request:`, error diff --git a/src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts b/src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts index 871449914..6f849ecdb 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts @@ -43,7 +43,9 @@ export const databaseInitHook: LifecycleHook = { }) // Create database initializer - const dbInitializer = new DatabaseInitializer({ password }) + const dbInitializer = new DatabaseInitializer({ + password + }) // Initialize database const database = await dbInitializer.initialize() diff --git a/src/main/presenter/mcpPresenter/index.ts b/src/main/presenter/mcpPresenter/index.ts index 717c45d1b..1b7a05577 100644 --- a/src/main/presenter/mcpPresenter/index.ts +++ b/src/main/presenter/mcpPresenter/index.ts @@ -1,4 +1,5 @@ import logger from '@shared/logger' +import { performance } from 'node:perf_hooks' import { IMCPPresenter, IConfigPresenter, @@ -16,6 +17,7 @@ import { McpServerAuthStatus } from '@shared/presenter' import { ServerManager } from './serverManager' +import type { McpClient as RuntimeMcpClient } from './mcpClient' import { ToolManager } from './toolManager' import { McpRouterManager } from './mcprouterManager' import { McpOAuthManager } from './mcpOAuthManager' @@ -34,6 +36,9 @@ type McpToolAccessContext = { conversationId?: string } +const MCP_SHUTDOWN_SERVER_TIMEOUT_MS = 10_000 +const MCP_SHUTDOWN_CONCURRENCY = 4 + const normalizeStringList = (items?: string[]): string[] | undefined => { if (!Array.isArray(items)) { return undefined @@ -98,10 +103,19 @@ export class McpPresenter implements IMCPPresenter { failureMessage: string ): void { void this.serverManager - .startServer(serverName) - .then(() => { - logger.info(successMessage) - this.emitServerStarted(serverName) + .startServer(serverName, { + onBackgroundConnected: () => { + logger.info(successMessage) + this.emitServerStarted(serverName) + } + }) + .then((connectResult) => { + if (connectResult === 'connected') { + logger.info(successMessage) + this.emitServerStarted(serverName) + } else if (connectResult === 'soft-timeout-released') { + logger.info(`[MCP] Server ${serverName} startup released after soft timeout`) + } }) .catch((error) => { console.error(failureMessage, error) @@ -250,12 +264,70 @@ export class McpPresenter implements IMCPPresenter { } private async shutdownRunningClients(): Promise { - const runningClients = await this.serverManager.getRunningClients() - for (const client of runningClients) { - try { - await this.stopServer(client.serverName) - } catch (error) { - console.error(`[MCP] Failed to stop server ${client.serverName} during shutdown:`, error) + const activeClients = await this.serverManager.getActiveClients() + let nextIndex = 0 + + const stopNext = async (): Promise => { + while (nextIndex < activeClients.length) { + const client = activeClients[nextIndex++] + await this.stopServerDuringShutdown(client) + } + } + + const workers = Array.from( + { length: Math.min(MCP_SHUTDOWN_CONCURRENCY, activeClients.length) }, + () => stopNext() + ) + await Promise.all(workers) + } + + async stopServerDuringShutdownByName(serverName: string): Promise { + const client = this.serverManager.getClient(serverName) + if (!client) { + return + } + + await this.stopServerDuringShutdown(client) + } + + private async stopServerDuringShutdown(client: RuntimeMcpClient): Promise { + const startedAt = performance.now() + let timeoutId: NodeJS.Timeout | null = null + let timedOut = false + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + timedOut = true + reject(new Error(`MCP server ${client.serverName} stop timed out`)) + }, MCP_SHUTDOWN_SERVER_TIMEOUT_MS) + }) + + try { + await Promise.race([this.stopServer(client.serverName), timeoutPromise]) + console.info( + `[MCP] Stopped server ${client.serverName} during shutdown durationMs=${(performance.now() - startedAt).toFixed(1)}` + ) + } catch (error) { + if (timedOut) { + const forceTerminated = await client.forceTerminateStdioProcessTree( + `shutdown stop timed out after ${MCP_SHUTDOWN_SERVER_TIMEOUT_MS}ms` + ) + console.warn('[MCP] Server stop timed out during shutdown; continuing shutdown:', { + serverName: client.serverName, + timeoutMs: MCP_SHUTDOWN_SERVER_TIMEOUT_MS, + durationMs: Number((performance.now() - startedAt).toFixed(1)), + forceTerminatedStdioProcess: forceTerminated, + note: forceTerminated + ? 'stdio process tree force termination was attempted; the underlying stop promise may still finish later' + : 'no stdio process tree was available to force terminate; underlying stop may still be pending' + }) + return + } + + console.error(`[MCP] Failed to stop server ${client.serverName} during shutdown:`, error) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) } } } @@ -268,8 +340,12 @@ export class McpPresenter implements IMCPPresenter { } try { - await this.serverManager.startServer(serverName) - this.emitServerStarted(serverName) + const connectResult = await this.serverManager.startServer(serverName, { + onBackgroundConnected: () => this.emitServerStarted(serverName) + }) + if (connectResult === 'connected') { + this.emitServerStarted(serverName) + } } catch (error) { console.error(`[MCP] Failed to restart authenticated server ${serverName}:`, error) } @@ -544,9 +620,17 @@ export class McpPresenter implements IMCPPresenter { return Promise.resolve(this.serverManager.isServerRunning(serverName)) } + async isServerActive(serverName: string): Promise { + return Promise.resolve(this.serverManager.isServerActive(serverName)) + } + async startServer(serverName: string): Promise { - await this.serverManager.startServer(serverName) - this.emitServerStarted(serverName) + const connectResult = await this.serverManager.startServer(serverName, { + onBackgroundConnected: () => this.emitServerStarted(serverName) + }) + if (connectResult === 'connected') { + this.emitServerStarted(serverName) + } } async stopServer(serverName: string): Promise { @@ -859,9 +943,9 @@ export class McpPresenter implements IMCPPresenter { return } - const runningClients = await this.serverManager.getRunningClients() + const activeClients = await this.serverManager.getActiveClients() const servers = await this.configPresenter.getMcpServers() - for (const client of runningClients) { + for (const client of activeClients) { if (this.isPluginOwnedServerConfig(servers[client.serverName])) { continue } diff --git a/src/main/presenter/mcpPresenter/mcpClient.ts b/src/main/presenter/mcpPresenter/mcpClient.ts index 21a2bbf90..5dc6b90bc 100644 --- a/src/main/presenter/mcpPresenter/mcpClient.ts +++ b/src/main/presenter/mcpPresenter/mcpClient.ts @@ -40,6 +40,11 @@ import { McpSamplingDecision, MCPServerConfig } from '@shared/presenter' +import type { + McpServerLifecycleStatus, + McpServerStatusPhase, + McpServerStatusReason +} from '@shared/types/core/mcp' const ALLOWED_SAMPLING_IMAGE_MIME_TYPES = new Set([ 'image/png', @@ -48,6 +53,44 @@ const ALLOWED_SAMPLING_IMAGE_MIME_TYPES = new Set([ 'image/webp' ]) +const MCP_STARTUP_SOFT_TIMEOUT_MS = 45 * 1000 +const MCP_CONNECT_HARD_TIMEOUT_MS = 5 * 60 * 1000 + +export type McpConnectResult = 'connected' | 'soft-timeout-released' | 'stopped' + +export class McpStartupSoftTimeoutError extends Error { + constructor(serverName: string) { + super(`Connection to MCP server ${serverName} reached startup soft timeout`) + this.name = 'McpStartupSoftTimeoutError' + } +} + +export class McpConnectionHardTimeoutError extends Error { + constructor(serverName: string) { + super(`Connection to MCP server ${serverName} timed out`) + this.name = 'McpConnectionHardTimeoutError' + } +} + +export class McpConnectionCancelledError extends Error { + constructor(serverName: string) { + super(`Connection to MCP server ${serverName} was cancelled`) + this.name = 'McpConnectionCancelledError' + } +} + +type McpConnectOptions = { + phase?: McpServerStatusPhase + waitForConnection?: boolean +} + +interface ServerStatusChangedOptions { + phase?: McpServerStatusPhase + attempt?: number + reason?: McpServerStatusReason + message?: string +} + type StdioClientTransportProcessAccess = { _process?: ChildProcess } @@ -132,6 +175,11 @@ export class McpClient { public serverConfig: Record private isConnected: boolean = false private connectionTimeout: NodeJS.Timeout | null = null + private stdioChildProcessForShutdown?: ChildProcess + private connectPromise: Promise | null = null + private startupAttempt = 0 + private lifecycleStatus: McpServerLifecycleStatus = 'stopped' + private connectAborted = false private npmRegistry: string | null = null private uvRegistry: string | null = null private mcpOAuthManager?: McpOAuthManager @@ -161,17 +209,27 @@ export class McpClient { this.runtimeHelper.initializeRuntimes() } - private emitServerStatusChanged(isRunning: boolean): void { - eventBus.sendToMain(MCP_EVENTS.SERVER_STATUS_CHANGED, { + private emitServerStatusChanged( + lifecycleStatus: McpServerLifecycleStatus, + options: ServerStatusChangedOptions = {} + ): void { + this.lifecycleStatus = lifecycleStatus + const isRunning = lifecycleStatus === 'connected' + const payload = { name: this.serverName, - status: isRunning ? 'running' : 'stopped', - isRunning - }) - publishDeepchatEvent('mcp.server.status.changed', { serverName: this.serverName, + lifecycleStatus, + status: lifecycleStatus, isRunning, + phase: options.phase, + attempt: options.attempt, + reason: options.reason, + message: options.message, version: Date.now() - }) + } + + eventBus.sendToMain(MCP_EVENTS.SERVER_STATUS_CHANGED, payload) + publishDeepchatEvent('mcp.server.status.changed', payload) } public processCommandWithArgs( @@ -213,12 +271,101 @@ export class McpClient { } // Connect to MCP server - async connect(): Promise { + async connect(options: McpConnectOptions = {}): Promise { if (this.isConnected && this.client) { console.info(`MCP server ${this.serverName} is already running`) - return + return 'connected' + } + + if (this.connectPromise) { + if (options.waitForConnection) { + await this.connectPromise + return 'connected' + } + return this.waitForConnectSoftTimeout(this.connectPromise, this.startupAttempt, options.phase) + } + + const attempt = this.startupAttempt + 1 + this.startupAttempt = attempt + const phase = options.phase ?? 'manual' + this.connectAborted = false + this.emitServerStatusChanged('connecting', { phase, attempt }) + + const connectPromise = this.performConnect(attempt, phase) + this.connectPromise = connectPromise + connectPromise + .catch(() => undefined) + .finally(() => { + if (this.connectPromise === connectPromise) { + this.connectPromise = null + } + }) + + if (options.waitForConnection) { + await connectPromise + return 'connected' + } + + return this.waitForConnectSoftTimeout(connectPromise, attempt, options.phase) + } + + private async ensureConnectedForRequest(): Promise { + if (!this.isConnected) { + await this.connect({ phase: 'manual', waitForConnection: true }) + } + + if (!this.isConnected || !this.client) { + throw new Error(`MCP client ${this.serverName} is not connected`) + } + } + + private async waitForConnectSoftTimeout( + connectPromise: Promise, + attempt: number, + phase: McpServerStatusPhase = 'manual' + ): Promise { + let softTimeout: NodeJS.Timeout | null = null + try { + await Promise.race([ + connectPromise, + new Promise((_, reject) => { + softTimeout = setTimeout(() => { + reject(new McpStartupSoftTimeoutError(this.serverName)) + }, MCP_STARTUP_SOFT_TIMEOUT_MS) + }) + ]) + return 'connected' + } catch (error) { + if (error instanceof McpStartupSoftTimeoutError) { + if (this.connectAborted || this.lifecycleStatus === 'stopped') { + throw new McpConnectionCancelledError(this.serverName) + } + console.warn( + `MCP server ${this.serverName} startup soft timeout reached; continuing in background` + ) + this.emitServerStatusChanged('timeout', { + phase, + attempt, + reason: 'soft-timeout', + message: error.message + }) + this.emitServerStatusChanged('retrying', { + phase: 'retry', + attempt, + reason: 'soft-timeout', + message: 'Connection is still running in the background' + }) + return 'soft-timeout-released' + } + throw error + } finally { + if (softTimeout) { + clearTimeout(softTimeout) + } } + } + private async performConnect(attempt: number, phase: McpServerStatusPhase): Promise { try { console.info(`Starting MCP server ${this.serverName}...`, this.serverConfig) @@ -466,37 +613,32 @@ export class McpClient { // 设置连接超时 const timeoutPromise = new Promise((_, reject) => { - this.connectionTimeout = setTimeout( - () => { - console.error(`Connection to MCP server ${this.serverName} timed out`) - reject(new Error(`Connection to MCP server ${this.serverName} timed out`)) - }, - 5 * 60 * 1000 - ) // 5分钟 + this.connectionTimeout = setTimeout(() => { + console.error(`Connection to MCP server ${this.serverName} timed out`) + reject(new McpConnectionHardTimeoutError(this.serverName)) + }, MCP_CONNECT_HARD_TIMEOUT_MS) }) // 连接到服务器 - const connectPromise = this.client - .connect(this.transport) - .then(() => { - // 清除超时 - if (this.connectionTimeout) { - clearTimeout(this.connectionTimeout) - this.connectionTimeout = null - } + const connectPromise = this.client.connect(this.transport) - this.isConnected = true - console.info(`MCP server ${this.serverName} connected successfully`) + // 等待连接完成或硬超时 + await Promise.race([connectPromise, timeoutPromise]) - this.emitServerStatusChanged(true) - }) - .catch((error) => { - console.error(`Failed to connect to MCP server ${this.serverName}:`, error) - throw error - }) + // 清除超时 + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout) + this.connectionTimeout = null + } - // 等待连接完成或超时 - await Promise.race([connectPromise, timeoutPromise]) + if (this.connectAborted) { + throw new McpConnectionCancelledError(this.serverName) + } + + this.isConnected = true + console.info(`MCP server ${this.serverName} connected successfully`) + + this.emitServerStatusChanged('connected', { phase, attempt }) } catch (error) { // 清除超时 if (this.connectionTimeout) { @@ -505,11 +647,21 @@ export class McpClient { } // 清理资源 - await this.cleanupResources() + await this.cleanupResources({ emitStopped: false }) + + if (error instanceof McpConnectionCancelledError || this.connectAborted) { + console.info(`MCP server ${this.serverName} connection cancelled`) + throw new McpConnectionCancelledError(this.serverName) + } console.error(`Failed to connect to MCP server ${this.serverName}:`, error) - this.emitServerStatusChanged(false) + this.emitServerStatusChanged('failed', { + phase, + attempt, + reason: error instanceof McpConnectionHardTimeoutError ? 'hard-timeout' : 'connect-error', + message: error instanceof Error ? error.message : String(error) + }) throw error } @@ -517,13 +669,16 @@ export class McpClient { // 断开与 MCP 服务器的连接 async disconnect(): Promise { - if (!this.isConnected || !this.client) { + if (!this.client && !this.transport && !this.connectPromise) { return } + this.connectAborted = true + this.emitServerStatusChanged('stopped', { phase: 'shutdown', reason: 'shutdown' }) + try { // Use internal disconnect method for normal disconnection - await this.internalDisconnect() + await this.internalDisconnect(undefined, 'shutdown') } catch (error) { console.error(`Failed to disconnect from MCP server ${this.serverName}:`, error) throw error @@ -531,7 +686,7 @@ export class McpClient { } // 清理资源 - private async cleanupResources(): Promise { + private async cleanupResources(options: { emitStopped?: boolean } = {}): Promise { // 清除超时定时器 if (this.connectionTimeout) { clearTimeout(this.connectionTimeout) @@ -540,6 +695,7 @@ export class McpClient { // 关闭transport const transport = this.transport + this.stdioChildProcessForShutdown = this.getStdioChildProcess(transport) this.transport = null if (transport) { try { @@ -557,21 +713,57 @@ export class McpClient { this.cachedTools = null this.cachedPrompts = null this.cachedResources = null + + if (options.emitStopped) { + this.emitServerStatusChanged('stopped', { reason: 'shutdown' }) + } } - private async closeTransport(transport: Transport): Promise { + private getStdioChildProcess( + transport: Transport | null = this.transport + ): ChildProcess | undefined { + if (!(transport instanceof StdioClientTransport)) { + return undefined + } + return (transport as unknown as StdioClientTransportProcessAccess)._process + } + + async forceTerminateStdioProcessTree(reason: string): Promise { + const child = this.getStdioChildProcess() ?? this.stdioChildProcessForShutdown + if (!child) { + return false + } + try { - if (transport instanceof StdioClientTransport) { - const child = (transport as unknown as StdioClientTransportProcessAccess)._process - if (child) { - await terminateProcessTree(child, { graceMs: 2000 }) - } - } + await terminateProcessTree(child, { graceMs: 2000 }) + console.warn(`[MCP] Force terminated stdio process tree for ${this.serverName}: ${reason}`) + return true } catch (error) { - console.error(`Failed to terminate MCP stdio process tree for ${this.serverName}:`, error) + console.warn( + `Failed to force terminate MCP stdio process tree for ${this.serverName}:`, + error + ) + return false } + } - await transport.close() + private async closeTransport(transport: Transport): Promise { + const child = this.getStdioChildProcess(transport) + if (child) { + try { + await terminateProcessTree(child, { graceMs: 2000 }) + } catch (error) { + console.error(`Failed to terminate MCP stdio process tree for ${this.serverName}:`, error) + } + } + + try { + await transport.close() + } finally { + if (this.stdioChildProcessForShutdown === child) { + this.stdioChildProcessForShutdown = undefined + } + } } // Register notification handlers @@ -929,6 +1121,18 @@ export class McpClient { return this.isConnected && !!this.client } + isActive(): boolean { + return !!this.client || !!this.transport || !!this.connectPromise + } + + getLifecycleStatus(): McpServerLifecycleStatus { + return this.lifecycleStatus + } + + getConnectionCompletion(): Promise | null { + return this.connectPromise + } + // Check and handle session errors by restarting the service private async checkAndHandleSessionError(error: unknown): Promise { if (isSessionError(error) && !this.isRecovering) { @@ -976,14 +1180,17 @@ export class McpClient { private async stopService(): Promise { try { // Use the same disconnect logic but with different reason - await this.internalDisconnect('persistent session errors') + await this.internalDisconnect('persistent session errors', 'connect-error') } catch (error) { console.error(`Failed to stop service ${this.serverName}:`, error) } } // Internal disconnect with custom reason - private async internalDisconnect(reason?: string): Promise { + private async internalDisconnect( + reason?: string, + statusReason: McpServerStatusReason = 'shutdown' + ): Promise { // Clean up all resources await this.cleanupResources() @@ -993,15 +1200,13 @@ export class McpClient { logger.info(logMessage) - this.emitServerStatusChanged(false) + this.emitServerStatusChanged('stopped', { reason: statusReason }) } // 调用 MCP 工具 async callTool(toolName: string, args: Record): Promise { try { - if (!this.isConnected) { - await this.connect() - } + await this.ensureConnectedForRequest() if (!this.client) { throw new Error(`MCP client ${this.serverName} not initialized`) @@ -1047,9 +1252,7 @@ export class McpClient { } try { - if (!this.isConnected) { - await this.connect() - } + await this.ensureConnectedForRequest() if (!this.client) { throw new Error(`MCP client ${this.serverName} not initialized`) @@ -1094,9 +1297,7 @@ export class McpClient { } try { - if (!this.isConnected) { - await this.connect() - } + await this.ensureConnectedForRequest() if (!this.client) { throw new Error(`MCP client ${this.serverName} not initialized`) @@ -1150,9 +1351,7 @@ export class McpClient { // 获取指定提示 async getPrompt(name: string, args?: Record): Promise { try { - if (!this.isConnected) { - await this.connect() - } + await this.ensureConnectedForRequest() if (!this.client) { throw new Error(`MCP client ${this.serverName} not initialized`) @@ -1200,9 +1399,7 @@ export class McpClient { } try { - if (!this.isConnected) { - await this.connect() - } + await this.ensureConnectedForRequest() if (!this.client) { throw new Error(`MCP client ${this.serverName} not initialized`) @@ -1249,9 +1446,7 @@ export class McpClient { // 读取资源 async readResource(resourceUri: string): Promise { try { - if (!this.isConnected) { - await this.connect() - } + await this.ensureConnectedForRequest() if (!this.client) { throw new Error(`MCP client ${this.serverName} not initialized`) diff --git a/src/main/presenter/mcpPresenter/serverManager.ts b/src/main/presenter/mcpPresenter/serverManager.ts index d4e893eaf..cc3e3d248 100644 --- a/src/main/presenter/mcpPresenter/serverManager.ts +++ b/src/main/presenter/mcpPresenter/serverManager.ts @@ -1,6 +1,6 @@ import logger from '@shared/logger' import { IConfigPresenter, MCPServerConfig } from '@shared/presenter' -import { McpClient } from './mcpClient' +import { McpClient, McpConnectionCancelledError, type McpConnectResult } from './mcpClient' import axios from 'axios' import { proxyConfig } from '@/presenter/proxyConfig' import { eventBus } from '@/eventbus' @@ -219,15 +219,32 @@ export class ServerManager { return clients } - async startServer(name: string): Promise { + async getActiveClients(): Promise { + return Array.from(this.clients.values()).filter((client) => client.isActive()) + } + + async startServer( + name: string, + options: { onBackgroundConnected?: () => void } = {} + ): Promise { // If server is already running, no need to start again - if (this.clients.has(name)) { - if (this.isServerRunning(name)) { + const existingClient = this.clients.get(name) + if (existingClient) { + if (existingClient.isServerRunning()) { console.info(`MCP server ${name} is already running`) + return 'connected' } else { console.info(`MCP server ${name} is starting...`) + const serverConfig = (existingClient.serverConfig ?? {}) as unknown as MCPServerConfig + this.handleStartupConnectResult( + name, + existingClient, + serverConfig, + 'soft-timeout-released', + options + ) + return 'soft-timeout-released' } - return } const servers = await this.configPresenter.getMcpServers() @@ -237,11 +254,12 @@ export class ServerManager { throw new Error(`MCP server ${name} not found`) } + let client: McpClient | null = null try { console.info(`Starting MCP server ${name}...`) const npmRegistry = serverConfig.customNpmRegistry || this.npmRegistry // Create and save client instance, passing npm registry - const client = new McpClient( + client = new McpClient( name, serverConfig as unknown as Record, npmRegistry, @@ -251,9 +269,19 @@ export class ServerManager { this.clients.set(name, client) // Connect to server, this will start the service - await client.connect() - this.clearServerLastError(name) + const connectResult = await client.connect({ phase: 'startup' }) + this.handleStartupConnectResult(name, client, serverConfig, connectResult, options) + if (connectResult === 'connected') { + this.clearServerLastError(name) + } + return connectResult } catch (error) { + if (client?.getLifecycleStatus?.() === 'stopped' && !client.isActive()) { + console.info(`MCP server ${name} startup was cancelled; ignoring stopped client`) + this.clients.delete(name) + return 'stopped' + } + console.error(`Failed to start MCP server ${name}:`, error) // Remove client reference @@ -273,6 +301,50 @@ export class ServerManager { } } + private handleStartupConnectResult( + name: string, + client: McpClient, + serverConfig: MCPServerConfig, + connectResult: McpConnectResult, + options: { onBackgroundConnected?: () => void } = {} + ): void { + if (connectResult !== 'soft-timeout-released') { + return + } + + const completion = client.getConnectionCompletion() + if (!completion) { + return + } + + completion + .then(() => { + this.clearServerLastError(name) + options.onBackgroundConnected?.() + eventBus.sendToMain(MCP_EVENTS.CLIENT_LIST_UPDATED) + }) + .catch((error) => { + if (error instanceof McpConnectionCancelledError) { + return + } + + if (this.clients.get(name) !== client) { + return + } + + this.clients.delete(name) + this.setServerLastError(name, error) + const authHandled = + this.mcpOAuthManager?.handleConnectionError(name, serverConfig, error) ?? false + + if (!authHandled && !this.isPluginOwnedServerConfig(serverConfig)) { + this.sendMcpConnectionError(name, error) + } + + eventBus.sendToMain(MCP_EVENTS.CLIENT_LIST_UPDATED) + }) + } + // Handle and send MCP connection error notification private sendMcpConnectionError(serverName: string, error: unknown): void { // Import required modules @@ -329,6 +401,10 @@ export class ServerManager { return client.isServerRunning() } + isServerActive(name: string): boolean { + return this.clients.get(name)?.isActive() ?? false + } + /** * Get client instance */ diff --git a/src/main/presenter/pluginPresenter/index.ts b/src/main/presenter/pluginPresenter/index.ts index f20a4840c..d34875f33 100644 --- a/src/main/presenter/pluginPresenter/index.ts +++ b/src/main/presenter/pluginPresenter/index.ts @@ -48,6 +48,10 @@ type PluginPresenterDeps = { resourcesPath?: string } +type ShutdownAwareMcpPresenter = IMCPPresenter & { + stopServerDuringShutdownByName?: (serverName: string) => Promise +} + type ResolvedOfficialPlugin = { manifest: DeepChatPluginManifest root: string @@ -132,6 +136,7 @@ export class PluginPresenter { async shutdown(): Promise { const pluginIds = new Set(this.getInstallations().map((installation) => installation.pluginId)) const servers = await this.configPresenter.getMcpServers() + const pluginOwnedServers: Array<{ serverName: string; pluginId?: string }> = [] for (const [serverName, serverConfig] of Object.entries(servers)) { if (!this.isPluginOwnedServerConfig(serverConfig)) { @@ -142,20 +147,11 @@ export class PluginPresenter { if (ownerPluginId) { pluginIds.add(ownerPluginId) } - - try { - if (await this.mcpPresenter.isServerRunning(serverName)) { - await this.mcpPresenter.stopServer(serverName) - } - } catch (error) { - console.warn('[PluginHost] Failed to stop plugin-owned MCP server during shutdown:', { - pluginId: ownerPluginId, - serverName, - error - }) - } + pluginOwnedServers.push({ serverName, pluginId: ownerPluginId }) } + await this.stopPluginOwnedServers(pluginOwnedServers) + for (const pluginId of pluginIds) { unregisterPluginToolPolicies(pluginId) } @@ -163,6 +159,45 @@ export class PluginPresenter { this.closeAllPluginSettingsWindows() } + private async stopPluginOwnedServers( + servers: Array<{ serverName: string; pluginId?: string }> + ): Promise { + const concurrency = 4 + let nextIndex = 0 + + const stopNext = async (): Promise => { + while (nextIndex < servers.length) { + const { serverName, pluginId } = servers[nextIndex++] + try { + if (await this.isMcpServerActive(serverName)) { + const mcpPresenter = this.mcpPresenter as ShutdownAwareMcpPresenter + if (mcpPresenter.stopServerDuringShutdownByName) { + await mcpPresenter.stopServerDuringShutdownByName(serverName) + } else { + await this.mcpPresenter.stopServer(serverName) + } + } + } catch (error) { + console.warn('[PluginHost] Failed to stop plugin-owned MCP server during shutdown:', { + pluginId, + serverName, + error + }) + } + } + } + + const workers = Array.from({ length: Math.min(concurrency, servers.length) }, () => stopNext()) + await Promise.all(workers) + } + + private async isMcpServerActive(serverName: string): Promise { + return ( + (await this.mcpPresenter.isServerActive?.(serverName)) ?? + (await this.mcpPresenter.isServerRunning(serverName)) + ) + } + async listPlugins(): Promise { await this.loadOfficialPlugins() return await Promise.all( @@ -325,7 +360,7 @@ export class PluginPresenter { for (const [serverName, serverConfig] of Object.entries(servers)) { if (this.isServerOwnedByPlugin(serverConfig, pluginId)) { try { - if (await this.mcpPresenter.isServerRunning(serverName)) { + if (await this.isMcpServerActive(serverName)) { await this.mcpPresenter.stopServer(serverName) } } catch (error) { @@ -1594,7 +1629,7 @@ export class PluginPresenter { for (const serverName of serverNames) { try { - if (!(await this.mcpPresenter.isServerRunning(serverName))) { + if (!(await this.isMcpServerActive(serverName))) { void this.mcpPresenter.startServer(serverName).catch((error) => { console.warn('[PluginHost] Failed to auto-start plugin MCP server:', { pluginId, diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index a47b72400..febf61461 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -316,11 +316,26 @@ export class SQLitePresenter implements ISQLitePresenter { private initializeDatabase(): void { this.databaseFileExistedBeforeOpen = fs.existsSync(this.dbPath) + + const openStart = performance.now() this.db = openSQLiteDatabase(this.dbPath, this.password) this.db.prepare('SELECT 1').get() + logger.info( + `SQLitePresenter: phase=open duration=${(performance.now() - openStart).toFixed(2)}ms` + ) + + const initTablesStart = performance.now() this.initTables() this.initVersionTable() + logger.info( + `SQLitePresenter: phase=initTables duration=${(performance.now() - initTablesStart).toFixed(2)}ms` + ) + + const migrateStart = performance.now() this.migrate() + logger.info( + `SQLitePresenter: phase=migrate duration=${(performance.now() - migrateStart).toFixed(2)}ms` + ) } private handleInitializationError(error: unknown): void { diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts b/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts index 58bb74ad1..2e85dbdb5 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts @@ -256,9 +256,9 @@ export class DeepChatMessagesTable extends BaseTable { return row.max_seq ?? 0 } - listAssistantUsageCandidates(): DeepChatMessageUsageCandidateRow[] { + iterAssistantUsageCandidates(): IterableIterator { return this.db - .prepare( + .prepare<[], DeepChatMessageUsageCandidateRow>( `SELECT m.id, m.session_id, @@ -273,7 +273,48 @@ export class DeepChatMessagesTable extends BaseTable { WHERE m.role = 'assistant' ORDER BY m.created_at ASC` ) - .all() as DeepChatMessageUsageCandidateRow[] + .iterate() + } + + listAssistantUsageCandidatesPage( + cursor: { createdAt: number; id: string } | null, + limit: number + ): DeepChatMessageUsageCandidateRow[] { + const baseQuery = `SELECT + m.id, + m.session_id, + m.metadata, + m.created_at, + m.updated_at, + s.provider_id, + s.model_id + FROM deepchat_messages m + LEFT JOIN deepchat_sessions s + ON s.id = m.session_id + WHERE m.role = 'assistant'` + + if (!cursor) { + return this.db + .prepare<[number], DeepChatMessageUsageCandidateRow>( + `${baseQuery} + ORDER BY m.created_at ASC, m.id ASC + LIMIT ?` + ) + .all(limit) + } + + return this.db + .prepare<[number, number, string, number], DeepChatMessageUsageCandidateRow>( + `${baseQuery} + AND (m.created_at > ? OR (m.created_at = ? AND m.id > ?)) + ORDER BY m.created_at ASC, m.id ASC + LIMIT ?` + ) + .all(cursor.createdAt, cursor.createdAt, cursor.id, limit) + } + + listAssistantUsageCandidates(): DeepChatMessageUsageCandidateRow[] { + return Array.from(this.iterAssistantUsageCandidates()) } getLastUserMessageBeforeOrAtOrderSeq( diff --git a/src/main/presenter/usageStats.ts b/src/main/presenter/usageStats.ts index 9c550770a..debd62977 100644 --- a/src/main/presenter/usageStats.ts +++ b/src/main/presenter/usageStats.ts @@ -116,7 +116,9 @@ export function normalizeUsageStatsBackfillStatus(value: unknown): UsageStatsBac startedAt: toFiniteNumber(input.startedAt) ?? null, finishedAt: toFiniteNumber(input.finishedAt) ?? null, error: typeof input.error === 'string' ? input.error : null, - updatedAt: toFiniteNumber(input.updatedAt) ?? 0 + updatedAt: toFiniteNumber(input.updatedAt) ?? 0, + processedCount: toFiniteNumber(input.processedCount), + durationMs: toFiniteNumber(input.durationMs) } } diff --git a/src/renderer/settings/components/DataSettings.vue b/src/renderer/settings/components/DataSettings.vue index 6a9bf9f82..77c296177 100644 --- a/src/renderer/settings/components/DataSettings.vue +++ b/src/renderer/settings/components/DataSettings.vue @@ -1212,7 +1212,7 @@ const canSubmitDatabaseEncryptionDialog = computed(() => { }) const databaseEncryptionDialogIcon = computed(() => { if (databaseEncryptionAction.value === 'enable') { - return 'lucide:shield-lock' + return 'lucide:lock-keyhole' } if (databaseEncryptionAction.value === 'change') { return 'lucide:key-round' diff --git a/src/renderer/settings/components/ModelProviderSettings.vue b/src/renderer/settings/components/ModelProviderSettings.vue index a67465f67..9b77f1c84 100644 --- a/src/renderer/settings/components/ModelProviderSettings.vue +++ b/src/renderer/settings/components/ModelProviderSettings.vue @@ -122,11 +122,28 @@
-
- {{ t('settings.provider.disabled') }} ({{ disabledProviders.length }}) +
+
+ {{ t('settings.provider.disabled') }} ({{ disabledProviders.length }}) +
+
{ const searchQueryBase = ref('') const searchQuery = refDebounced(searchQueryBase, 150) const showClearButton = computed(() => searchQueryBase.value.trim().length > 0) +const isDisabledProvidersExpanded = ref(false) const editingProviderId = ref(null) const editingName = ref('') @@ -691,6 +709,15 @@ const disabledProviders = computed({ } }) +watch( + () => [searchQuery.value.trim(), disabledProviders.value.length] as const, + ([query, disabledMatchCount]) => { + if (query.length > 0 && disabledMatchCount > 0) { + isDisabledProvidersExpanded.value = true + } + } +) + const setActiveProvider = (providerId: string) => { return router.push({ name: 'settings-provider', diff --git a/src/renderer/src/components/chat/ChatStatusBar.vue b/src/renderer/src/components/chat/ChatStatusBar.vue index 8309c5d4e..55aab1114 100644 --- a/src/renderer/src/components/chat/ChatStatusBar.vue +++ b/src/renderer/src/components/chat/ChatStatusBar.vue @@ -1163,6 +1163,7 @@ const capabilityProviderId = ref('') let draftModelSyncToken = 0 let permissionSyncToken = 0 let generationSyncToken = 0 +let generationSyncQueued = false let generationPersistTimer: ReturnType | null = null let pendingGenerationPatch: Partial = {} let generationPersistRequestToken = 0 @@ -2214,7 +2215,7 @@ const updateLocalGenerationSettings = (patch: Partial scheduleGenerationPersist(normalizedPatch) } -const syncGenerationSettings = async () => { +const runSyncGenerationSettings = async () => { const token = ++generationSyncToken clearPendingGenerationPersist() invalidateGenerationPersistResponses() @@ -2285,6 +2286,19 @@ const syncGenerationSettings = async () => { loadedSettingsSelection.value = { ...selection } } +const syncGenerationSettings = () => { + if (generationSyncQueued) { + return + } + + generationSyncQueued = true + void nextTick(() => { + generationSyncQueued = false + void runSyncGenerationSettings().catch((error) => { + console.warn('[ChatStatusBar] Failed to sync generation settings:', error) + }) + }) +} const reloadSystemPrompts = async () => { try { systemPromptList.value = await configClient.getSystemPrompts() @@ -2300,13 +2314,13 @@ watch( isAcpAgent, () => agentStore.selectedAgentId, () => modelStore.initialized, - () => modelStore.chatSelectableModelGroups + () => modelStore.chatSelectableModelGroupsRevision ], () => { if (hasActiveSession.value) return void syncDraftModelSelection() }, - { immediate: true, deep: true } + { immediate: true } ) watch( @@ -2369,7 +2383,7 @@ watch( () => isAcpAgent.value ], () => { - void syncGenerationSettings() + syncGenerationSettings() }, { immediate: true } ) diff --git a/src/renderer/src/components/icons/AgentAvatar.vue b/src/renderer/src/components/icons/AgentAvatar.vue index de2567b0b..9c814d9f1 100644 --- a/src/renderer/src/components/icons/AgentAvatar.vue +++ b/src/renderer/src/components/icons/AgentAvatar.vue @@ -1,9 +1,10 @@