diff --git a/build/run-app-perf.cjs b/build/run-app-perf.cjs index 9381c598..5a884e66 100644 --- a/build/run-app-perf.cjs +++ b/build/run-app-perf.cjs @@ -10,7 +10,11 @@ const { tmpdir } = require('node:os') const { join, resolve } = require('node:path') const root = resolve(__dirname, '..') -const modelPort = 11434 +// 11434 is the fresh-profile default endpoint. Another GoodBuddy (or Ollama) +// may already use it; GB_PERF_MODEL_PORT moves the fake model and points the +// isolated profile at it through the GOODBUDDY_MODEL_* environment variables. +const defaultModelPort = 11434 +const modelPort = Number(process.env.GB_PERF_MODEL_PORT || defaultModelPort) const totalTimeoutMs = Number(process.env.GB_PERF_TIMEOUT_MS || 600_000) const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1' const streamBytes = Number(process.env.GB_PERF_STREAM_BYTES || 40_000) @@ -160,12 +164,18 @@ async function main() { try { server = await startModelServer(ledger) } catch (error) { - throw new Error(`Cannot listen on 127.0.0.1:${modelPort} for the fake model (${error.code ?? error.message}); stop the local service using that port and retry`, { cause: error }) + throw new Error(`Cannot listen on 127.0.0.1:${modelPort} for the fake model (${error.code ?? error.message}); stop the local service using that port, or set GB_PERF_MODEL_PORT to a free port`, { cause: error }) } const environment = { ...process.env, GB_PERF_DIRECTORY: runDirectory, GB_PERF_ARTIFACTS: outputDirectory, GB_PERF_ROOT: root } delete environment.ELECTRON_RUN_AS_NODE delete environment.ELECTRON_RENDERER_URL for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key] + if (modelPort !== defaultModelPort) { + // The fake model ignores the key; it only makes the default profile use the URL. + environment.GOODBUDDY_MODEL_API_KEY = 'perf-loopback' + environment.GOODBUDDY_MODEL_BASE_URL = `http://127.0.0.1:${modelPort}/v1` + environment.GOODBUDDY_MODEL_NAME = 'qwen3' + } environment.GOODBUDDY_WORKSPACE = join(runDirectory, 'workspace') mkdirSync(environment.GOODBUDDY_WORKSPACE, { recursive: true }) const started = Date.now() diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index 759483d5..e8053502 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -639,12 +639,47 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用 ### PERF-13 Renderer 领域 Store 迁移 -- **优先级 / 状态:** P1 / 待开始 +- **优先级 / 状态:** P1 / 进行中(流式缓冲、Composer 草稿已迁移) - **顺序:** 流式缓冲 → Composer 草稿 → 会话与消息 → 任务与通知 → 设置及其余界面状态。 - **每个领域的完成步骤:** 建立 store 并把 IPC 订阅移入 store;调用方改为 selector;删除 App 中对应 state/ref 同步代码;运行测试与 `PERF-11`。 - **验收:** 输入和流式更新不再重新渲染 App 根组件;`App.tsx` 最终降到布局壳规模。 +#### 2026-10-02 第一步:流式缓冲与 Composer 草稿 + +- **流式缓冲(`live-message-store.ts`):** `text`/`reasoning` delta 不再逐条 `setConversations`, + 而是写入按消息分组的外部 store;`ChatMessageRow` 用 `useSyncExternalStore` 订阅自己的消息, + 只有这一行重新渲染。App 状态每 250 ms 吸收一次,或在任何其他会话更新时先吸收。 + - 每个 delta 带序号和应用后的正文/思考长度。store 生成的消息对象记录已包含的序号;持久化 + 快照等外来��象按长度判断(delta 只追加),因此不会重复或丢失。 + - 一次更新只吸收派发时已到达的 delta,工具、审批等块与文本保持原有顺序。 + - 吸收后裁剪已提交的 delta,行内显示增量折叠,长回答期间缓冲区保持很短。 + - 吸收用普通更新而非 transition:transition 会被行的同步更新反复打断,导致缓冲区一直增长 + (首版实测出现过流式 Long Tasks 翻倍)。 +- **Composer 草稿(`composer-draft-store.ts`):** 按会话的草稿移出 App state;输入框和发送按钮 + 分别订阅草稿文本和“是否有内容”,按键不再重渲染 App。高度调整改为草稿提交后的 layout effect。 +- **测试:** store 单测覆盖折叠等价、提交前显示、工具块顺序、派发时快照、替换快照不重复、 + React 重放幂等、无基线不隐藏、长流裁剪;App 测试改为断言流式文本不再渲染历史面板与任务条。 +- **`npm run perf:app` A/B(同机,改动前为临时 worktree 中的 `90ea33e` 构建,交替运行):** + +| 场景(4× 降速,3 轮) | 改动前 p50 / p95 | 改动后 | +| --- | --- | --- | +| 输入 120 字(新 profile) | 18–21 / 24–26 ms,掉帧 7–13% | 9–10 / 11–16 ms,掉帧 0–1% | +| 输入 120 字(有 300 个会话) | 27–81 / 35–227 ms | 10–19 / 12–36 ms | +| 流式 40 KB:Long Tasks 总时长 | 6.5–20.8 s | 3.6–19.3 s(3 轮中 2 轮约 4–5 s) | +| 流式期间输入 | 73–1,808 / 174–3,498 ms | 34–62 / 59–573 ms | +| 打开 2,000 条消息会话 | 1.07–1.80 s | 0.47–0.76 s | +| 首次 / 缓存命中切换会话 | 持平 | 持平 | + + 不降速时(2 轮)各场景持平或略好,流式期间输入 p95 从 15–17 ms 降到 11–12 ms。流式本身在 + 4× 降速下仍每帧掉帧:剩余成本是行内 Markdown 解析与 KaTeX 渲染,以及每 250 ms 一次的 App + 级吸收;切换会话的 App 级重渲染未改善,留给后续领域迁移。 +- **远程 Runtime:** 远程事件与本地事件走同一 `agent:event` 通道和同一 Renderer 处理函数, + 上述 Renderer 改进同样生效。远程事件在 Main 中逐条同步写 SQLite(每个 delta 约 2 个写事务, + 会话恢复路径还会重写整条消息)后再转发,这部分未改,归入 `PERF-15`/`PERF-16`。 +- **基准工具:** `GB_PERF_MODEL_PORT` 可把假模型移到其他端口(例如本机已运行 GoodBuddy 占用 + 11434 时),隔离 profile 通过 `GOODBUDDY_MODEL_*` 环境变量指向它。 + ### PERF-14 长列表虚拟化 - **优先级 / 状态:** P2 / 待开始