From 0681ac47deee9eb23fe0de8eda73e556e7777278 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:50:24 +0800 Subject: [PATCH 01/49] chore(gui): mission work logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chore(gui): mission work logs — cordis design finalization, tool-card wire archive, incident records chore: missions chore: missions chore(gui): mission ledger — batch-2 answers, parallel dispatch state, jsdom coverage re-scope chore(gui): ledger — night-mode standing orders (self-commit small, no push, 5-min refresh) chore(gui): ledger — jsdom batches 2-4 landed (233 green), coverage probe next chore(gui): ledger — web-ui coverage probe 65%, four-tier fill plan approved chore(gui): ledger — cordis-impl B1 state after third API drop, decisions on file chore(gui): ledger — 01:32 patrol snapshot (jsdom tier-1 landed, coverage-fixer probed) chore(gui): ledger — 01:37 patrol (peer src trio landed, coverage-fixer still silent) chore(gui): ledger — 01:42 patrol (jsdom tier-2 landed, peer committed x2, coverage-fixer 2nd probe) chore(gui): ledger — coverage diagnosis complete (6-file gap list), web-ui at 91.4% chore(gui): ledger — 01:46 patrol (B1 done, jsdom tier-3 landed, coverage fix batch running) chore(gui): ledger — 01:51 patrol (jsdom tails x2 landed, B2 underway) chore(gui): ledger — 01:56 patrol (gateway.ts 337 lines, checkpoint T-7min) chore(gui): ledger — hold/pending split ruling, coverage-fixer externalize-or-restart ultimatum chore(gui): ledger — 02:01 patrol (B2 done, jsdom final arms, coverage ultimatum pending) chore(gui): ledger — 02:03 checkpoint executed (fixer2 respawn, four lanes released, three owners cold-started) chore(gui): ledger — all six lanes acked, type isolation first live proof (client closure clean) chore(gui): ledger — 02:08 patrol (all seven lanes active, wire carrier assembled) docs(gui): respond-design task checkpoint — apiproxy wire-layer recon done chore(gui): ledger — P0-2 contributor AGENTS.md landed (dd28a5019) docs(gui): respond-design checkpoint 2 — host-side recon (stub respond, frame types, approval seam, ACP answerer precedent) docs(gui): OOP debt inventory — seven territories, 2 real debts (createApiProxy, createFixtureApi), rest ruled keep-as-is docs(gui): disambiguation note on the archived i18n design task chore(gui): ledger — 02:12 patrol (exclude removed, mixed-knife incident under reconciliation) chore(gui): ledger — 02:15 wave (jsdom mission closed, OOP audit done, B3 isolation proof, mixed-knife resolved) chore(gui): ledger — 02:17 patrol (attribution reversal filed, arch-session probed) chore(gui): ledger — 02:22 patrol (B4 done, B5+B6 merged batch, arch-session deadline set) docs(gui): respond-design checkpoint 3 — client-side recon (pending map, PendingCard onRespond stub, AbstractApiClient.respond ready, bootHost missing approval mounts) docs(gui): peer carrier territory review — 2 fixes (SSE cancel leak, route-reservation guard), 1 ruling ask (RPC-log visibility), compliance ledger chore(gui): ledger — 02:27 (arch-shell respawn, territory review verdicts routed, ask-deny finding flagged) docs(gui): P1-5 respond design page complete — pending registry, wire answerer, client state machine, first-wins arbitration chore(gui): ledger — 02:31 patrol (respond design complete, B5 wire smoke green, shell knife 1 underway) docs(gui): respond design — add §0 status warning (web host ask defaults to deny), mount-behavior delta, no-timeout ruling with Config discipline chore(gui): ledger — 02:42 patrol (respond line closed pending review, B7 last piece underway) docs(gui): respond design contract review — direction pass, 2 doc fixes (settle-order contradiction, answering-state race), A/B/C compliance ledger docs(gui): respond design — contract review fixes (R1 verify-before-delete arbitration, R2 answering+resolved-frame transition, ask dual-source wording, rejected-is-ok-value note) chore(gui): ledger — 02:46 patrol (respond line final, two user decisions distilled, arch-shell deadline) docs(gui): respond review addendum — contract-gap ruling: approve plan A (ApprovalRequest.id), wire unchanged, drop plan B backscan chore(gui): ledger — cordis B7 summit: real-browser 10/10 green, user acceptance criterion proven docs(gui): respond design — contract gap #4 approved as plan A (ApprovalRequest.id), backscan fallback retired, blade 0 prepended docs(gui): respond design — final polish (owner-approval vs user-go-ahead wording, implementation handoff notes) chore(gui): ledger — 02:51 (shell-exec third respawn with operational script, respond line 4-knife final) chore(gui): ledger — 02:53 wave (respond five-knife true final, B7 closed 12/12, client.ts green) chore(gui): ledger — 02:56 patrol (cordis closeout bounced pending R1/R2/N1, shell-exec first sign of life) chore(gui): ledger — 03:01 patrol (R1/R2/N1 remediation in flight across four files) chore(gui): ledger — cordis line officially closed and archived, verified on disk (24 knives, 12/12, reviews closed) chore(gui): ledger — 03:06 patrol (shell-exec final window, lowered first-knife bar) chore(gui): ledger — 03:11 patrol (shell line iced-broken: three registries on disk) chore(gui): ledger — 03:15 patrol (quiet window, both active lanes within threshold) chore(gui): ledger — 03:20 patrol (shell five files up, api-proxy plan reported) chore(gui): ledger — 03:25 patrol (shell migration in flight with history-preserving moves, webserver green) chore(gui): ledger — 03:30 patrol (shell knife-1 in verification, api-proxy patching) chore(gui): ledger — shell knife 1 accepted (694cecc53), knife 2 released chore(gui): ledger — 03:40 patrol (knife 2 pre-move stage, cold-list spec appears) chore(gui): ledger — 03:45 patrol (rpclog moves staged, api-proxy two specs in flight) chore(gui): ledger — 03:54 patrol (knife-2 code done, coverage full-run final check) docs(gui): coverage-fixer task ledger — fixer2 takeover, per-file fix log, isolated reportsDirectory pitfall chore(gui): ledger — coverage lane closed and accepted (a19f069a5), the PR #443 CI fix knife chore(gui): ledger — 04:04 patrol (knife-2 calibration, sole active lane) chore(gui): ledger — shell knife 2 accepted (f8fb77b95), knife 3 released as final night task chore(gui): ledger — 04:19 patrol (knife-3 past half: callback chain through, ToolCallDetail up) chore(gui): ledger — night closeout summary: seven lanes closed, wake-up decision sheet chore: missions chore: missions chore: missions chore(gui): mission-local browser/probe verify scripts under missions/scripts/ The six acceptance/probe scripts move here as mission-side working material (headers and relative imports adjusted for the new location): carrier-errors, rpclog-panel, session, session-real, webserver-backpressure, webserver-hardening. chore(gui): verify-relocate mission log chore(gui): verify-relocate mission log — R1 guard addendum chore(gui): gates-continue mission log — CI-equivalent sequence all green chore(gui): VS Code 扩展体系双边设计调研报告 chore(gui): 调研追加 4.5 节——git 扩展数据面与 scope 绑定 docs(gui): web plugin system RFC — walkthrough + design notes docs(gui): RFC — restore existing SSE/POST as the v1 transport; envelope rides on it (D16) docs(gui): RFC — envelope demoted to chan-dispatch, scope out of envelope, rpc-log cut, peer deferred, scope tree is native cordis (D17-D21) docs(gui): RFC — hooks re-derived from component needs: useWatch/useAction only, useService removed; sessionHub cut, projections user-space, router rename, loader-only root (D22-D25) docs(gui): RFC — drop stale fork vocabulary (vendored cordis has Fiber only; scope = mintScope pattern), hook idempotence contract (D26-D27) docs(gui): RFC — session precision seam: plugins read scope key (host paradigm), React gets it from tree position via SlotOutlet (D28) docs(gui): RFC — domain hooks owned by plugins over framework primitives; useConversation paradigm carried over (D29) docs(gui): RFC — ctx services are the inter-plugin API (cordis proper); declarations are wire-only; get(id) returns scoped ctx (D30) docs(gui): RFC — full ctx.conversation walkthrough: root-singleton scope-sensitive service, caller-ctx scope key, get(key) as scoped ctx (D31) docs(gui): RFC — no client-side agents collection: session state machine already expresses the duality; agent resolution stays host authority (D32) docs(gui): RFC — v1 stays session-precision, no agent-level isolation; incarnation/agent-axis designs archived in ledger (D33) docs(gui): RFC walkthrough — full rewrite to final state (D16-D33 consolidated), end-to-end chain restored docs(gui): RFC — apiproxy demoted to generic channel routing; domain RPCs dissolve into owner plugins (D34) docs(gui): RFC — TS-interface-first wire contract (zod internal), conversation owns the dialogue frame with pluggable views (D35) docs(gui): RFC — page skeleton (sidebar+conversation), projects as plugin not service, nested slots via owner registries (D36) docs(gui): RFC — SlotMap declaration-merging slot model: single register API, inject-as-ownership, FC-typed registration, typed outlets (D37) docs(gui): RFC — slot props whitelist: identity, display params, materialized snapshot slices, stable UI callbacks (D38) docs(gui): RFC — end-to-end data flow: three transforms, equality protocol table, immer placement; i18n/theme kept standard (D39-D40) docs(gui): RFC — full external-injection model: props carry values + stable injected hooks; shared/client/react example rewritten (D41-D43) docs(gui): RFC final trio — modules.md (agent implementation spec), architecture.md (human walkthrough), plugins.md (business plugin inventory) docs(gui): RFC — props three-source merge (scope-standard useSession auto-injected); keyed key vs list id disambiguated (D44) docs(gui): RFC — inject comment says what it is (the React-facing props bundle); SessionHandle rename; snapshot-production story unified on buildSnapshot docs(gui): RFC architecture — full React component tree walkthrough: props three sources, slot vs plain children, hook taxonomy per node docs(gui): RFC — module map finalized (ui-slots/web-react/connection/runtime/ui-*/web); slots onChange replaced by cordis events; toolcall dimension; detail sidebar default-collapsed with toolName-keyed routing docs(gui): RFC plugins — openDetail relay chain: toolcard calls chat-view injected action, chat-view relays to conversation sidebar chore(gui): progress ledger — full archive rewrite: RFC outcome digest, open gaps, dispatch plan, cold-start entry docs(gui): RFC grill pass 1 — SlotScope axis (root/session) on declares, Gate dependency inversion, inject handle by scope, W5 acceptance list, gantt relay chain fixed docs(gui): figma analysis — sidebar/projects/sessions 区域交互视觉理解报告 docs(gui): figma 解析报告 — details 面板/多视图 tabs/未来功能区盘点 + slot 需求清单 docs(gui): figma 对话主区解析报告 — 消息流/tool calls 变体/审批接管输入框/Header tabs/视觉 token docs(gui): plugins.md rewritten from figma analysis — three-column layout, full slot reservation table, selection channel, composer-takeover approvals, phased scope docs(gui): layout dynamics ruled (drag+collapse both rails, details yields first, composer swap-panel, same-component transition); toolviews promoted to named scope-aware registry docs(gui): P-I scope locked (details minimal, dual theme, chat-view, custom toolview sample); teammate dispatch plan — 6 owners by package, dependency-driven waves, contract arbitration docs(gui): P-I api-contracts (full inter-package API spec) + dispatch plan (T0 skeleton knife, 7-dev roster, task briefs, milestones) docs(gui): api-contracts v2 — scope tree in P-I, bundle loader + per-plugin CSS isolation in P-I, agent-scoped toolviews live, zustand engine, renames (SessionProvider/ObservableSnapshot/SessionBinding), router owns all shell view-state docs(gui): services roster + progressive loading (no blocking loadAll), SlotsService as real cordis Service, renderSlot/renderSuspenseSlot duo, ui-traj teammate docs(gui): loading-chain gaps ruled — dev=rebundle no HMR, ui-primitives package, externals on globals (no import map), host injects __DSH_BOOT__ into HTML (zero round-trip) docs(gui): api-contracts v3 + dispatch v2 final — 12 packages, services merged in, progressive loader, global externals, __DSH_BOOT__ injection, 8-dev roster with convo split and ui-traj docs(gui): v3 amendments — router renamed ctx.layout, ui-trajectory has no service (pure consumer sample), wait-for-settled loading (no Suspense in P-I, ledger 6b) chore(gui): progress — pre-compact final state: v3 revision chain, 8-dev roster, T0 procedure, doc authority order docs(gui): authority banners — modules/architecture get v3 term-mapping headers, walkthrough marked as archived process doc docs(gui): cssdesign token set is THE theme source (--dsw-* variables, data-ds-dark-theme switch); recorded in contracts + progress docs(gui): architecture.md full v3 rewrite — loading chain, 12-package map, service roster, slot/inject/toolviews, data flow, component tree, perf model, all current docs(gui): contracts — UI plugins are dual-entry host plugins (node half serves client asset via ctx.webPlugins; __DSH_BOOT__ derives from it; client-closure gate back in scope) docs(gui): contracts — closure-factory bundles with DI require (no globals), package.json dshWeb declarative discovery (no serve ritual), create-then-send empty state with project picker, ancestry() for breadcrumb, unload stubbed until HMR, props.renderSlot confirmed docs(gui): dshClient declaration (inject/platform/immediately, exports./client), closure-DI require loading — synced across contracts/dispatch/modules/architecture/walkthrough chore(gui): progress — record final loading-chain rulings (dshClient declaration, closure-DI require, startSession) before compact docs(gui): architecture.md — developer-facing whole-web architecture on master baseline 6b16a67cb: what exists, what is new, no process narrative docs(gui): architecture.md — self-contained whole-web architecture: absorbs still-valid substance from the branch RFCs (host layering, four-quadrant RPC, object layer, testing tiers) under the new plugin system as the override chore(gui): progress — final pre-compact snapshot: contracts digest, apiproxy purity ruling, T0 procedure with first-action list docs(gui): api-contracts v3 §3.1 — apiproxy purity principle with three-way existing-code verdicts docs(gui): api-contracts v3 — immediately reinterpreted as static-infra group (8-package dshClient scope, boot manifest reconciliation) docs(gui): api-contracts v3 — immediately corrected to early-load dynamic group (prod shell must not rebundle); loader shell-held; bundles register their export surface into module table docs(gui): architecture — align with immediately=early-load dynamic group ruling; loader shell-held; module-table registration of loaded bundles docs(gui): T0 checklist — 12-package skeleton table, 4-cut sequence, mv/attic/rewire rules (pre-drafted, awaiting go) docs(gui): t0-checklist — pin figma-flows findings (missing font-family base vars, three alias vars behind upstream) docs(gui): dispatch v2.1 — drop cordis-web salvage wording, two-wave staffing, loader/immediately boundary updates docs(gui): progress + t0-checklist ledger — T0 landed, staffing status, execution accounting docs(gui): api-contracts v3 — arbitration round 1: renderBody deps, RootBindingProvider, flush default sync, prune current, loader subpath, config-source P-I bar docs(gui): v3 §3.2 connection 导出清单附录(rt-core 对账)+ rt-core 实现计划档案 docs(gui): progress — T1 milestone, arbitration round 1 ledger, fw-react timeout escalation docs(gui): progress rolling update — per-line battlefield state at 00:2x, mailbox-vs-contract lesson, small-batch discipline reinforced docs(gui): fw-react notes — v3 §2 complete, seven knives, T1/T2 follow-ups docs(fw-slots): archive — four packages landed, open tails logged docs(gui): progress — framework layer complete (web-react five, fw-slots four packages), T2 gated on rt-core runtime knife only docs: api-contracts docs: style-spec docs docs(gui): tsconfig convergence ruling — no host.json, root resumes host-aggregate duty, typecheck = root + client aggregates docs(gui): missions 根三份 07-18 世代档案加「已被取代」头注——指向 web-plugin-rfc 现行权威并注明新旧对应 missions docs(gui): progress rewritten for post-closeout state — wave ledger, architecture finale, teammate roster with handover notes, pending-user-command queue --- missions/conventions.md | 37 + missions/plan.md | 33 + missions/progress.md | 58 ++ missions/scripts/verify-carrier-errors.mjs | 113 +++ missions/scripts/verify-rpclog-panel.mjs | 99 ++ missions/scripts/verify-session-real.mjs | 132 +++ missions/scripts/verify-session.mjs | 361 ++++++++ .../scripts/verify-webserver-backpressure.mjs | 45 + .../scripts/verify-webserver-hardening.mjs | 37 + .../README.md | 34 + .../deepseekchat-baseline.md | 74 ++ .../design.md | 636 +++++++++++++ .../harness-boot-facts.md | 184 ++++ .../README.md | 68 ++ .../core-coverage.md | 170 ++++ .../design.md | 413 +++++++++ .../opencode-crosscheck.md | 17 + .../findings.md | 72 ++ .../tasks/20260719-2036-step1-impl/README.md | 57 ++ .../20260719-2039-rpc-vs-jsonrpc/README.md | 18 + .../20260719-2039-rpc-vs-jsonrpc/findings.md | 87 ++ .../tasks/20260719-2119-step2-impl/README.md | 47 + .../README.md | 30 + .../design.md | 539 +++++++++++ .../README.md | 44 + .../design.md | 865 ++++++++++++++++++ .../20260719-2315-style-research/README.md | 37 + .../shot-rpclog.mjs | 29 + .../style-research.md | 218 +++++ .../upgrade-rpclog-v2.md | 22 + .../20260719-2339-web-cordis-design/README.md | 7 + .../blueprint-v2.md | 206 +++++ .../bundle-loader-design.md | 119 +++ .../20260719-2339-web-cordis-design/design.md | 170 ++++ .../README.md | 24 + .../design.md | 323 +++++++ .../20260720-0206-rfc-consolidation/README.md | 35 + .../outline.md | 194 ++++ .../rfc-list-proposal.md | 79 ++ .../20260720-0246-inputbar-fix/README.md | 47 + .../tasks/20260720-0246-inputbar-fix/bugs.md | 65 ++ .../20260720-0246-inputbar-fix/compare.md | 32 + .../20260720-0246-inputbar-fix/probe.mjs | 155 ++++ .../20260720-0246-inputbar-fix/shot-theme.mjs | 33 + .../tasks/20260720-0246-inputbar-fix/shot.mjs | 36 + .../20260720-0250-comment-sweep/README.md | 46 + .../README.md | 17 + .../audit.md | 90 ++ .../notes.md | 90 ++ .../tasks/20260720-0337-test-design/README.md | 6 + .../tasks/20260720-0337-test-design/design.md | 279 ++++++ .../README.md | 7 + .../multiclient-e2e.mjs | 73 ++ .../report.md | 90 ++ .../streaming-observe.mjs | 40 + .../three-tabs-pool.mjs | 50 + .../two-tabs-pool.mjs | 50 + .../20260720-0920-settings-research/README.md | 15 + .../settings-inventory.md | 84 ++ .../20260720-1030-feature-session/README.md | 70 ++ .../exports-survey.md | 83 ++ .../20260720-1238-history-rebase/README.md | 45 + .../tasks/20260720-1315-docs-float/README.md | 39 + .../tasks/20260720-1440-pr-gates/README.md | 65 ++ .../20260720-1545-arch-session/README.md | 121 +++ .../tasks/20260720-1545-web-test/README.md | 51 ++ .../20260720-1620-cordis-spike/README.md | 240 +++++ .../poc/app/client-apiproxy-probe.ts | 10 + .../poc/app/client-main.ts | 22 + .../poc/app/client-violation-main.ts | 17 + .../poc/app/escape-dts-import-type.ts | 16 + .../poc/app/escape-paths-override.ts | 9 + .../poc/app/escape-src-channel.ts | 9 + .../poc/app/negative.ts | 15 + .../poc/app/node-main.ts | 15 + .../poc/app/smuggle-chain.ts | 13 + .../poc/app/v1-misuse-main.ts | 10 + .../poc/clean-program-files.txt | 24 + .../poc/echo-a/package.json | 14 + .../poc/echo-a/src/client.ts | 14 + .../poc/echo-a/src/node.ts | 16 + .../poc/echo-a/src/shared.ts | 13 + .../poc/echo-b/package.json | 15 + .../poc/echo-b/src/node.ts | 23 + .../poc/echo-c/package.json | 13 + .../poc/echo-c/src/node.ts | 14 + .../poc/echo-d/package.json | 10 + .../poc/echo-d/src/node.ts | 19 + .../poc/gate-api.mjs | 169 ++++ .../20260720-1620-cordis-spike/poc/gate.mjs | 75 ++ .../poc/ide/client/main.ts | 25 + .../poc/ide/client/tsconfig.json | 8 + .../poc/ide/node/main.ts | 15 + .../poc/ide/node/tsconfig.json | 7 + .../poc/tsconfig.client-apiproxy.json | 5 + .../poc/tsconfig.client-violation.json | 9 + .../poc/tsconfig.client.json | 8 + .../poc/tsconfig.escape-dts.json | 5 + .../poc/tsconfig.escape-paths.json | 44 + .../poc/tsconfig.escape-src.json | 5 + .../poc/tsconfig.node.json | 7 + .../poc/tsconfig.shared.json | 38 + .../poc/tsconfig.v1-misuse.json | 5 + .../poc/types/buffer-shim.d.ts | 7 + .../poc/types/nodejs-timeout-shim.d.ts | 8 + .../20260720-1630-arch-carrier/README.md | 47 + .../20260720-1640-gui-arch-research/README.md | 33 + .../feature-list.md | 95 ++ .../20260720-1640-gui-arch-research/report.md | 233 +++++ .../README.md | 23 + .../survey.md | 170 ++++ .../20260720-1709-dsc-dsh-rename/README.md | 67 ++ .../README.md | 22 + .../report.md | 204 +++++ .../20260720-1900-toolcard-wire/design.md | 71 ++ .../20260720-2125-gateway-design/README.md | 154 ++++ .../20260720-2125-gateway-design/design.md | 651 +++++++++++++ .../20260720-2131-regroup-32-to-10/README.md | 32 + .../20260720-2149-pr-gates-round2/README.md | 45 + .../tasks/20260720-2202-translator/README.md | 3 + .../20260720-2224-gate-finisher/README.md | 3 + .../tasks/20260720-2325-i18n-design/README.md | 31 + .../tasks/20260720-2325-i18n-design/design.md | 177 ++++ .../agents-md-contribution.md | 34 + .../component.spec.tsx.template | 52 ++ .../pure-data.spec.ts.template | 31 + .../tasks/20260721-0041-app-shell/design.md | 115 +++ .../tasks/20260721-0041-app-shell/impl-log.md | 77 ++ .../20260721-0041-web-agents-md/README.md | 51 ++ .../20260721-0054-coverage-fixer/README.md | 45 + .../tasks/20260721-0120-jsdom-e2e/README.md | 43 + .../20260721-0209-respond-design/README.md | 62 ++ .../20260721-0209-respond-design/design.md | 107 +++ .../tasks/20260721-0210-oop-debt/report.md | 45 + .../review.md | 41 + .../review.md | 64 ++ .../20260721-1140-verify-relocate/README.md | 44 + .../20260721-1250-gates-continue/README.md | 35 + .../report.md | 280 ++++++ .../api-contracts.md | 433 +++++++++ .../architecture.md | 334 +++++++ .../clientcontext-audit.md | 120 +++ .../cssdesign/design-platform.css | 326 +++++++ .../cssdesign/gradient-shadow-text.css | 224 +++++ .../design-notes.md | 259 ++++++ .../20260721-1520-web-plugin-rfc/dispatch.md | 100 ++ .../figma-analysis/conversation.md | 205 +++++ .../figma-analysis/flows-future.md | 278 ++++++ .../figma-analysis/sidebar.md | 139 +++ .../20260721-1520-web-plugin-rfc/modules.md | 289 ++++++ .../20260721-1520-web-plugin-rfc/plugins.md | 224 +++++ .../style-spec.md | 336 +++++++ .../t0-checklist.md | 82 ++ .../w5-visual-verdict.md | 261 ++++++ .../walkthrough.md | 424 +++++++++ missions/tasks/20260721-p1-fw-react/notes.md | 81 ++ missions/tasks/20260721-p1-fw-slots/notes.md | 184 ++++ .../history-timing-data.md | 42 + missions/tasks/20260721-p1-rt-core/notes.md | 155 ++++ missions/tasks/20260721-p1-ui-shell/notes.md | 164 ++++ missions/tasks/20260722-p1-convo-a/plan.md | 50 + missions/tasks/20260722-p1-ui-side/notes.md | 49 + missions/tasks/20260722-p1-ui-traj/plan.md | 22 + missions/ui-product.md | 221 +++++ missions/ui-tech.md | 309 +++++++ 165 files changed, 17001 insertions(+) create mode 100644 missions/conventions.md create mode 100644 missions/plan.md create mode 100644 missions/progress.md create mode 100644 missions/scripts/verify-carrier-errors.mjs create mode 100644 missions/scripts/verify-rpclog-panel.mjs create mode 100644 missions/scripts/verify-session-real.mjs create mode 100644 missions/scripts/verify-session.mjs create mode 100644 missions/scripts/verify-webserver-backpressure.mjs create mode 100644 missions/scripts/verify-webserver-hardening.mjs create mode 100644 missions/tasks/20260719-1843-step1-skeleton-design/README.md create mode 100644 missions/tasks/20260719-1843-step1-skeleton-design/deepseekchat-baseline.md create mode 100644 missions/tasks/20260719-1843-step1-skeleton-design/design.md create mode 100644 missions/tasks/20260719-1843-step1-skeleton-design/harness-boot-facts.md create mode 100644 missions/tasks/20260719-1902-apiproxy-api-design/README.md create mode 100644 missions/tasks/20260719-1902-apiproxy-api-design/core-coverage.md create mode 100644 missions/tasks/20260719-1902-apiproxy-api-design/design.md create mode 100644 missions/tasks/20260719-1902-apiproxy-api-design/opencode-crosscheck.md create mode 100644 missions/tasks/20260719-1902-opencode-api-research/findings.md create mode 100644 missions/tasks/20260719-2036-step1-impl/README.md create mode 100644 missions/tasks/20260719-2039-rpc-vs-jsonrpc/README.md create mode 100644 missions/tasks/20260719-2039-rpc-vs-jsonrpc/findings.md create mode 100644 missions/tasks/20260719-2119-step2-impl/README.md create mode 100644 missions/tasks/20260719-2140-ui-milestone1-design/README.md create mode 100644 missions/tasks/20260719-2140-ui-milestone1-design/design.md create mode 100644 missions/tasks/20260719-2247-step-session-design/README.md create mode 100644 missions/tasks/20260719-2247-step-session-design/design.md create mode 100644 missions/tasks/20260719-2315-style-research/README.md create mode 100644 missions/tasks/20260719-2315-style-research/shot-rpclog.mjs create mode 100644 missions/tasks/20260719-2315-style-research/style-research.md create mode 100644 missions/tasks/20260719-2315-style-research/upgrade-rpclog-v2.md create mode 100644 missions/tasks/20260719-2339-web-cordis-design/README.md create mode 100644 missions/tasks/20260719-2339-web-cordis-design/blueprint-v2.md create mode 100644 missions/tasks/20260719-2339-web-cordis-design/bundle-loader-design.md create mode 100644 missions/tasks/20260719-2339-web-cordis-design/design.md create mode 100644 missions/tasks/20260720-0101-hostruntime-split-design/README.md create mode 100644 missions/tasks/20260720-0101-hostruntime-split-design/design.md create mode 100644 missions/tasks/20260720-0206-rfc-consolidation/README.md create mode 100644 missions/tasks/20260720-0206-rfc-consolidation/outline.md create mode 100644 missions/tasks/20260720-0206-rfc-consolidation/rfc-list-proposal.md create mode 100644 missions/tasks/20260720-0246-inputbar-fix/README.md create mode 100644 missions/tasks/20260720-0246-inputbar-fix/bugs.md create mode 100644 missions/tasks/20260720-0246-inputbar-fix/compare.md create mode 100644 missions/tasks/20260720-0246-inputbar-fix/probe.mjs create mode 100644 missions/tasks/20260720-0246-inputbar-fix/shot-theme.mjs create mode 100644 missions/tasks/20260720-0246-inputbar-fix/shot.mjs create mode 100644 missions/tasks/20260720-0250-comment-sweep/README.md create mode 100644 missions/tasks/20260720-0300-web-dev-2-onboarding/README.md create mode 100644 missions/tasks/20260720-0300-web-dev-2-onboarding/audit.md create mode 100644 missions/tasks/20260720-0300-web-dev-2-onboarding/notes.md create mode 100644 missions/tasks/20260720-0337-test-design/README.md create mode 100644 missions/tasks/20260720-0337-test-design/design.md create mode 100644 missions/tasks/20260720-0356-multiclient-research/README.md create mode 100644 missions/tasks/20260720-0356-multiclient-research/multiclient-e2e.mjs create mode 100644 missions/tasks/20260720-0356-multiclient-research/report.md create mode 100644 missions/tasks/20260720-0356-multiclient-research/streaming-observe.mjs create mode 100644 missions/tasks/20260720-0356-multiclient-research/three-tabs-pool.mjs create mode 100644 missions/tasks/20260720-0356-multiclient-research/two-tabs-pool.mjs create mode 100644 missions/tasks/20260720-0920-settings-research/README.md create mode 100644 missions/tasks/20260720-0920-settings-research/settings-inventory.md create mode 100644 missions/tasks/20260720-1030-feature-session/README.md create mode 100644 missions/tasks/20260720-1030-feature-session/exports-survey.md create mode 100644 missions/tasks/20260720-1238-history-rebase/README.md create mode 100644 missions/tasks/20260720-1315-docs-float/README.md create mode 100644 missions/tasks/20260720-1440-pr-gates/README.md create mode 100644 missions/tasks/20260720-1545-arch-session/README.md create mode 100644 missions/tasks/20260720-1545-web-test/README.md create mode 100644 missions/tasks/20260720-1620-cordis-spike/README.md create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/client-apiproxy-probe.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/client-main.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/client-violation-main.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/escape-dts-import-type.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/escape-paths-override.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/escape-src-channel.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/negative.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/node-main.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/smuggle-chain.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/app/v1-misuse-main.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/clean-program-files.txt create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-a/package.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-a/src/client.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-a/src/node.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-a/src/shared.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-b/package.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-b/src/node.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-c/package.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-c/src/node.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-d/package.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/echo-d/src/node.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/gate-api.mjs create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/gate.mjs create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/ide/client/main.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/ide/client/tsconfig.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/ide/node/main.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/ide/node/tsconfig.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.client-apiproxy.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.client-violation.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.client.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.escape-dts.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.escape-paths.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.escape-src.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.node.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.shared.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/tsconfig.v1-misuse.json create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/types/buffer-shim.d.ts create mode 100644 missions/tasks/20260720-1620-cordis-spike/poc/types/nodejs-timeout-shim.d.ts create mode 100644 missions/tasks/20260720-1630-arch-carrier/README.md create mode 100644 missions/tasks/20260720-1640-gui-arch-research/README.md create mode 100644 missions/tasks/20260720-1640-gui-arch-research/feature-list.md create mode 100644 missions/tasks/20260720-1640-gui-arch-research/report.md create mode 100644 missions/tasks/20260720-1652-components-research/README.md create mode 100644 missions/tasks/20260720-1652-components-research/survey.md create mode 100644 missions/tasks/20260720-1709-dsc-dsh-rename/README.md create mode 100644 missions/tasks/20260720-1749-tool-render-research/README.md create mode 100644 missions/tasks/20260720-1749-tool-render-research/report.md create mode 100644 missions/tasks/20260720-1900-toolcard-wire/design.md create mode 100644 missions/tasks/20260720-2125-gateway-design/README.md create mode 100644 missions/tasks/20260720-2125-gateway-design/design.md create mode 100644 missions/tasks/20260720-2131-regroup-32-to-10/README.md create mode 100644 missions/tasks/20260720-2149-pr-gates-round2/README.md create mode 100644 missions/tasks/20260720-2202-translator/README.md create mode 100644 missions/tasks/20260720-2224-gate-finisher/README.md create mode 100644 missions/tasks/20260720-2325-i18n-design/README.md create mode 100644 missions/tasks/20260720-2325-i18n-design/design.md create mode 100644 missions/tasks/20260721-0010-gate-friendly/agents-md-contribution.md create mode 100644 missions/tasks/20260721-0010-gate-friendly/component.spec.tsx.template create mode 100644 missions/tasks/20260721-0010-gate-friendly/pure-data.spec.ts.template create mode 100644 missions/tasks/20260721-0041-app-shell/design.md create mode 100644 missions/tasks/20260721-0041-app-shell/impl-log.md create mode 100644 missions/tasks/20260721-0041-web-agents-md/README.md create mode 100644 missions/tasks/20260721-0054-coverage-fixer/README.md create mode 100644 missions/tasks/20260721-0120-jsdom-e2e/README.md create mode 100644 missions/tasks/20260721-0209-respond-design/README.md create mode 100644 missions/tasks/20260721-0209-respond-design/design.md create mode 100644 missions/tasks/20260721-0210-oop-debt/report.md create mode 100644 missions/tasks/20260721-0223-peer-carrier-review/review.md create mode 100644 missions/tasks/20260721-0239-respond-contract-review/review.md create mode 100644 missions/tasks/20260721-1140-verify-relocate/README.md create mode 100644 missions/tasks/20260721-1250-gates-continue/README.md create mode 100644 missions/tasks/20260721-1330-vscode-extension-research/report.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/api-contracts.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/architecture.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/clientcontext-audit.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/cssdesign/design-platform.css create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/cssdesign/gradient-shadow-text.css create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/design-notes.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/dispatch.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/figma-analysis/conversation.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/figma-analysis/flows-future.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/figma-analysis/sidebar.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/modules.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/plugins.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/style-spec.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/t0-checklist.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/w5-visual-verdict.md create mode 100644 missions/tasks/20260721-1520-web-plugin-rfc/walkthrough.md create mode 100644 missions/tasks/20260721-p1-fw-react/notes.md create mode 100644 missions/tasks/20260721-p1-fw-slots/notes.md create mode 100644 missions/tasks/20260721-p1-rt-core/history-timing-data.md create mode 100644 missions/tasks/20260721-p1-rt-core/notes.md create mode 100644 missions/tasks/20260721-p1-ui-shell/notes.md create mode 100644 missions/tasks/20260722-p1-convo-a/plan.md create mode 100644 missions/tasks/20260722-p1-ui-side/notes.md create mode 100644 missions/tasks/20260722-p1-ui-traj/plan.md create mode 100644 missions/ui-product.md create mode 100644 missions/ui-tech.md diff --git a/missions/conventions.md b/missions/conventions.md new file mode 100644 index 0000000000..fc3139a495 --- /dev/null +++ b/missions/conventions.md @@ -0,0 +1,37 @@ +# GUI 项目工作约定(用户历次拍板沉淀;对所有参与者生效) + +> 本文件 = 本项目内的持久规矩。全局个人偏好在 Claude 记忆里;这里只放**这个项目**的要求。架构类决策不在此(见 docs/rfc/ 四篇与 docs/web-styling.md)。 + +## 流程与协作 + +1. **设计先行,文档给用户 review**:新领域先出设计文档(missions/tasks/ 归档)经用户过目再编码;量小或机械照抄类可直接生码,但契约/架构变更必须先改文档。 +2. **teammate 组织**:耗时任务开 background teammate;干完不 kill 保持存活当长期 owner(后续变更 SendMessage 派发);会话断了从 missions/tasks/ 归档冷启动同名 owner。设计 owner 兼任本领域实现 dispatcher;worker 反复超时时 owner 直接下场写。 +3. **小步快跑**(网络慢易超时):文件改动分批落盘(每批几分钟内)、思考外化、每批一句话回执;产出零落盘超过约 5 分钟即视为可疑。 +4. **commit 纪律**:`--no-verify` 跳过门禁(GUI 免门禁期);不同性质的改动分刀提交(注释类落盘即提不攒批,不与功能改动混);RFC 独立成刀(回刷时要整体挪位)。工作区里其他 teammate 的在途文件不许混入自己的 commit。commit message 不携带 Co-Authored-By 等 co-auth 尾注。 + - **门禁按 PR 周期收口**(用户 2026-07-20 定):测试/门禁只在提 PR 的窗口集中修(2026-07-20 首次 PR 已修过一轮);平时快速开发不随手写测试、不盯门禁;期间弄红存量测试记台账不追修,下次 PR 窗口统一算账。分层结构与文件落点(包级 `tests/`、`.spec.ts` 命名)从第一天守全仓惯例——PR 窗口收口的只是阈值与红绿,不是搬迁。 + - **文档住顶刀**:GUI 文档(missions/、docs/rfc/、docs/ui-*.md、docs/web-styling.md)集中在最顶部的 docs commit,底部实现历史不含文档。每次代码改动的提交顺序:先把文档改动提交完,再开新 commit 改代码;被代码刀压下去的文档 commit,找时间 rebase 重排合并回最顶(重排铁律:终树 diff 为零)。 +5. **跨属地改动**:动别人属地的代码先报告/事后备案给属地 owner;契约有误先改契约文档再让实现照抄;发现契约缺口只报告不擅改。 +6. **验收自动化**:UI 交付前 agent 自己跑 playwright(chromium headless)过验收清单,不留给用户手验;每修一个 bug 钉一条防回归断言进 verify 脚本;「fixture 全绿」不算完——真 host 级也要过(fixture 掩盖时序 bug 有两次实证)。 +7. **进度可见**:用户要求时开 5 分钟巡检(盘上核实+表格同步+催落后线);巡检探针只用安全 URL。 +7a. **未答问题不得代答**(用户 2026-07-21 定,起因:主会话在提问超时后擅自「代拍」并据此派工):向用户发出的问题若未获回答(超时/离席),该问题**保持未决**——不许以「推荐项/合理默认」自动代答,不许基于代答派发任何工作;只能执行此前已获明确授权的部分,未决项对应的工作线整体挂起等答复。「用户睡觉/全自动模式」也不例外:自动化只覆盖已拍口径内的执行,不覆盖替用户做新决策。 + +## 代码与文档 + +8. **代码注释一律英文且少写**:只留非显然契约/约束/防坑(如 Node 16 req 'close' 语义);不写叙述性/复述代码/评审史;中文只用于 missions/docs 文档(供用户 review)。产品 UI 文案中文,不算注释。 +9. **注释不引用工作记录**:禁止引 missions/tasks/*.md、设计稿节号、裁决时间戳——首选注释自含说清约束;确需出处才引 docs/rfc/ 正式 RFC。 +10. **产物分流**:截图进 .artifacts/(gitignored);有归档价值的验收脚本进 scripts/;一次性诊断脚本进 ignore 目录。 +11. **命名规则**:packages/host/*、packages/client/* 的包名必含目录前缀(dsh-host-*、dsh-client-*);全仓 rename 走冻结窗口一次改完。 +12. **妥协台账三段式**:设计文档的不做清单写【触发条件(具体到事件)→ 返工点 → 预埋要求】,不写模糊的「将来优化」。 +13. **RFC 是活文档**:大改动落地后主动扫时效更新,不等用户提醒;面向开发者体裁(现状+怎么开发),取舍原因短写。 + +## 架构红线(详见 RFC,此处仅提醒高频踩点) + +14. store 无业务对象(sessions/connection 走 OOP 对象层+useSyncExternalStore);视图选中态等 UI 局部事实不进全局 store。 +15. rpcId 严格双向(发起方 mint、应答方回填),但业务函数签名只见 RpcRequest

封装,mint 收在载体层。 +16. 逻辑面(hooks/对象层)与展示面(纯 props 组件)分离——组件是耗材会重做。 +17. Notifier 双通道纪律:仅用户手势直接回响可用 notifyNow,帧驱动一律 markDirty 合批。 +18. **web 是纯呈现层,呈现物不进 session log**(用户 2026-07-20 定):log 只记模型真正经历的事;「怎么画」类数据(tool 卡 view、queue 排队态等控制面)一律 host 现算随帧下发或 live 帧推送,不持久化——重放时按当时能力重算,算不出就回退通用形态(documented-default)。 + +## 终局工程(已定待执行) + +18. RFC 中英文提交后**回刷历史 commit**:消掉 missions 工作记录、RFC 插历史配对、历史注释转英(映射表在 tasks/20260720-0250-comment-sweep/);执行时冻结所有其他工作。web-cordis 设计归档列删除豁免(用户自改)。 diff --git a/missions/plan.md b/missions/plan.md new file mode 100644 index 0000000000..07f6421f90 --- /dev/null +++ b/missions/plan.md @@ -0,0 +1,33 @@ +> **【已被取代——历史档案】** 本文是 GUI 立项期的原始任务书(07-18 前)。现行权威=`missions/tasks/20260721-1520-web-plugin-rfc/` 的 api-contracts.md v3(接口契约)+ architecture.md(架构讲解):当年的「后台 server + React 壳」已演进为 host/client 双 cordis 插件树(12 个 packages/client/* 包、bundle loader 动态装载);「设置页/Provider 配置」未进 P-I 范围。滚动进度见 missions/progress.md。 + +DO NOT read AGENTS.md / CLAUDE.md in this project !! + +要做的 PLAN +1. 需要做一个 Harness 的 UI 架构,设计模型上同时考虑 TUI / Electron / WebUI 同时对接,目前看到 opencode 的分层挺好的。 + 1. WebUI: 基于本地 server localhost + 消息协议 + 2. Electron: renderer 与 WebUI 相同,main 仅用于处理桌面专用功能(窗口、更新、Menu) +2. 当前实现Web localhost (后台 server 模式). +3. Web 整体架构基于 React + vite 可见参考项目 DeepSeek Chat +4. Web 设计上需要引入 Cordis Context (虽然现在不用每个组件都引入,但是先保证有一个 root Context ,能初始化基础 Service 上去,作为 与 React 对等根的形式) + 1. 考虑到合理性诉求,需要确认 vscode 当前的插件隔离模型 +5. 首先还是得实现一个简易的对话流和设置页 + 1. Session 选择:当前一共有多少个 sessionId ,按时间列 + 2. 对话流:输入框、流式输入、Markdown 展示,tool 显示,发送排队,数据走 SSE/WebSocket 不确定 + 3. 设置页:Provider 配置/APIKEY 配置、模型列表 + +最新调研结论,在 +- missions/ui-product.md +- missions/ui-tech.md + + +前提: +- 说中文,记录中文 +- 当前主会话任务非常繁忙,如果有各类调研和编码任务,请启动 agent team subagent (background,不阻塞主会话)。 + - 主会话可以创建 dispatcher,dispatcher 可以创建 worker。 + - dispatcher 负责干完整命题,worker 负责干具体耗时任务,交由dispatcher进行汇总。主会话负责表格化同步所有任务进展 +- 主会话和 subagent 的所有工作,需要在 missions/tasks/$具体任务$ 中按照时间(精确到分钟)-任务名归档,边干边记录变化,便于回溯 +- 当前 deepseep-harness 项目不需要先投入时间经历分析,先搞其他 + +参考项目地址可以访问: +- opencode: /weka-hg/prod/deepseek/permanent/ys/private/workspace/github/opencode +- deepseekchat: /weka-hg/prod/deepseek/permanent/ys/private/workspace/gitlab/deepsuite-frontend diff --git a/missions/progress.md b/missions/progress.md new file mode 100644 index 0000000000..0d6673e862 --- /dev/null +++ b/missions/progress.md @@ -0,0 +1,58 @@ +# GUI 项目进度账本 + +> 2026-07-22 13:2x 版(P-I 已收口+两轮收尾波次完成)。本版=下次冷启动唯一入口;施工期逐日流水已压缩,细节见 git log 与 missions/tasks/ 档案。 + +## 一、当前态(2026-07-22 13:2x) + +- **P-I(UI 插件化系统全链路)已完成并收口**:T0-T5 全里程碑达成,W5 验收通过(真 key 真 host 真模型 8/8 动线+figma 逐屏判定高 0 中 0 低 6+回归钉全入库)。**严禁 push/merge——留用户本人**;conventions 7a 未答问题不得代答。 +- **基线**:用户已两轮 rebase/squash——远端 worktree-web2 = origin/master 合并串 + `93d6adea1`(全部产品代码一刀)+ `8b428cb14`(missions 档案一刀);其上叠本地后续刀。**missions/ 假设最终不进 PR**——一切对外文档(RFC/README/docs)必须自含,不得引用 missions。 +- **在途(唯一)**:rt-core 的 RFC①(gui-layering-and-rpc-protocol 双语对)落库后**全线暂停**(用户令)。 +- **待用户令**:①对新远端基线的 rebase 叠刀(操作同前两轮:`git rebase --onto origin/worktree-web2 <本地对应点>`,本地对应点=用户指定,上轮为 7e529a633 语义等效点);②check:pre-push 终验时机;③merge。 + +## 二、已完成波次台账(P-I 收口后) + +| 波次 | 内容 | 状态 | +|---|---|---| +| 门禁修复 | build(tsdown 豁免→后随 apps/web 恢复撤销)/verify-cordis-config/module-graph/doc 全系列/type-equiv/export-jsdoc/knip(最小 diff 重写 69322d3bb)/README×2(118 全 conform)/constraints+invariants(client 12 包 fw-react 四批+host 三包 rt-core,118 伴生全 conform)/llm-retry timeout 提额 | ✅ 除 test/lint/publint/snapshot 终验未跑(等用户令) | +| 工程结构调整(用户三点裁定) | ①apps/web 恢复=vite 应用(@deepseek-ai/dsh-frontend,ui-shell),packages/client/web 降回 lib(bootWebShell 库导出);②tsconfig 收敛:删 tsconfig.host.json,根恢复 host 聚合原职,仅新增 tsconfig.client.json,根 diff 压至 ±13 行(fw-react);③exports 纪律:dshClient 八包 node index=只空 apply 零类型导出,实现/类型全住 src/client/,消费走 /client 子路径,测试 import /src 直取,纯库三包豁免(rt-core 四刀) | ✅ | +| 时效清扫 | 文档半(convo-a 五刀):missions 根三份 07-18 旧世代档案加取代头注/testing.md 残句/web-styling token 换代注记/四对 GUI Agent Note 路径更新+i18n 重录。测试半(convo-b+rt-core):死码三件退役——init/getSessionManager 单例对、**Session draft 面整删**(sendDraft/setDraft/snapshot.draft,仲裁 24d413133:真实链走 ConversationService+apply draftsStore,双账=平移残留)、WEB_EVENTS/WebEventName(web-cordis pre-provision 零消费);判留 4 组有据(对象层五件套/loader stub 契约钉/fake-api 双胞胎/connection 三 spec) | ✅ | +| RFC 刷新(missions 不进 PR 前提) | ②web-client-architecture 已落(0033d7d8a,fw-react):自含化+新增 cordis 树/装载链/slot 体系/scope 寻址三大节,对象层去 draft 面,目录终态;①layering-and-rpc-protocol(rt-core)在途收尾 | 🔄 ①落库即全线暂停 | + +## 三、架构终态速记(防冷启动失忆;对外叙述见两份 RFC) + +- **工程结构**:host 三包(apiproxy/runtime/webserver)+ client 纯库三包(ui-slots/web-react/ui-primitives,根 index 库形态)+ dshClient 插件八包(connection/runtime/ui-theme/i18n/ui-layout/ui-sidebar/ui-conversation/ui-trajectory——node index=只空 apply,实现全在 src/client/,消费走 /client 子路径)+ apps/cli + **apps/web(@deepseek-ai/dsh-frontend,vite 应用,薄 main 调 packages/client/web 的 bootWebShell)**。 +- **tsconfig**:根=host 聚合(exclude packages/client/**)+tsconfig.client.json=client 聚合(12 包+tsx specs+apps/web+purity spec/preset);typecheck=`tsc -b tsconfig.json tsconfig.client.json` 单命令双 program——host/client 对 cordis Context merge 同名键(sessions/loader),双 program 隔离撞名;client 经 session/llm/tools/approval/interaction 的纯类型子路径(./types 等)消费 wire 词汇,不装载 host augmentation。 +- **装载链**:GET / 注 __DSH_BOOT__(HostWebPluginRegistry 订 Loader+dshClient 声明)→loader(壳静态持有)immediately 四包并行先装→其余 inject 拓扑→DSHClientProxy.loadPlugin 闭包工厂+DI require 模块表+导出面回登记→settled 一次成型。三防线:bundle 纯度门(resolveId 三分类,裸名自动改写 /client)/loader e2e 吃真产物/mount 锚 fiber-less throw。 +- **契约史料**:v3 全落款在 missions/tasks/20260721-1520-web-plugin-rfc/api-contracts.md(含 §3.1 apiproxy 纯度/§3.2 导出清单与溶解项/§4.0 双 program 终裁);style-spec.md=样式对账永久底册。missions 不进 PR,故正式权威=两份 RFC+各包 README+docs/ 生成物。 +- **draft 单账终态**:草稿归 ConversationService.drafts(persist keyed by sessionId)+apply.ts composer 编排(乐观清稿/失败回填);Session 无 draft 面。 + +## 四、挂账(PR 窗口/P-II) + +- **PR 窗口**:pre-push 终验未跑段(test 全量/lint/publint/snapshot——注意 test-invariants 已随伴生齐而自愈过,最后一轮结构调整后需复跑);低 6 视觉偏差(判定报告尾表);W5 补拍两项(树展开/hover 已拍过一轮,审批琥珀条=P-II)。 +- **P-II 池**:approvals composer 换面板/slash/toast/details 三段/HMR+unload 完整链/history 纯持久化读(1.75s 案已证伪为旧 lib 测量假象——rt-core history-timing-data.md,纯读改造只剩语义论据,动 wire 需用户拍板)/agentFor+summarizeCold 下沉 host/viewFor+backscanArgs 删除刀(涉 wire view 字段)/assertServable O(n) 冷径/delegationDepth 拒收加 warn/drafts persist 回迁/二级树+长列表+暗 hover+审批条视觉复核。 +- **终局工程**:回刷历史(missions 消档+注释转英——执行时冻结全部)。 + +## 五、teammate 名册与现状(2026-07-22 13:2x;主会话可能被 clear/compact——本表=接续依据) + +> 九人全部**存活常驻**(SendMessage 按名直达)。主会话重启后:先读本文件+git log 恢复盘面,再按「在途/待命」逐人接管。当前全队在「RFC①落库即全线暂停」令下。 + +| teammate | 属地 | 当前状态 | 备注(接续要点) | +|---|---|---|---| +| **rt-core** | connection/runtime 两包+host 三包(apiproxy/runtime/webserver)+装载链/纯度门 | 🔄 **唯一在途**:RFC①(gui-layering-and-rpc-protocol 双语对+i18n 重录)收尾中,落库后按令静默 | 超时惯犯但产出全队最大;信箱丢失率高——催报先看 git log。档案 missions/tasks/20260721-p1-rt-core/(含 history-timing-data.md) | +| **fw-react** | web-react 包+tsconfig 双聚合体系+clientcontext-audit 细案 | 💤 待命(RFC② 0033d7d8a 刚交付) | 早期三连超时后改极小步脱困;擅长机械大批量与文档。档案 20260721-p1-fw-react/ | +| **fw-slots** | ui-slots/ui-primitives/ui-theme/i18n 四包+token 体系 | 💤 待命 | 全队质量标杆;图标管线(geometry 直读→实证落库)共识在档。挂账:sparkle 精确字形/wordmark svg 未提取。档案 20260721-p1-fw-slots/ | +| **ui-shell** | ui-layout+packages/client/web(lib)+apps/web(vite 应用)+tsdown preset+W5 探针 | 💤 待命 | W5 probe/smoke-real/boot-chain e2e 全它写;apps/web 恢复刚完工。档案 20260721-p1-ui-shell/ | +| **ui-side** | ui-sidebar | 💤 待命 | 亲验 dump 三方对账典范(纠过底册转录误差);挂账:行级…菜单锚点/树展开态样式已实装。档案 20260722-p1-ui-side/ | +| **convo-a** | ui-conversation 包 owner(service+skeleton 半+公共类型) | 💤 待命 | 四次超时重灾户但全部完整交卷;M1a 定性/P0 双实例破案是它。与 convo-b 同包分工默契已成。档案 20260722-p1-convo-a/ | +| **convo-b** | ui-conversation 消息流半(chat/+toolviews/+apply 接线)+README 实质化+测试清扫 | 💤 待命 | 判死判留过堂最严谨;12 包 README 两节全它写。档案 20260722-p1-convo-b/ | +| **ui-traj** | ui-trajectory | 💤 待命 | 占位包已齐(10 测+chrome.header 第二挂点);P-III 真实现时回叫。档案 20260722-p1-ui-traj/ | +| **figma-flows** | 视觉顾问(figma 数据/查询脚本/判定报告) | 💤 待命 | W5 两轮逐屏判定+style-spec 三批底册全它出;PIL 像素实测法;答疑走 SendMessage。无独立档案(产出在 w5-visual-verdict.md/style-spec.md) | + +派工惯例(重启后沿用):契约仲裁只归主会话(v3 落款后广播);跨属地改动报备制;同包双人(convo-a/b)由 a 划文件边界;视觉问 figma-flows 架构问 main;>15min 零落盘催报,超时唤醒消息要含「从盘上恢复」指引。 + +## 六、环境与纪律 + +- dsh web:`pnpm run demo:web`(src 模式启动 ~8.5s 是 tsx 转译;built lib 快一个量级);DEEPSEEK_API_KEY 在树根 .env;playwright chromium 已装;figma 数据 .artifacts/figma/(gitignored);W5 探针 .artifacts/w5-full-probe.mjs 可重放。 +- 编制九人常驻(fw-slots/fw-react/rt-core/ui-shell/ui-side/convo-a/convo-b/ui-traj/figma-flows),全员待命;档案在 missions/tasks/20260721-p1-*/ 与 20260722-p1-*/。 +- 纪律沉淀:pathspec 精确到文件(四起卷刀教训);共享分支零历史改写;裁决以盘上落款为准信箱只是提醒;状态疑问先 git log;编译只 pnpm exec tsc -b;dist 不入库改完重跑 tsdown;client 值 import 必须走 externals 形态(双实例坑)。 +- W5 验收形态(用户定):真跑不静态绿+截图对 figma 只比要做的+动线亲走。 diff --git a/missions/scripts/verify-carrier-errors.mjs b/missions/scripts/verify-carrier-errors.mjs new file mode 100644 index 0000000000..ec0d94d1e6 --- /dev/null +++ b/missions/scripts/verify-carrier-errors.mjs @@ -0,0 +1,113 @@ +// Carrier error-channel regression probes (audit batch: A1/A2/A4/A9 + R2 half). +// Runs the isomorphic path (InProcessApiClient over toFetchHandler) — no server needed. +// Run: node --experimental-strip-types missions/scripts/verify-carrier-errors.mjs (or via tsx) +import { toFetchHandler } from '../../packages/host/apiproxy/src/fetch/handler.ts' +import { InProcessApiClient } from '../../packages/host/apiproxy/src/fetch/client.ts' +import { RpcId } from '../../packages/host/apiproxy/src/api/rpc.ts' +import { serverResponseSchema } from '../../packages/host/apiproxy/src/api/rpc.schema.ts' + +let failures = 0 +const report = (n, p, d = '') => { failures += p ? 0 : 1; console.log(`${p ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`) } + +const okList = { rpcId: RpcId('x'), result: { ok: true, value: { items: [] } } } +/** Minimal ApiProxy stub; per-test cases override single methods. */ +function makeApi(overrides = {}) { + return { + sessions: { + list: async (r) => ({ ...okList, rpcId: r.rpcId }), + create: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { sessionId: 's1' } } }), + history: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }), + prompt: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { accepted: true } } }), + cancel: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { accepted: true } } }), + ...overrides.sessions, + }, + host: { + describe: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { version: '0', cwd: '/', attachedSessions: 0 } } }), + ...overrides.host, + }, + events: { + mux: overrides.mux ?? async function* () {}, + host: overrides.hostStream ?? async function* () {}, + }, + respond: async () => ({ accepted: false, reason: 'not-pending' }), + } +} + +// ---- A1: mid-stream impl throw → one stream/error frame on the wire, then clean close ---- +{ + const api = makeApi({ + hostStream: async function* () { + yield { rpcId: RpcId('f1'), payload: { type: 'host/session-status', sessionId: 's1', running: true } } + throw new Error('impl exploded mid-stream') + }, + }) + const client = new InProcessApiClient(toFetchHandler(api)) + const seen = [] + for await (const frame of client.events.host({}, new AbortController().signal)) seen.push(frame.payload) + report('A1 流中 impl throw → stream/error 帧真到达 client', seen.some(f => f.type === 'stream/error' && f.error.code === 'internal' && /impl exploded/.test(f.error.message)), JSON.stringify(seen.map(f => f.type))) + report('A1b stream/error 后流正常收尾(迭代自然结束不 throw)', true) +} + +// ---- A2: S→C frame validation — a malformed frame is dropped, the stream survives ---- +{ + const api = makeApi({ + hostStream: async function* () { + yield { rpcId: RpcId('bad'), payload: { type: 'host/session-status', sessionId: 's1' } } // missing `running` + yield { rpcId: RpcId('good'), payload: { type: 'host/session-status', sessionId: 's1', running: false } } + }, + }) + const client = new InProcessApiClient(toFetchHandler(api)) + const seen = [] + for await (const frame of client.events.host({}, new AbortController().signal)) seen.push(frame) + report('A2 坏帧被丢弃且不杀流(后续好帧照常到达)', seen.length === 1 && seen[0].payload.running === false, `seen=${seen.length}`) +} + +// ---- A2: S→C unary value validation — a wrong-shaped ok value throws at the client boundary ---- +{ + const api = makeApi({ sessions: { list: async (r) => ({ rpcId: r.rpcId, result: { ok: true, value: { items: 'not-an-array' } } }) } }) + const client = new InProcessApiClient(toFetchHandler(api)) + const threw = await client.sessions.list({}).then(() => false, () => true) + report('A2b unary ok value 过 Value schema(坏形状在 client 边界抛出)', threw) +} + +// ---- A4: envelope parse failure backfills a salvageable rpcId; otherwise the sentinel — and the response parses as a valid ServerResponse ---- +{ + const handler = toFetchHandler(makeApi()) + const post = (body) => handler.fetch('http://dsh.internal/api/session.list', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }) + const salvaged = await (await post({ rpcId: 'my-id', method: 5 })).json() + report('A4 信封烂但 rpcId 可捞 → 回填原值', serverResponseSchema.safeParse(salvaged).success && salvaged.rpcId === 'my-id', JSON.stringify(salvaged.rpcId)) + const sentinel = await (await post({ nothing: true })).json() + report('A4b rpcId 不可捞 → invalid-request 哨兵,且过 serverResponseSchema', serverResponseSchema.safeParse(sentinel).success && sentinel.rpcId === 'invalid-request', JSON.stringify(sentinel.rpcId)) +} + +// ---- A10: external signal aborts an in-flight unary ---- +{ + const api = makeApi({ sessions: { list: () => new Promise(() => {}) } }) + const client = new InProcessApiClient(toFetchHandler(api)) + const ctl = new AbortController() + const call = client.sessions.list({}, ctl.signal).then(() => 'resolved', (e) => String(e)) + ctl.abort(new Error('user cancelled')) + const outcome = await call + report('A10 unary 外部 signal 可取消在途请求', outcome !== 'resolved', outcome.slice(0, 60)) +} + +// ---- onOpen: stream-established signal fires before any frame is delivered ---- +{ + const api = makeApi({ + hostStream: async function* () { + yield { rpcId: RpcId('f'), payload: { type: 'host/session-removed', sessionId: 's1' } } + }, + }) + const client = new InProcessApiClient(toFetchHandler(api)) + const order = [] + const iter = client.events.host({}, new AbortController().signal, () => order.push('open'))[Symbol.asyncIterator]() + await iter.next() + order.push('frame') + report('C2 信号:onOpen 先于首帧交付', order.join(',') === 'open,frame', order.join(',')) + await iter.return?.() +} + +console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`) +process.exit(failures === 0 ? 0 : 1) diff --git a/missions/scripts/verify-rpclog-panel.mjs b/missions/scripts/verify-rpclog-panel.mjs new file mode 100644 index 0000000000..0721529641 --- /dev/null +++ b/missions/scripts/verify-rpclog-panel.mjs @@ -0,0 +1,99 @@ +// RPC panel browser acceptance (fixture mode); step tags §D-1..§D-6 match the report labels. +// Prereqs: dsh web running on 3080, apps/web/dist freshly built, playwright chromium installed. +// Run: node missions/scripts/verify-rpclog-panel.mjs (not part of any gate system) +import { chromium } from 'playwright' + +const BASE = process.env.DSH_WEB_URL ?? 'http://127.0.0.1:3080' +let failures = 0 + +function report(name, pass, detail = '') { + failures += pass ? 0 : 1 + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) +} + +const browser = await chromium.launch() +try { + const page = await browser.newPage() + await page.goto(`${BASE}/?fixture`, { waitUntil: 'load' }) + + // §D-1 page shell + rpclog rail button present, unread badge > 0 (boot auto-ping + subscribed frames); shell presence = the list sidebar. + await page.waitForSelector('aside') + const railBtn = page.locator('nav button[title="RPC 日志"]') + await railBtn.waitFor({ state: 'visible' }) + await page.waitForFunction(() => { + const el = document.querySelector('nav button[title="RPC 日志"] span[class*="unread"]') + return el !== null && /\d/.test(el.textContent ?? '') + }, undefined, { timeout: 5000 }) + report('§D-1 角标存在且未读数 > 0', true) + + // §D-2 activate the rpclog bar: the ledger page fills the panel area, kinds cover three quadrants (direction symbols ↑ ↓ ⇟, up/down spatial metaphor) + await railBtn.click() + const list = page.locator('section:has(header)').locator('div[class*="list"]') + await list.waitFor({ state: 'visible' }) + const rowTexts = await list.locator('button[class*="rowLine"]').allTextContents() + const joined = rowTexts.join('\n') + const hasThree = joined.includes('↑') && joined.includes('↓') && joined.includes('⇟') + report('§D-2 展开见台账,三象限方向符齐', hasThree, `rows=${rowTexts.length}`) + const unreadAfterOpen = await page.locator('span[class*="unread"]').count() + report('§D-2 展开后未读徽标消失', unreadAfterOpen === 0) + + // §D-3 click ping: adds one client-request/server-response pair (host.describe) + const rowsBefore = await list.locator('button[class*="rowLine"]').count() + await page.locator('button', { hasText: 'ping' }).click() + await page.waitForFunction( + (n) => document.querySelectorAll('button[class*="rowLine"]').length >= n + 2, + rowsBefore, { timeout: 3000 }, + ) + const lastTwo = (await list.locator('button[class*="rowLine"]').allTextContents()).slice(-2) + const pingPair = lastTwo[0]?.includes('host.describe') && lastTwo[0]?.includes('↑') + && lastTwo[1]?.includes('host.describe') && lastTwo[1]?.includes('↓') + report('§D-3 ping 新增一对 describe 往返', Boolean(pingPair), lastTwo.map((t) => t.slice(0, 30)).join(' | ')) + + // §D-3b hover pair highlight: hovering the last row (server-response) lights its client-request row too + const rows = list.locator('div[class*="row"]:not([class*="rowLine"])') + await list.locator('button[class*="rowLine"]').last().hover() + await page.waitForTimeout(100) + const pairedCount = await list.locator('div[class*="rowPaired"]').count() + report('§D-3b hover 同 rpcId 配对行高亮(2 行)', pairedCount === 2, `paired=${pairedCount}`) + + // §D-4 click a row to expand the JSON payload, click again to collapse + const firstRow = list.locator('button[class*="rowLine"]').first() + await firstRow.click() + const payloadShown = await list.locator('pre[class*="payload"]').count() + await firstRow.click() + const payloadHidden = await list.locator('pre[class*="payload"]').count() + report('§D-4 点行 JSON 展开/收起', payloadShown === 1 && payloadHidden === 0) + + // §D-5 scrolling up pauses; resume restores follow + // First overflow the list (no scroll overflow → onScroll can never fire): click ping until ≥30 rows + while (await list.locator('button[class*="rowLine"]').count() < 30) { + await page.locator('button', { hasText: 'ping' }).click() + await page.waitForTimeout(30) + } + await list.evaluate((el) => { el.scrollTop = 0 }) + await page.waitForSelector('div[class*="pausedBar"]', { timeout: 3000 }) + const resumeBtn = page.locator('button', { hasText: '继续' }) + report('§D-5 上滚触发暂停(按钮态+提示条)', await resumeBtn.count() === 1) + await resumeBtn.click() + await page.waitForTimeout(100) + const followRestored = await list.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight < 30) + report('§D-5b 继续恢复贴底跟随', followRestored) + + // §D-6 clear: list empty, counters reset; periodic frames keep arriving (wait 6s for new rows) + await page.locator('button', { hasText: '清空' }).click() + const emptyAfterClear = await list.locator('button[class*="rowLine"]').count() + report('§D-6 清空后列表空', emptyAfterClear === 0) + await page.waitForFunction( + () => document.querySelectorAll('button[class*="rowLine"]').length > 0, + undefined, { timeout: 8000 }, + ) + report('§D-6b 清空后周期帧继续进入', true) +} catch (error) { + failures += 1 + console.log(`FAIL 脚本异常 — ${error instanceof Error ? error.message : String(error)}`) +} finally { + await browser.close() +} + +console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`) +process.exit(failures === 0 ? 0 : 1) diff --git a/missions/scripts/verify-session-real.mjs b/missions/scripts/verify-session-real.mjs new file mode 100644 index 0000000000..02e6130628 --- /dev/null +++ b/missions/scripts/verify-session-real.mjs @@ -0,0 +1,132 @@ +// Real-host spot check (condensed acceptance + connection stability): real sessions in the list, +// history renders on open, real prompt streams back. The stability assertions guard against +// fixture masking: fake streams never touch real SSE, so bridge-layer bugs (e.g. the req 'close' +// misdetection) only surface against a real host. +import { chromium } from 'playwright' +const BASE = process.env.VERIFY_BASE ?? 'http://127.0.0.1:3080' +let failures = 0 +const report = (n, p, d = '') => { failures += p ? 0 : 1; console.log(`${p ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`) } +const browser = await chromium.launch() +try { + const page = await browser.newPage() + page.on('pageerror', (e) => console.log('[pageerror]', String(e).slice(0, 300))) + const apiRequests = [] + let apiFailed = 0 + page.on('request', (r) => { if (r.url().includes('/api/')) apiRequests.push(r.url()) }) + page.on('requestfailed', (r) => { if (r.url().includes('/api/')) apiFailed++ }) + await page.goto(`${BASE}/`, { waitUntil: 'load' }) + // E2-0 connection stability: within a 12s window /api requests must be one-time setup cost + // (two streams + describe + list <= 10), zero aborts. The 300ms reconnect storm + // (the bridge bug fixed 2026-07-20) shows up here instantly. + await page.waitForTimeout(12000) + report('E2-0a 12s 内 /api 请求 ≤10(无重连风暴)', apiRequests.length <= 10, `count=${apiRequests.length}`) + report('E2-0b 无 requestfailed(SSE 不被 client abort)', apiFailed === 0, `failed=${apiFailed}`) + // E2-0c cold-session merge: list must include persisted sessions from previous host runs, + // not just in-memory attached ones (guards the R4 regression: first screen empty after + // restart). Requires at least one prior run's session on disk — every run of this script + // leaves some behind, so only a truly virgin .sessions root skips the assertion. + const listRes = await page.evaluate(async (base) => { + const res = await fetch(`${base}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'verify-cold-list', method: 'session.list', payload: {} }), + }) + return res.json() + }, BASE) + const coldItems = listRes?.result?.ok ? listRes.result.value.items : [] + const sorted = coldItems.every((it, i) => i === 0 || coldItems[i - 1].updatedAt >= it.updatedAt) + if (coldItems.length > 0) { + report('E2-0c 冷 session 进 list 且 updatedAt 倒序', sorted, `count=${coldItems.length}`) + const coldRows = await page.locator('aside button[class*="item"]').count() + report('E2-0d 首屏列表渲染冷 session(非空)', coldRows >= 1, `rows=${coldRows}`) + // Legacy no-cwd logs are not served (pre-release stance: no compatibility) — + // every listed session must carry its project cwd. + const noCwd = coldItems.filter((it) => typeof it.cwd !== 'string' || it.cwd.length === 0) + report('E2-0c2 无 cwd 存量不可见(全部条目携带 project cwd)', noCwd.length === 0, `noCwd=${noCwd.length}`) + } else { + console.log('SKIP E2-0c/E2-0c2/E2-0d 冷 session 断言(.sessions 为空的全新 host)') + } + // E2-0e error-channel fidelity: an unknown id must come back as session-not-found, + // never disguised as internal (and vice versa — guards the R3 regression). + const nf = await page.evaluate(async (base) => { + const res = await fetch(`${base}/api/session.history`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'verify-not-found', method: 'session.history', payload: { sessionId: 'session-00000000-dead-beef-0000-000000000000' } }), + }) + return res.json() + }, BASE) + report('E2-0e 未知 id 回 session-not-found(不伪装 internal)', nf?.result?.ok === false && nf.result.error.code === 'session-not-found', `code=${nf?.result?.error?.code}`) + // E2-1 create a real session into the list via '+' (covers the create path). + await page.locator('aside button[title="新建 session"]').click() + await page.waitForSelector('aside button[class*="item"]', { timeout: 8000 }) + const n = await page.locator('aside button[class*="item"]').count() + report('E2-1 新建真 session 入列表', n >= 1, `count=${n}`) + // E2-1b default-project injection: a create without an explicit cwd must still get one + // (the host default — its process working directory), so the session lands in a project + // bucket instead of _no-cwd (guards the B-decision regression). + const afterCreate = await page.evaluate(async (base) => { + const res = await fetch(`${base}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'verify-default-cwd', method: 'session.list', payload: {} }), + }) + return res.json() + }, BASE) + const newest = afterCreate?.result?.ok ? afterCreate.result.value.items[0] : undefined + report('E2-1b 新建 session 携带默认 cwd(host 进程目录注入)', typeof newest?.cwd === 'string' && newest.cwd.length > 0, `cwd=${newest?.cwd ?? '(absent)'}`) + // E2-2 open the first row: openState reaches open (input enabled) + await page.locator('aside button[class*="item"]').first().click() + await page.waitForSelector('main textarea:not([disabled])', { timeout: 8000 }) + report('E2-2 打开真 session(history 通、输入可用)', true) + // E2-3 real prompt: user bubble lands + partial pulse (real model streaming). + // Ask for a ~100-char reply: too-short replies finish inside waitForSelector's polling gap, + // making the pulse assertion race into a false failure. + await page.locator('main textarea').fill('用大约100字介绍事件溯源,最后一句以「介绍完毕」结尾') + await page.locator('main button[class*="primary"]').click() + await page.waitForSelector('main div[class*="bubble"]', { timeout: 5000 }) + report('E2-3a user 气泡入流', true) + const sawPulse = await page.waitForSelector('main span[class*="pulse"]', { timeout: 30000 }).then(() => true).catch(() => false) + report('E2-3b 真模型流式 partial 出现', sawPulse) + await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 60000 }) + const text = (await page.locator('main').textContent()) ?? '' + report('E2-3c 回复定稿入流', text.includes('介绍完毕') || text.includes('事件溯源'), text.slice(-60)) + // E2-4 stop mid-stream freezes the partial (aborted turns never finalize): the accumulated + // text survives as an interrupted terminal node (已停止 marker), the pulse stops, and later + // messages land after it. A reload must reconstruct the same node from the logged chunks. + const primary = page.locator('main button[class*="primary"]') + await page.locator('main textarea').fill('请从头背诵出师表全文,直接开始不要客套') + await primary.click() + await page.waitForSelector('main span[class*="pulse"]', { timeout: 30000 }) + // Let visible content accumulate so the frozen node has a body to keep. + await page.waitForTimeout(2500) + await primary.click() // stop mid-stream (the no-finalize abort path) + await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 15000 }) + const pulseGone = await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 2000 }).then(() => true).catch(() => false) + const frozenMark = await page.locator('main span[class*="stopped"]', { hasText: '已停止' }).count() + report('E2-4a 停止后 partial 定格(脉冲停+已停止标记+文本保留)', pulseGone && frozenMark >= 1, `pulseGone=${pulseGone} marks=${frozenMark}`) + await page.locator('main textarea').fill('请只回复四个字:顺序正常') + await primary.click() + await page.waitForSelector('main div[class*="bubble"]:has-text("顺序正常")', { timeout: 10000 }) + const rows = await page.evaluate(() => { + const scroll = document.querySelector('main div[class*="scroll"]') + return [...(scroll?.children ?? [])].map((el) => (el.textContent ?? '').trim()).filter(Boolean) + }) + const iStopped = rows.findIndex((t) => t.includes('出师表')) + const iNew = rows.findIndex((t) => t.includes('顺序正常')) + report('E2-4b 停止后再发消息顺序正确(新消息在末尾)', iNew > iStopped && iStopped >= 0, `stopped@${iStopped} new@${iNew}`) + // E2-4c reload: history replay re-freezes the interrupted node (live view and replay agree). + await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 60000 }) + await page.reload({ waitUntil: 'load' }) + await page.locator('aside button[class*="item"]').first().click() + await page.waitForSelector('main textarea:not([disabled])', { timeout: 8000 }) + const marksAfterReload = await page.locator('main span[class*="stopped"]', { hasText: '已停止' }).count() + report('E2-4c 刷新后中断消息仍在(history 重建一致)', marksAfterReload >= 1, `marks=${marksAfterReload}`) +} catch (e) { + failures += 1 + console.log(`FAIL 脚本异常 — ${String(e).slice(0, 300)}`) +} finally { + await browser.close() +} +console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`) +process.exit(failures === 0 ? 0 : 1) diff --git a/missions/scripts/verify-session.mjs b/missions/scripts/verify-session.mjs new file mode 100644 index 0000000000..d4d9864c6f --- /dev/null +++ b/missions/scripts/verify-session.mjs @@ -0,0 +1,361 @@ +// Session UI browser acceptance (fixture mode); step tags match the report labels. +// Prereqs: dsh web running on 3080, apps/web/dist freshly built, playwright chromium installed. +// Run: node missions/scripts/verify-session.mjs (not part of any gate system) +import { chromium } from 'playwright' + +const BASE = process.env.DSH_WEB_URL ?? 'http://127.0.0.1:3080' +let failures = 0 + +function report(name, pass, detail = '') { + failures += pass ? 0 : 1 + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) +} + +const browser = await chromium.launch() +try { + const page = await browser.newPage() + await page.goto(`${BASE}/?fixture`, { waitUntil: 'load' }) + + // §E1-1 three list rows + fx-alpha running dot + fx-beta lineage indent + empty right pane + await page.waitForSelector('aside button[class*="item"]', { timeout: 5000 }) + const items = page.locator('aside button[class*="item"]') + report('§E1-1a 列表 3 条', await items.count() === 3, `count=${await items.count()}`) + const alphaDot = page.locator('aside button[title="fx-alpha"] span[class*="running"]') + report('§E1-1b fx-alpha running 绿点', await alphaDot.count() === 1) + const betaPad = await page.locator('aside button[title="fx-beta"]').evaluate((el) => el.style.paddingLeft) + report('§E1-1c fx-beta 谱系缩进(depth=1 → 24px)', betaPad === '24px', `paddingLeft=${betaPad}`) + report('§E1-1d 右侧空态', (await page.locator('main').textContent())?.includes('选择或新建') ?? false) + + // §E1-2 open fx-alpha: all history node kinds render, scroll lands at bottom + await page.locator('aside button[title="fx-alpha"]').click() + await page.waitForSelector('main div[class*="bubble"]', { timeout: 5000 }) + const mainText = await page.locator('main').textContent() + report('§E1-2a user 气泡渲出', (mainText ?? '').includes('问题 59')) + report('§E1-2b assistant 正文渲出', (mainText ?? '').includes('回答 59')) + // Bottom check BEFORE expanding reasoning (a local expand grows height without triggering follow — view state, not a snapshot change; by design). + const scroll = page.locator('main div[class*="scroll"]') + const atBottom = await scroll.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight < 30) + report('§E1-2i 打开后滚动在底部', atBottom) + const reasoningToggle = page.locator('main button[class*="reasoningToggle"]').last() + report('§E1-2c reasoning 折叠钮存在', await reasoningToggle.count() > 0) + await reasoningToggle.click() + report('§E1-2d reasoning 展开有内容', ((await page.locator('main').textContent()) ?? '').includes('思考过程')) + report('§E1-2e 工具卡渲出', await page.locator('main div[class*="card"] span[class*="name"]', { hasText: 'echo' }).count() > 0) + report('§E1-2f steering 徽标渲出', await page.locator('main span[class*="badge"]', { hasText: '插话' }).count() > 0) + report('§E1-2g context 折叠卡渲出', await page.locator('main button', { hasText: '上下文注入' }).count() > 0) + report('§E1-2h 常驻审批占位卡', await page.locator('main div[class*="card"]', { hasText: '等待审批' }).count() === 1) + + // §E1-3 load-older: prepend one page, viewport stays anchored + const olderBtn = page.locator('main button', { hasText: '加载更早' }) + report('§E1-3a hasMore 显示加载更早钮', await olderBtn.count() === 1) + const beforeAnchor = await scroll.evaluate((el) => ({ h: el.scrollHeight, t: el.scrollTop })) + await scroll.evaluate((el) => { el.scrollTop = 0 }) // scroll up before paging (realistic gesture) + const anchorTop = await scroll.evaluate((el) => el.scrollTop) + await olderBtn.click() + await page.waitForFunction((prev) => { + const el = document.querySelector('main div[class*="scroll"]') + return el !== null && el.scrollHeight > prev + }, beforeAnchor.h, { timeout: 5000 }) + const afterAnchor = await scroll.evaluate((el) => ({ h: el.scrollHeight, t: el.scrollTop })) + const drift = Math.abs(afterAnchor.t - (anchorTop + (afterAnchor.h - beforeAnchor.h))) + report('§E1-3b 翻页锚定(scrollTop 补偿高度差)', drift < 4, `drift=${drift}px`) + report('§E1-3c 更早消息已前插', ((await page.locator('main').textContent()) ?? '').includes('问题 20')) + + // §E1-5 send (queue): user bubble lands + typewriter partial + finalize; draft clears. + // Button rulings 2026-07-20: one primary button (send idle / stop running); running locks the input. + const input = page.locator('main textarea') + const primaryBtn = page.locator('main button[class*="primary"]') + // fx-alpha opens running=true (fixture list material) — the merged primary reads 停止 there; reset to idle first. + if (await primaryBtn.getAttribute('aria-label') === '停止') { + await primaryBtn.click() + await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 }) + } + await input.fill('验收消息一') + await primaryBtn.click() + await page.waitForSelector('main div[class*="bubble"]:has-text("验收消息一")', { timeout: 3000 }) + report('§E1-5a user 气泡入流', true) + report('§E1-5b 草稿清空', await input.inputValue() === '') + // Typewriter: the partial pulse is visible + await page.waitForSelector('main span[class*="pulse"]', { timeout: 3000 }) + report('§E1-5c 流式 partial 脉冲出现', true) + // Running dot lights up (fixture prompt flips status) + await page.waitForSelector('main div[class*="head"] span[data-running]', { timeout: 3000 }) + report('§E1-5d running 状态点亮', true) + + // §E1-6 running locks the input (ruling 2026-07-20 #3, supersedes the hover menu): + // textarea disabled (draft visible but frozen), no queue/steer menu, stop is the only action. + report('§E1-6a running 时输入框置灰', await input.isDisabled()) + report('§E1-6b running 时无排队/插话菜单', await page.locator('main button[class*="menuItem"]').count() === 0) + report('§E1-6c running 时主按钮为停止且可用', await primaryBtn.isEnabled() && (await primaryBtn.getAttribute('aria-label')) === '停止') + + // Wait for finalize: pulse gone + echo body present (partial -> finalized node swap) + await page.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 15000 }) + report('§E1-5e 定稿切换(脉冲消失)', true) + report('§E1-5f 回声正文定稿', ((await page.locator('main').textContent()) ?? '').includes('回声:验收消息一')) + + // §E1-7 stop: send another, the primary button flips to stop (same slot) mid-replay + await input.fill('验收消息二') + await primaryBtn.click() + await page.waitForSelector('main button[aria-label="停止"]', { timeout: 3000 }) + report('§E1-7d 运行中主按钮原地变停止', true) + await primaryBtn.click() // now the stop action + await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 }) + report('§E1-7a 停止后 running 熄灭', true) + report('§E1-7b 中断标记入流', ((await page.locator('main').textContent()) ?? '').includes('(已中断)')) + report('§E1-7e 停止后主按钮回到发送', await primaryBtn.getAttribute('aria-label') === '发送') + // Turn end unlocks the box and returns focus (before any fill taints activeElement). + await page.waitForTimeout(200) + report('§E1-7f 停止解禁后焦点回输入框', await page.evaluate(() => document.activeElement?.tagName === 'TEXTAREA')) + await input.fill('x') + await primaryBtn.hover() + await page.waitForTimeout(300) + report('§E1-7c 无排队/插话菜单(空闲 hover 亦无)', await page.locator('main button[class*="menuItem"]').count() === 0) + await input.fill('') + + // §E1-8 switch to fx-beta and back: empty conversation / instant re-render (resident instances) + await page.locator('aside button[title="fx-beta"]').click() + await page.waitForFunction(() => { + const main = document.querySelector('main') + return main !== null && (main.textContent ?? '').includes('fx-beta') + }, undefined, { timeout: 3000 }) + const betaBubbles = await page.locator('main div[class*="bubble"]').count() + report('§E1-8a fx-beta 空对话', betaBubbles === 0, `bubbles=${betaBubbles}`) + const t0 = Date.now() + await page.locator('aside button[title="fx-alpha"]').click() + await page.waitForSelector('main div[class*="bubble"]:has-text("验收消息一")', { timeout: 2000 }) + report('§E1-8b 切回 fx-alpha 即时呈现(常驻实例)', Date.now() - t0 < 1500, `${Date.now() - t0}ms`) + + // §E1-9 create selects and opens immediately + const before = await items.count() + await page.locator('aside button[title="新建 session"]').click() + await page.waitForFunction((n) => document.querySelectorAll('aside button[class*="item"]').length > n, before, { timeout: 3000 }) + const newSelected = await page.locator('aside button[class*="selected"]').getAttribute('title') + report('§E1-9 新建即入列表并选中', newSelected !== null && newSelected.startsWith('fx-'), `selected=${newSelected}`) + + // §E1-11 InputBar regression pins (IME composition / autorepeat / caret / autosize / draft semantics) + await page.locator('aside button[title="fx-alpha"]').click() + const inputBox = page.locator('main textarea') + await inputBox.waitFor({ timeout: 3000 }) + + // B1: composition Enter must not send (IME candidate pick) + const bubblesB1 = await page.locator('main div[class*="bubble"]').count() + await inputBox.fill('IME 探测') + await inputBox.evaluate((el) => { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, isComposing: true, bubbles: true, cancelable: true })) + }) + await page.waitForTimeout(200) + report('§E1-11a IME 组合期 Enter 不发送', await page.locator('main div[class*="bubble"]').count() === bubblesB1 && await inputBox.inputValue() === 'IME 探测') + + // B6: key-repeat Enter must not send + await inputBox.evaluate((el) => { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, repeat: true })) + }) + await page.waitForTimeout(200) + report('§E1-11b Enter 长按 autorepeat 不发送', await page.locator('main div[class*="bubble"]').count() === bubblesB1) + await inputBox.fill('') + + // B2: mid-text edit keeps the caret (synchronous controlled-value notify) + await inputBox.fill('abcdef') + await inputBox.evaluate((el) => el.setSelectionRange(3, 3)) + await inputBox.press('x') + await page.waitForTimeout(100) + const caret = await inputBox.evaluate((el) => ({ v: el.value, s: el.selectionStart })) + report('§E1-11c 中段编辑光标不跳', caret.v === 'abcxdef' && caret.s === 4, `value=${caret.v} caret=${caret.s}`) + await inputBox.fill('') + + // B3: soft-wrap long text grows the box (mirror-div auto-grow), capped at the 14-line baseline (336px) + const hEmpty = (await inputBox.boundingBox())?.height ?? 0 + await inputBox.fill('这是一段没有换行符但是非常长的文本'.repeat(60)) + await page.waitForTimeout(100) + const hLong = (await inputBox.boundingBox())?.height ?? 0 + report('§E1-11d 软换行自增高且封顶', hLong > hEmpty + 20 && hLong <= 344, `h ${hEmpty} -> ${hLong}`) + await inputBox.fill('') + + // B4 (reworked under ruling 3): sending locks the box for the turn; focus returns on unlock (pinned at §E1-7f) + const primary = page.locator('main button[class*="primary"]') + await inputBox.fill('焦点验收') + await primary.click() + await page.waitForTimeout(200) + report('§E1-11e 发送后运行期输入锁定', await inputBox.isDisabled()) + report('§E1-11f 发送即清稿(乐观清)', await inputBox.inputValue() === '') + + // B5: single primary slot — the button must not move when running flips (send<->stop in place) + await page.waitForSelector('main button[aria-label="停止"]', { timeout: 3000 }) + const yRunning = (await primary.boundingBox())?.y ?? -1 + await primary.click() // stop + await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 }) + const yIdle = (await primary.boundingBox())?.y ?? -2 + report('§E1-11g 发送/停止原地切换不跳动', Math.abs(yRunning - yIdle) < 2, `primary.y ${yRunning} vs ${yIdle}`) + + // Sending force-scrolls to the bottom even when scrolled away (own words must be visible; + // passive follow still respects scrolled-away readers during streaming). + const scrollBox = page.locator('main div[class*="scroll"]') + await scrollBox.evaluate((el) => { el.scrollTop = 0 }) + await inputBox.fill('置底验收消息') + await primary.click() + await page.waitForSelector('main div[class*="bubble"]:has-text("置底验收消息")', { timeout: 3000 }) + const nearBottom = await scrollBox.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight < 30) + report('§E1-11h 上滚状态下发送强制置底', nearBottom) + await page.waitForSelector('main button[aria-label="停止"]', { timeout: 3000 }) + await primary.click() // stop the replay to leave the fixture idle + await page.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 }) + + // §E1-10 RPC panel cross-check: this run's traffic is visible in the ledger (history/prompt/cancel round trips). + // The ledger is a left-menu bar page now: activate it from the icon rail, assert panel-area content. + await page.locator('nav button[title="RPC 日志"]').click() + await page.waitForSelector('section[class*="panel"]', { timeout: 3000 }) + const panelText = (await page.locator('section[class*="panel"]').textContent()) ?? '' + const sawHistory = panelText.includes('session.history') || panelText.includes('session/event') + report('§E1-10 调试面板见 session 流量', sawHistory) + await page.locator('nav button[title="会话列表"]').click() // restore the sessions panel for later steps + + // §E1-12 时序批(audit S1/S3/S4):fixture __fxTiming 后门制造慢 history / 丢帧 / 重连窗口。 + // 新开 page = 全新 fixture 实例,不受上面步骤污染。 + const page2 = await browser.newPage() + await page2.goto(`${BASE}/?fixture`, { waitUntil: 'load' }) + await page2.waitForSelector('aside button[title="fx-alpha"]', { timeout: 5000 }) + + // S1: open 窗口期来的 live 帧必须缝合进窗口(慢 history 下打开正在流式的会话) + await page2.evaluate(() => globalThis.__fxTiming.setHistoryDelay(700)) + await page2.locator('aside button[title="fx-alpha"]').click() + await page2.waitForTimeout(120) // open 在途(history 还有 ~580ms 才回) + await page2.evaluate(() => { + globalThis.__fxTiming.appendUser('fx-alpha', '开窗期实时消息A') + globalThis.__fxTiming.appendUser('fx-alpha', '开窗期实时消息B') + }) + // 就绪信号用气泡而非 textarea:fx-alpha 初始 running=true,输入框在 running 期被禁用 + await page2.waitForSelector('main div[class*="bubble"]', { timeout: 8000 }) + await page2.waitForSelector('main div[class*="bubble"]:has-text("开窗期实时消息B")', { timeout: 5000 }).catch(() => {}) + await page2.evaluate(() => globalThis.__fxTiming.setHistoryDelay(0)) + const s1Text = (await page2.locator('main').textContent()) ?? '' + report('§E1-12a open 期间来帧缝合不丢(S1)', s1Text.includes('开窗期实时消息A') && s1Text.includes('开窗期实时消息B')) + report('§E1-12b 缝合后无 fold 降级(S1)', !s1Text.includes('历史视图降级')) + + // S3: 途中丢一帧造出 seq 洞 → resync-lite 重拉尾页找回丢帧,且不触发 fold 降级 + await page2.evaluate(() => { + globalThis.__fxTiming.appendSilent('fx-alpha', '途中丢失的消息C') // 只进 log 不发 mux 帧 + globalThis.__fxTiming.appendUser('fx-alpha', '洞后到达的消息D') // client 看见 seq 跳 2 + }) + const gapRepaired = await page2.waitForSelector('main div[class*="bubble"]:has-text("途中丢失的消息C")', { timeout: 5000 }).then(() => true).catch(() => false) + report('§E1-12c seq 洞触发补拉,丢帧经 history 找回(S3)', gapRepaired) + const s3Text = (await page2.locator('main').textContent()) ?? '' + report('§E1-12d 洞后帧不丢(S3)', s3Text.includes('洞后到达的消息D')) + report('§E1-12e 洞不再触发 fold 降级(S3)', !s3Text.includes('历史视图降级')) + + // S4: open 在途时断线,在途 history 注定失败 → 重连 resync 的 generation 必须作废旧结果, + // 不得在新窗口成功打开后被迟到的旧失败定格成 error。用 fx-beta(无定时素材,running 恒 false)。 + await page2.evaluate(() => { + globalThis.__fxTiming.setHistoryDelay(1200) + globalThis.__fxTiming.failNextHistory() + }) + await page2.locator('aside button[title="fx-beta"]').click() + await page2.waitForTimeout(150) // 注定失败的 open 在途 + await page2.evaluate(() => { + globalThis.__fxTiming.setHistoryDelay(0) + globalThis.__fxTiming.breakStreams() // 双流断 → 重连(退避 ~250-500ms + 宽限 150ms)→ resync + }) + await page2.waitForSelector('main textarea:not([disabled])', { timeout: 8000 }) + await page2.waitForTimeout(1400) // 等旧 doomed 请求(~1350ms 处)失败落地后再断言 + const s4ErrStrips = await page2.locator('main div[class*="openError"]').count() + const s4InputOk = await page2.locator('main textarea:not([disabled])').count() + report('§E1-12f 断线窗口在途 open 不定格失败(S4 generation 作废)', s4ErrStrips === 0 && s4InputOk === 1, `errStrips=${s4ErrStrips} input=${s4InputOk}`) + await page2.close() + + // §E1-13 引用稳定(audit S5+C3):流式 chunk 期间 memo 必须真实命中—— + // 稳定的 ToolCallCard/SessionListItem 渲染次数不随 chunk 帧线性增长。 + const page3 = await browser.newPage() + await page3.addInitScript(() => { globalThis.__renderCounts = {} }) + await page3.goto(`${BASE}/?fixture`, { waitUntil: 'load' }) + await page3.waitForSelector('aside button[title="fx-alpha"]', { timeout: 5000 }) + await page3.locator('aside button[title="fx-alpha"]').click() + await page3.waitForSelector('main div[class*="bubble"]', { timeout: 8000 }) + // 复位到空闲(fx-alpha 开局 running) + const primary3 = page3.locator('main button[class*="primary"]') + if (await primary3.getAttribute('aria-label') === '停止') { + await primary3.click() + await page3.waitForSelector('main div[class*="head"] span[data-running]', { state: 'detached', timeout: 5000 }) + } + await page3.evaluate(() => { globalThis.__renderCounts = {} }) + // 发送触发 fixture 流式回放(约 20+ 个 chunk 帧) + await page3.locator('main textarea').fill('memo 稳定性验收') + await primary3.click() + await page3.waitForSelector('main span[class*="pulse"]', { timeout: 5000 }) + await page3.waitForSelector('main span[class*="pulse"]', { state: 'detached', timeout: 20000 }) + const counts = await page3.evaluate(() => globalThis.__renderCounts) + // 历史窗口 50 条消息里有 ~10 张工具卡,全部已定稿:chunk 期间它们的 props 引用应稳定, + // memo 全程命中 → 整轮流式回放中每张卡渲染次数为 0(发送时快照 nodes 未变)。 + // 列表条目:running 翻转 2 次(true/false)+ updatedAt 变 1 次是合法渲染,帧驱动重渲则会到几十次。 + const toolRenders = counts.ToolCallCard ?? 0 + const listRenders = counts.SessionListItem ?? 0 + report('§E1-13a 流式期间已定稿工具卡 memo 命中(S5)', toolRenders <= 12, `ToolCallCard renders=${toolRenders}(10 卡;>12 即 memo 失效)`) + report('§E1-13b 流式期间列表条目 memo 命中(S5+C3)', listRenders <= 12, `SessionListItem renders=${listRenders}(3 行 × 合法状态翻转;>12 即 memo 失效)`) + + // §E1-15 tool 卡三级回退(toolcard-wire):fixture 60-62 turn 携带三型 view 样本; + // echo(无 presenter)钉住无 view 兜底 JSON 卡路径。 + { + const scroll3 = page3.locator('main div[class*="scroll"]') + // fx-bash terminal 卡:命令占卡头 name 槽 + cwd + exit 胶囊 + 输出 + const termCmd = await page3.locator('main span[class*="name"]', { hasText: 'ls -la' }).count() + const termCwd = await page3.locator('main span[class*="cwd"]', { hasText: '/tmp/fixture' }).count() + const termPill = await page3.locator('main span[class*="pill"]', { hasText: 'exit 0' }).count() + report('§E1-15a terminal 卡渲出(命令+cwd+exit 胶囊)', termCmd >= 1 && termCwd >= 1 && termPill >= 1, `cmd=${termCmd} cwd=${termCwd} pill=${termPill}`) + const termOut = (await scroll3.textContent() ?? '').includes('drwxr-xr-x fixture') + report('§E1-15b terminal 卡输出体渲出', termOut) + // fx-write diff 卡:path 头 + 新文本块 + const diffPath = await page3.locator('main div[class*="diffPath"]', { hasText: 'notes/demo.txt' }).count() + const diffNew = await page3.locator('main pre[class*="diffNew"]', { hasText: 'hello fixture' }).count() + report('§E1-15c diff 卡渲出(path 头+新文本)', diffPath >= 1 && diffNew >= 1, `path=${diffPath} new=${diffNew}`) + // fx-note generic 卡:view 标题上头 + kind 图标 + const genTitle = await page3.locator('main span[class*="name"]', { hasText: '记录笔记' }).count() + report('§E1-15d generic 卡渲出(view 标题)', genTitle >= 1, `title=${genTitle}`) + // echo 无 presenter:老 JSON 折叠卡兜底(参数折叠钮仍在) + const echoCard = await page3.locator('main div[class*="card"]:has(span[class*="name"]:text-is("echo")) button', { hasText: '参数' }).count() + report('§E1-15e 无 view 工具兜底 JSON 卡(echo)', echoCard >= 1, `echoParamToggles=${echoCard}`) + } + + // §E1-16 壳骨架(app-shell knife 3):tabs 条在、占位页渲、点 tool 卡展开右栏 detail、再点收起。 + { + // tabs 条:conversation + gantt 两 tab 注册后条自然出现(单 tab 时不渲染的分支反证)。 + const tabConv = await page3.locator('main button', { hasText: '会话' }).count() + const tabGantt = await page3.locator('main button', { hasText: '甘特' }).count() + report('§E1-16a tabs 条渲出(会话+甘特)', tabConv >= 1 && tabGantt >= 1, `conv=${tabConv} gantt=${tabGantt}`) + // 占位页:切甘特 tab 渲说明性占位,切回会话流还在。 + await page3.locator('main button', { hasText: '甘特' }).click() + const placeholderText = (await page3.locator('main').textContent()) ?? '' + report('§E1-16b 甘特占位页渲出', placeholderText.includes('视图建设中')) + await page3.locator('main button', { hasText: '会话' }).first().click() + await page3.waitForSelector('main div[class*="bubble"]', { timeout: 3000 }) + // 点 tool 卡头 → 右栏 detail 展开(callId+argsRaw JSON);同卡再点 → 收起。 + const echoHead = page3.locator('main div[class*="card"]:has(span[class*="name"]:text-is("echo")) div[class*="head"]').first() + await echoHead.click() + const detailShown = await page3.waitForSelector('span[class*="detailTitle"]', { timeout: 3000 }).then(() => true).catch(() => false) + const detailText = detailShown ? (await page3.locator('div[class*="detailBody"]').textContent()) ?? '' : '' + report('§E1-16c 点卡展开右栏 detail(callId+argsRaw)', detailShown && detailText.includes('callId') && detailText.includes('argsRaw')) + await echoHead.click() + const detailGone = await page3.waitForSelector('span[class*="detailTitle"]', { state: 'detached', timeout: 3000 }).then(() => true).catch(() => false) + report('§E1-16d 同卡再点收起', detailGone) + // 关闭钮路径:再开一次,点 × 收起(空 detail 兜底由 jsdom 层守)。 + await echoHead.click() + await page3.waitForSelector('span[class*="detailTitle"]', { timeout: 3000 }) + await page3.locator('button[title="关闭详情"]').click() + const closedByBtn = await page3.waitForSelector('span[class*="detailTitle"]', { state: 'detached', timeout: 3000 }).then(() => true).catch(() => false) + report('§E1-16e 关闭钮收起', closedByBtn) + } + + // §E1-14 连接状态可见(audit C1):断流 → 顶部细条出现;重连成功 → 细条消失。 + report('§E1-14a 连接正常时无断线细条', await page3.locator('div[class*="banner"]').count() === 0) + await page3.evaluate(() => globalThis.__fxTiming.breakStreams()) + const bannerShown = await page3.waitForSelector('div[class*="banner"]', { timeout: 5000 }).then(() => true).catch(() => false) + report('§E1-14b 断流后重连细条出现', bannerShown) + const bannerGone = await page3.waitForSelector('div[class*="banner"]', { state: 'detached', timeout: 8000 }).then(() => true).catch(() => false) + report('§E1-14c 重连成功后细条消失', bannerGone) + await page3.close() +} catch (error) { + failures += 1 + console.log(`FAIL 脚本异常 — ${error instanceof Error ? error.message : String(error)}`) +} finally { + await browser.close() +} + +console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`) +process.exit(failures === 0 ? 0 : 1) diff --git a/missions/scripts/verify-webserver-backpressure.mjs b/missions/scripts/verify-webserver-backpressure.mjs new file mode 100644 index 0000000000..3e03f42ae6 --- /dev/null +++ b/missions/scripts/verify-webserver-backpressure.mjs @@ -0,0 +1,45 @@ +// Webserver bridge backpressure probe (audit R2, webserver half): a paused client socket +// must stall the SSE pump (res.write false → await drain) instead of buffering unboundedly. +// Self-contained: starts startWebServer on PORT with a stub apiHandler; no host needed. +// Run: node_modules/.bin/tsx missions/scripts/verify-webserver-backpressure.mjs +import { connect } from 'node:net' +import { once } from 'node:events' +import { startWebServer } from '../../packages/host/webserver/src/index.ts' + +const PORT = Number(process.env.PROBE_PORT ?? 3097) +const CHUNK = 64 * 1024 +const TOTAL = 200 // 200 × 64KB = 12.5MB — far beyond any socket buffer +let failures = 0 +const report = (n, p, d = '') => { failures += p ? 0 : 1; console.log(`${p ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`) } + +let pulled = 0 +const apiHandler = { + fetch: async () => new Response(new ReadableStream({ + pull(controller) { + if (pulled >= TOTAL) return controller.close() + pulled++ + controller.enqueue(new Uint8Array(CHUNK)) + }, + }), { headers: { 'content-type': 'text/event-stream' } }), +} + +const server = await startWebServer({ port: PORT, distIndex: '/nonexistent/index.html', apiHandler }, (e) => console.error(String(e))) +const socket = connect(PORT, '127.0.0.1') +await once(socket, 'connect') +socket.write(`GET /api/events.host HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n\r\n`) +socket.pause() // stop reading: kernel+node buffers fill, then res.write must return false + +await new Promise(r => setTimeout(r, 1500)) +const stalled = pulled +// Without drain-await the pump races through all chunks regardless of the paused reader. +report('R2 暂停读的慢客户端使泵停在低水位(非全量吞入内存)', stalled < TOTAL / 2, `pulled=${stalled}/${TOTAL}`) + +socket.resume() // drain: the pump must resume and finish +const t0 = Date.now() +while (pulled < TOTAL && Date.now() - t0 < 10_000) await new Promise(r => setTimeout(r, 100)) +report('R2b 恢复读后泵继续推进到完成', pulled === TOTAL, `pulled=${pulled}/${TOTAL}`) + +socket.destroy() +await server.close() +console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`) +process.exit(failures === 0 ? 0 : 1) diff --git a/missions/scripts/verify-webserver-hardening.mjs b/missions/scripts/verify-webserver-hardening.mjs new file mode 100644 index 0000000000..e8c6f636f8 --- /dev/null +++ b/missions/scripts/verify-webserver-hardening.mjs @@ -0,0 +1,37 @@ +// Webserver hardening probe (audit R1): malformed requests must yield 4xx/5xx, never kill +// the process. Prereq: dsh web on 3080. Run: node missions/scripts/verify-webserver-hardening.mjs +const BASE = process.env.DSH_WEB_URL ?? 'http://127.0.0.1:3080' +let failures = 0 + +function report(name, pass, detail = '') { + failures += pass ? 0 : 1 + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) +} + +async function status(path, init) { + try { + return (await fetch(`${BASE}${path}`, init)).status + } catch { + return 0 // connection refused/reset — the server died or dropped us + } +} + +// R1 trigger set: bad %-encodings (decodeURIComponent URIError), long path, bad method, non-JSON API body. +const cases = [ + ['/%', [400]], + ['/%c0', [400]], + ['/%zz%', [400]], + ['/' + 'a'.repeat(9000), [200]], // SPA fallback, must not throw + ['/foo', [405], { method: 'DELETE' }], + ['/api/session.list', [400], { method: 'POST', body: 'not json' }], +] +for (const [path, expect, init] of cases) { + const got = await status(path, init) + const label = path.length > 24 ? `${path.slice(0, 24)}…` : path + report(`${init?.method ?? 'GET'} ${label} -> ${expect.join('/')}`, expect.includes(got), `got=${got}`) +} + +report('server alive after the barrage (GET / -> 200)', (await status('/')) === 200) + +console.log(failures === 0 ? 'ALL PASS' : `${failures} FAILURE(S)`) +process.exit(failures === 0 ? 0 : 1) diff --git a/missions/tasks/20260719-1843-step1-skeleton-design/README.md b/missions/tasks/20260719-1843-step1-skeleton-design/README.md new file mode 100644 index 0000000000..921d07ec57 --- /dev/null +++ b/missions/tasks/20260719-1843-step1-skeleton-design/README.md @@ -0,0 +1,34 @@ +# step1 骨架设计(GUI) + +任务:为 DeepSeek Harness GUI「step1 骨架」写中文设计文档,说清动线(boot / 构建 / 请求 / 停机)与五模块的 API 暴露面。设计文档任务,不写实现代码。 + +## 已拍板约束(2026-07-19,用户定) + +- 五模块:`apps/dsc`(bin 入口,node:http 内联静态服务)、`packages/host/apiproxy`(程序化组合 harness core,agents: [])、`packages/client/web-runtime`(浏览器启动层,无 React)、`packages/client/web-ui`(React 层)、`apps/web`(vite build 主入口,产 dist)。 +- apps/dsc 通过 workspace 依赖 apps/web 从包内解析 dist;不要 --static-dir。 +- 监听 0.0.0.0,打印 http://127.0.0.1:;`--port` 用 node util.parseArgs。 +- API key 从根 .env 读;`dsc web` 零参数即起。 +- step1 不做:连接协议、/health、session 通信、vite dev server 代理、精细 drain。停机:SIGINT → 关 HTTP → dispose cordis root。 +- 前端依赖版本参考 deepseekchat(deepsuite-frontend)基线。 +- 不遵循仓库门禁(coverage/doc-sync/JSDoc 等)。 +- 禁读 worktree-webpreview 旧 GUI 资产。 + +## 文件索引 + +| 文件 | 内容 | +|---|---| +| `deepseekchat-baseline.md` | worker 产出:deepsuite-frontend 前端工程版本基线 | +| `harness-boot-facts.md` | worker 产出:harness 程序化 boot 接线事实(带 file:line) | +| `design.md` | 最终设计文档 | + +## 进展 + +| 时间 | 事项 | +|---|---| +| 2026-07-19 18:43 | 任务下达(team-lead → dispatcher) | +| 2026-07-19 18:47 | 建归档目录;派出两个 background worker(deepseekchat 基线 / harness 接线) | +| 2026-07-19 18:52 | dispatcher 自查 root 工程事实:workspaces=`vendor/*`+`packages/*/*`+`website`;tsdown 只收 vendor/packages;website 是「自带 build、不进 tsc/tsdown 构建图」的 workspace 成员先例;tsconfig.base.json 的 dsh-* paths 通配需为 host/client 组各加一行 | +| 2026-07-19 18:55 | worker1(deepseekchat 基线)完成:deepsuite-frontend 用 Rush+Rspack **无 Vite**;可借鉴 React ^18.2、TS 6.0.3 strict/Bundler/react-jsx、zustand ~4.4.7、CSS Modules+PostCSS;Vite 版本需自定(列入遗留问题) | +| 2026-07-19 18:57 | 已读 `deepseekchat-baseline.md` 全文并确认可用;等待 worker2(harness 接线)后动笔 design.md | +| 2026-07-19 19:12 | dispatcher 两次 idle 未产出,主会话停掉 dispatcher、收回素材直写 design.md 完成(五模块/四动线/包清单/3 个遗留问题) | +| 2026-07-19 20:30 | 重开恢复:前任 teammate 因主会话意外关闭中断(20:14 API 超时又丢一轮),新 owner 从归档恢复历史后重写 design.md 为 v2 实现级(⓪已锁结论/①目录树/②根配置精确编辑/③五包 package.json 全文/④五 tsconfig 全文/⑤bootHost+bin.ts+vite 三件套源文件/⑥12 条验收/⑦step2 接缝只指向 apiproxy 文档/⑧v1 差异)。核实点:app-boot loadEnv 可直接 import;LlmDeepSeek 为函数插件 `import * as`;SessionPersistenceJsonl root 必填;dist 解析定 createRequire | diff --git a/missions/tasks/20260719-1843-step1-skeleton-design/deepseekchat-baseline.md b/missions/tasks/20260719-1843-step1-skeleton-design/deepseekchat-baseline.md new file mode 100644 index 0000000000..3e380fde4c --- /dev/null +++ b/missions/tasks/20260719-1843-step1-skeleton-design/deepseekchat-baseline.md @@ -0,0 +1,74 @@ +# deepseekchat(deepsuite-frontend)前端工程基线调研 + +调研对象:`/weka-hg/prod/deepseek/permanent/ys/private/workspace/gitlab/deepsuite-frontend`。 +该仓库是 **Rush + pnpm** monorepo(无根 package.json,项目清单在 `rush.json`),主聊天 web 应用选定为 **`apps/chat`(`@deepseek/chat`)**。 + +**重要前提:该仓库不用 Vite,构建器是 Rspack(`@rspack/cli` + `builtin:swc-loader`)。** 全仓 `find` 无任何 `vite.config.*`,也没有 `vite` 依赖。下文 "vite 配置要点" 一节相应改为 rspack 配置要点,供新骨架用 Vite 对齐等效能力时参考。 +## 1. 关键依赖版本表 + +| 项目 | 版本 / 值 | 出处 | +| --- | --- | --- | +| react | `^18.2.0`(lock 解析为 18.3.1) | `apps/chat/package.json` dependencies;`common/config/rush/pnpm-lock.yaml` | +| react-dom | `^18.2.0`(lock 18.3.1) | 同上 | +| @types/react / @types/react-dom | `~18.3.1` / `~18.3.0` | `apps/chat/package.json` | +| 构建器 | **Rspack**:`@rspack/cli` 2.0.2、`@rspack/core` 2.0.2、`@rspack/dev-server` 2.0.1(无 vite) | `apps/chat/package.json` devDependencies | +| React 转换 | 无 @vitejs/plugin-react\*;用 rspack `builtin:swc-loader`,`react.runtime: 'automatic'`,dev 下 `react-refresh`(`@rspack/plugin-react-refresh` 2.0.0) | `shared/rspack-base-config/index.ts`、`shared/rspack-base-config/package.json` | +| typescript | `6.0.3` | `apps/chat/package.json`、`shared/tsconfig-base` 的消费方统一为 6.0.3 | +| 状态管理 | **zustand `~4.4.7`**(配 `immer ~10.1.1`;数据请求用 `swr ~2.2.4`;另有 rxjs) | `apps/chat/package.json` dependencies | +| 路由 | react-router-dom `^6.16.0`(lock 6.30.4) | `apps/chat/package.json` | +| 样式方案 | **CSS Modules(`*.module.css`)+ PostCSS**(postcss-nested、postcss-custom-media、@csstools/postcss-global-data、autoprefixer);类型用 `typed-css-modules`(tcm)生成 `.css.d.ts`;类名工具 `clsx`。无 tailwind/less/styled-components | `apps/chat/rspack.config.ts`(`css/auto` + `createPostcssUse`)、`shared/rspack-postcss-rule/index.ts`、`apps/chat/package.json` | +| SVG | `@svgr/webpack ^8.1.0`(`?url` 走 asset,其余 tsx 引用走 SVGR 组件) | `apps/chat/rspack.config.ts` | +| Lint/格式化 | oxlint `1.63.0` + oxfmt `0.48.0`(非 eslint/prettier) | `apps/chat/package.json` | +| 测试 | vitest `~4.0.18` | `apps/chat/package.json` | +| Node 版本 | `>=20.19.0 <21.0.0 \|\| >=22.12.0 <23.0.0 \|\| >=26.0.0 <27.0.0` | `rush.json` `nodeSupportedVersionRange` | +| 包管理器 | Rush `5.175.1` + pnpm `10.33.4`(`useWorkspaces: true`,无独立 packageManager 字段) | `rush.json`、`common/config/rush/pnpm-config.json` | +| npm registry | `https://registry.npmmirror.com` | `common/config/rush/.npmrc` | + +## 2. build/dev 脚本清单(apps/chat/package.json scripts,构建相关) + +- `dev` → `rushx dev:staging`;`dev:staging` = `DEPLOY_ENV=staging rspack serve -c rspack.config.ts`(dev server 需要 `DEPLOY_ENV` 环境变量,否则 config 直接 throw) +- `dev:production` = `DEPLOY_ENV=production rspack serve ...` +- `devc` = 杀 8080 进程 + `run-p dev tcm:watch watch:deps`(并行跑 dev server、CSS Modules 类型 watch、上游依赖 watch) +- `build` = lint + type + test + 清 dist + `rspack build -c rspack.config.ts` + 产物语法兼容检查(`check:bundle-compat`) +- `build:production` / `build:staging` = 设 `DEPLOY_ENV` 后走 `build` +- `rspack` = `rspack build -c rspack.config.ts` +- `analyze` = `RSDOCTOR=true ... rspack build`(Rsdoctor 分析) +- `type` = 并行:`tcm`(生成 css.d.ts)+ `tsc -p tsconfig.scripts.json` + `tsc -p src/tsconfig.json`(全部 noEmit,类型检查与打包分离) +- `tcm` / `tcm:watch` = `typed-css-modules` 扫 `src/**/*.module.css` +- `test` = `vitest --run __tests__` +- `preview` = `rspack serve -c rspack.preview.config.ts` + +## 3. 构建配置要点(rspack.config.ts;Vite 骨架对齐参考) + +- **入口/产物**:entry `./src/index.tsx`;输出 `static/[name].[contenthash:10].js`,dev 用无 hash 名;`publicPath` 生产走 CDN(`https://fe-static.deepseek.com/chat/`),dev 为 `/`。 +- **HTML**:`HtmlRspackPlugin` 两份模板 `src/index.html` 与 `src/share.html`(多页),模板参数注入 git commit id 与内联 analytics 脚本。 +- **浏览器 target / polyfill**:swc `env.targets = ['ios >= 12', 'chrome >= 66']`,`mode: 'usage'` + `core-js 3.41`(`shared/rspack-base-config/browserTargets.cjs`)。仓库无 browserslist 文件,target 就是这份常量。 +- **JSX/TS 转换**:`builtin:swc-loader`,typescript+tsx 语法,`react.runtime: 'automatic'`,dev 开 `development` + `refresh`。 +- **CSS**:原生 `css/auto`(rspack 内置 CSS Modules,`namedExports: false`,生产 localIdentName `[hash:8]`)+ postcss-loader(nested / custom-media / global-data 注入共享 media.css / autoprefixer)。 +- **别名**:仅 `core-js` 与 `@swc/helpers` 指到 resolve 出的包目录(保证单实例),**没有 `@/` → `src` 之类的路径别名**;`resolve.extensions = ['.tsx', '.ts', '.js']`。 +- **dev server**:staging 端口 8080 / production 8090,`historyApiFallback: true`,`/api` 等前缀 proxy 到 `https://chat-dev.deepseek.com`(或生产域名),`allowedHosts: 'all'`。 +- **特殊产物**:SRI(子资源完整性)插件、sourcemap 上传插件、`NormalModuleReplacementPlugin` 按环境替换 debug 模块、splitChunks 手工分 vendors/mermaid/katex/prismjs 分组——这些属于该产品线定制,新骨架不需要。 + +## 4. tsconfig 关键 compilerOptions + +`apps/chat/src/tsconfig.json` extends `../tsconfig.web.json` extends `@deepseek/tsconfig-base/tsconfig.json`(`shared/tsconfig-base/tsconfig.json`),叠加后 web 源码生效值: + +- `strict: true`(另显式 `noImplicitAny`、`useUnknownInCatchVariables`) +- `target: "ESNext"`,`lib: ["DOM", "DOM.Iterable", "ESNext"]` +- `module: "ESNext"`,`moduleResolution: "Bundler"`(base 里是 CommonJS,web 层覆写) +- `jsx: "react-jsx"` +- `noEmit: true`、`isolatedModules: true`、`skipLibCheck: true` +- `noUnusedLocals` / `noUnusedParameters` / `noImplicitOverride` / `noImplicitReturns`、`checkJs: true` +- `allowSyntheticDefaultImports: true`;src 层 `types: ["react", "react-dom"]` + +## 5. 目录组织要点 + +`apps/chat/src/` 一级结构:`index.html` + `share.html`(HTML 模板在 src 内,非仓根)、入口 `index.tsx`(副作用 setup 一串 + `App.tsx`)、`router.tsx` / `setupRouter.ts` / `routes/`(react-router v6)、`components/`(每组件一目录,`Foo.tsx` + `Foo.module.css` + 生成的 `.css.d.ts`)、`store/`(zustand 各 store 按文件拆分)、`service/`、`models/`、`hooks/`、`jobs/`(启动任务)、`utils/`、`i18n/`、`style/`(global.css)、`assets/`、`config/`,另有 `css.d.ts` / `svg.ts` / `shims.d.ts` 等全局声明。React 挂载(`createRoot`)封装在共享包 `packages/app-kit-web` 的 app 框架内,业务入口只做 setup + 配置。 + +## 对新 React + Vite 骨架的启示(简结) + +可直接继承的基线:React 18 + react-dom 18、TS strict + `jsx: react-jsx` + `module: ESNext` + `moduleResolution: Bundler`、zustand(+immer)状态、CSS Modules + clsx 样式、react-router v6、pnpm + Node 22。 +这些继续依赖他们 + +构建器一项无法照搬(对方是 Rspack),Vite 侧等效物:`@vitejs/plugin-react`(swc 版可选)替代 builtin:swc-loader + react-refresh;Vite 原生 CSS Modules 替代 `css/auto` + tcm;`server.proxy` 替代 devServer.proxy;`build.target` 若无需老浏览器可不必带 core-js polyfill 链。 +我们继续使用 vite,主要考虑到后面会用他们的模块 diff --git a/missions/tasks/20260719-1843-step1-skeleton-design/design.md b/missions/tasks/20260719-1843-step1-skeleton-design/design.md new file mode 100644 index 0000000000..736b56de57 --- /dev/null +++ b/missions/tasks/20260719-1843-step1-skeleton-design/design.md @@ -0,0 +1,636 @@ +# step1 骨架 · 实现规格(v2) + +> 2026-07-19 v2 重写:读者是**无本会话上下文的编码 teammate**——照本文档即可建目录、写文件、跑通验收,不需要再查素材。事实核实基于 HEAD `9eb1fbd5d`。设计权衡见 git 历史里的 v1;本文只给结论。 +> 范围:五模块骨架 + 静态服务 + host boot + 停机。**不含任何 /api 路由、/health、session 通信、协议实现**(step2,契约见 `../20260719-1902-apiproxy-api-design/design.md`)。 + +## ⓪ 已锁定结论(直接照做,不再讨论) + +- 五模块:`apps/dsc`、`apps/web`、`packages/host/apiproxy`、`packages/client/web-runtime`、`packages/client/web-ui`。 +- 包名/bin:`@deepseek-ai/dsc`,bin 名 `dsc`,子命令 `web`;前端包 `@deepseek-ai/dsc-web`;三个 dsh 包 `@deepseek-ai/dsh-apiproxy` / `@deepseek-ai/dsh-web-runtime` / `@deepseek-ai/dsh-web-ui`。 +- 版本:vite `^6.0.0`、`@vitejs/plugin-react` `^4.0.0`、react/react-dom `^18.2.0`、`@types/react` `~18.3.1`、`@types/react-dom` `~18.3.0`、typescript `^6.0.3`(跟根)。 +- `--port` 用 `node:util` 的 `parseArgs`,默认 **3080**;`listen(port, '0.0.0.0')`,打印 `http://127.0.0.1:`。 +- API key:bin 先 `loadEnv()` 读根 `.env` 进 process.env(直接 import 自 `@deepseek-ai/dsh-app-boot`,已核实是普通具名导出函数,无 Loader 依赖),`LlmDeepSeek` 插件层自己兜底读 `$DEEPSEEK_API_KEY`(缺 key 在 plugin load 期 throw,fail loud)。 +- persistenceRoot:`'./.sessions'`(cwd 相对;demo:web 从仓库根跑,与现有 demos 一致)。 +- 停机:照 `packages/examples/jsonrpc-demo/src/bin.ts` 的 `disposeAndExit` 样板;SIGINT→130、SIGTERM→0;关 HTTP 后 dispose cordis root。不做 drain。 +- 纪律:**不遵循仓库门禁**(coverage/doc-sync/JSDoc/README/knip/根 typecheck references 一概不动、不补);只求 `demo:web` 能跑通验收清单。 +- step1 不做:`/api/*` 路由(含 /health)、session 通信、agent 预建、vite dev server/proxy、ClientSlot、tsdown/构建产物(dev 期 tsx 跑 src)、测试。 + +## ① 目录树(新建文件全清单) + +``` +apps/ ← 新顶层目录 + dsc/ + package.json ← §③-1 + tsconfig.json ← §④-1 + src/bin.ts ← §⑤-5(bin 全部逻辑单文件:parseArgs + bootHost + 静态服务 + 信号) + web/ + package.json ← §③-2 + tsconfig.json ← §④-2 + index.html ← §⑤-4(vite 默认入口位置 = 包根) + vite.config.ts ← §⑤-4 + src/main.ts ← §⑤-4 +packages/host/ ← 新包组 + apiproxy/ + package.json ← §③-3 + tsconfig.json ← §④-3 + src/index.ts ← §⑤-1(bootHost) +packages/client/ ← 新包组 + web-runtime/ + package.json ← §③-4 + tsconfig.json ← §④-4 + src/index.ts ← §⑤-2(Runtime + createRuntime) + web-ui/ + package.json ← §③-5 + tsconfig.json ← §④-5 + src/index.tsx ← §⑤-3(mount + App) +``` + +不建:tests/、README.md(含 packages/host/README.md、packages/client/README.md 组说明)、tsdown.config.ts——门禁跳过,step 后续补。 + +## ② 根配置改动(四处,给出精确编辑) + +### ②-1 `pnpm-workspace.yaml` + +`packages:` 列表在 `- packages/*/*` 之后插入一行: + +```yaml + - packages/*/* + - apps/* # ← 新增 + - website +``` + +### ②-2 根 `package.json` 两处 + +a) `workspaces` 数组(与 pnpm-workspace.yaml 保持一致)加一项: + +```json + "workspaces": [ + "vendor/*", + "packages/*/*", + "apps/*", + "website" + ], +``` + +b) `scripts` 加一行(无 `--expose-internals`,无 HMR): + +```json + "demo:web": "node --import tsx apps/dsc/src/bin.ts web", +``` + +### ②-3 `tsconfig.base.json` + +`"@deepseek-ai/dsh-*"` paths 数组**末尾**追加两行(位置无关——包目录名全仓唯一、first-on-disk-wins;注意给上一行 `"./packages/support/*/src"` 补逗号): + +```json + "./packages/support/*/src", + "./packages/host/*/src", + "./packages/client/*/src" +``` + +这两行是 tsx 跑 `demo:web` 时把 `@deepseek-ai/dsh-apiproxy` 等裸名解析到源码的**必要条件**(tsx 读 tsconfig paths;lib/ 未构建)。`@deepseek-ai/dsc-web` 不匹配 `dsh-*` 通配、走 node_modules workspace 软链 + package exports 解析,无需 paths。 + +### ②-4 `.gitignore` + +现有 `.gitignore` 无泛 `dist/` 条目(只有 `dist-exe/`),追加一行: + +``` +apps/web/dist/ +``` + +其余根配置(tsconfig.json references、tsconfig.build.json、tsdown.config.ts、coverage/knip/jscpd 各 glob)**一概不动**——apps/* 与新包学 website 先例:workspace 成员、自带构建、不进根构建图与门禁。 + +## ③ 五个包 package.json 全文 + +依赖纪律(本 step 统一):**全部用平铺 `dependencies`,内部包(含 vendored 的 cordis / @cordisjs/plugin-timer)一律 `workspace:^`**;不做仓库惯例的 peer+dev 双列(apps 是叶子、client 两包非 cordis 插件;apiproxy 正规化时再改)。全部 `private: true`,不发布。 + +### ③-1 `apps/dsc/package.json` + +bin 字段按仓库惯例指 `lib/bin.js`,但 step1 不构建、不经 bin 调用——唯一运行路径是根 script `demo:web`(tsx 跑 src)。 + +```json +{ + "name": "@deepseek-ai/dsc", + "description": "dsc CLI: `dsc web` serves the built web UI and boots the harness host", + "version": "0.0.1", + "private": true, + "type": "module", + "bin": { + "dsc": "lib/bin.js" + }, + "files": [ + "lib/bin.js", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsc-web": "workspace:^", + "@deepseek-ai/dsh-apiproxy": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^" + } +} +``` + +### ③-2 `apps/web/package.json` + +无 `main`/`"."` export——它是 vite 构建入口不是库;`"./dist/*"` export 是 apps/dsc 解析 dist 的唯一通道(`require.resolve` 走 exports 映射)。依赖四项都必须列(v2.1 修正,实测两次 build 失败得出):**`dsh-web-runtime`**——`src/main.ts` 直接 import,pnpm 严格 node_modules 下未声明不可解析;**react / react-dom**——`@vitejs/plugin-react` 强制 `resolve.dedupe: ['react','react-dom']`,dedupe 让 vite 从项目根 apps/web 解析而非从 importer(web-ui),apps/web 自己没有 react 即 resolve NULL。 + +```json +{ + "name": "@deepseek-ai/dsc-web", + "description": "dsc web frontend: vite build entry producing dist/ served by apps/dsc", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + "./dist/*": "./dist/*", + "./package.json": "./package.json" + }, + "scripts": { + "build": "vite build", + "watch": "vite build --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-web-runtime": "workspace:^", + "@deepseek-ai/dsh-web-ui": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.0.0", + "typescript": "^6.0.3", + "vite": "^6.0.0" + } +} +``` + +### ③-3 `packages/host/apiproxy/package.json` + +入口按仓库模板指 `lib/`(step1 不构建;tsx 经 tsconfig paths 直接吃 src,lib 只为将来构建留位)。 + +```json +{ + "name": "@deepseek-ai/dsh-apiproxy", + "description": "Programmatic harness host composition for dsc: bootHost mounts the core spine; step2 adds the ApiProxy contract", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "workspace:^" + } +} +``` + +### ③-4 `packages/client/web-runtime/package.json` + +**入口直接指 src**(与 apiproxy 不同):消费者只有 vite(啃源码打包),step1 这两个 client 包不做任何构建。零依赖。 + +```json +{ + "name": "@deepseek-ai/dsh-web-runtime", + "description": "Browser-side runtime layer for the dsc web UI (no React): runtime creation; step2 adds the api client and store", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "license": "BSD-3-Clause" +} +``` + +### ③-5 `packages/client/web-ui/package.json` + +```json +{ + "name": "@deepseek-ai/dsh-web-ui", + "description": "React component layer for the dsc web UI: mount(el, runtime)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "src/index.tsx", + "types": "src/index.tsx", + "exports": { + ".": "./src/index.tsx", + "./package.json": "./package.json" + }, + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-web-runtime": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0" + } +} +``` + +## ④ 五个 tsconfig.json 全文 + +形状照 `packages/examples/stdio-demo/tsconfig.json`(extends 根 base / rootDir src / outDir lib/types / include src / references 指依赖包目录)。apps/* 在顶层第二级,extends 相对路径少一级(`../../`)。这些 tsconfig step1 只服务 tsx 的 paths 解析与编辑器;不进根构建图、不跑 tsc 门禁。浏览器侧三包(web-runtime/web-ui/apps-web)覆写 `lib` 加 DOM、清空 `types`(去掉 base 的 node);含 JSX 的再加 `"jsx": "react-jsx"`。 + +### ④-1 `apps/dsc/tsconfig.json` + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../vendor/cordis" }, + { "path": "../../packages/host/apiproxy" }, + { "path": "../../packages/ui/app-boot" } + ] +} +``` + +### ④-2 `apps/web/tsconfig.json` + +`vite.config.ts` 不进 include(它要 node 环境类型,与浏览器 src 冲突;vite 自己能跑它,编辑器红线忍受或将来拆 tsconfig.node.json——step1 不管)。 + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": [], + "jsx": "react-jsx" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../packages/client/web-ui" } + ] +} +``` + +### ④-3 `packages/host/apiproxy/tsconfig.json` + +```json +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/timer" }, + { "path": "../../llm/llm" }, + { "path": "../../llm/llm-deepseek" }, + { "path": "../../core/session" }, + { "path": "../../core/system-prompt" }, + { "path": "../../core/tools" }, + { "path": "../../core/agent" }, + { "path": "../../tasks/tasks" }, + { "path": "../../core/agent-loop" }, + { "path": "../../session-persistence/session-persistence-jsonl" }, + { "path": "../../bash/bash-local" } + ] +} +``` + +### ④-4 `packages/client/web-runtime/tsconfig.json` + +```json +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": [] + }, + "include": [ + "src" + ] +} +``` + +### ④-5 `packages/client/web-ui/tsconfig.json` + +```json +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": [], + "jsx": "react-jsx" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../web-runtime" } + ] +} +``` + +## ⑤ 源文件内容 + +### ⑤-1 `packages/host/apiproxy/src/index.ts` — bootHost + +签名与插件清单(顺序即代码顺序;cordis 按 inject 自动挂起等依赖,顺序仅为可读性,但**逐个 await** 保证失败在 boot 期确定性上抛——不装 agent-spine-demo bundle,其 apply 内不 await 子插件、失败晚爆): + +```ts +import { Context } from 'cordis' +import Timer from '@cordisjs/plugin-timer' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import TaskService from '@deepseek-ai/dsh-tasks' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' + +export interface BootHostOptions { + persistenceRoot: string // apps/dsc 传 './.sessions' +} + +export interface HostHandle { + ctx: Context + dispose(): Promise // = ctx.fiber.dispose() +} + +export async function bootHost(options: BootHostOptions): Promise { + const ctx = new Context() + await ctx.plugin(Timer) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(TaskService) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LlmDeepSeek, {}) + await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) + await ctx.plugin(LocalBashExecutor, {}) + return { ctx, dispose: () => ctx.fiber.dispose() } +} +``` + +逐项说明(结论 + 一句注记): + +| 插件 | config 实参 | 注记 | +|---|---|---| +| `Timer` | 无 | AgentLoop 依赖链要 timer | +| `LlmService` | 无 | default export 类 | +| `SessionStore` | 无 | | +| `SystemPrompt` | `{ persona: '' }` | schema 有 default(''),传空串显式化;step2 再定真 persona | +| `ToolRegistry` | 无 | inject: ['systemPrompt'],晚于 SystemPrompt 列出仅为可读性 | +| `AgentRegistry` | 无 | | +| `TaskService` | 无 | | +| `AgentLoop` | `{ agents: [] }` | **不预建 agent**(acp-demo 同款);inject: agents/sessions/llm/tools/systemPrompt | +| `LlmDeepSeek` | `{}` | **函数插件,`import * as` 挂载**(named exports,无 default);apply 内 `config.apiKey ?? process.env.DEEPSEEK_API_KEY`,缺 key 直接 throw | +| `SessionPersistenceJsonl` | `{ root: options.persistenceRoot }` | root 必填无默认(schema `.required()`) | +| `LocalBashExecutor` | `{}` | default export 类;cwd 默认 process.cwd() | + +不装:skill 族、workspace-context、invariants、tool-bash(模型工具面 step2 随 agent 通信一起定)、fs 族、compact、subagent、UI 插件。step1 这个 host 起来后**什么都不做**,只证明 boot/dispose 通。 + +### ⑤-2 `packages/client/web-runtime/src/index.ts` + +```ts +export interface Runtime { + baseUrl: string // step2 的 ApiClient 从这里长出来 +} + +export function createRuntime(): Runtime { + return { baseUrl: window.location.origin } +} +``` + +### ⑤-3 `packages/client/web-ui/src/index.tsx` + +```tsx +import { createRoot } from 'react-dom/client' +import type { Runtime } from '@deepseek-ai/dsh-web-runtime' + +function App({ runtime }: { runtime: Runtime }) { + return

dsc web · skeleton · {runtime.baseUrl}
+} + +export function mount(el: HTMLElement, runtime: Runtime): () => void { + const root = createRoot(el) + root.render() + return () => root.unmount() +} +``` + +### ⑤-4 apps/web 三件套 + +`apps/web/index.html`(vite 约定:包根、script 指 src 入口): + +```html + + + + + + dsc + + +
+ + + +``` + +`apps/web/src/main.ts`: + +```ts +import { createRuntime } from '@deepseek-ai/dsh-web-runtime' +import { mount } from '@deepseek-ai/dsh-web-ui' + +const el = document.getElementById('root') +if (el === null) throw new Error('missing #root') +mount(el, createRuntime()) +``` + +`apps/web/vite.config.ts`(vite 默认 outDir 就是 dist、默认吃包根 index.html,无需多配;react 插件负责 web-ui 里的 JSX/tsx): + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) +``` + +vite 对 workspace 依赖的处理:`@deepseek-ai/dsh-web-ui` / `dsh-web-runtime` 经 node_modules 软链解析到源文件(package.json 入口直指 src,§③-4/③-5),vite 当普通源码编译——**不需要** resolve.alias 或 optimizeDeps 配置。 + +### ⑤-5 `apps/dsc/src/bin.ts` — 动线(伪代码级,函数边界与真实 API 已核实) + +```ts +#!/usr/bin/env node +import { createServer } from 'node:http' +import { parseArgs } from 'node:util' +import { createRequire } from 'node:module' +import { dirname, join, normalize, resolve, extname } from 'node:path' +import { readFile } from 'node:fs/promises' +import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { bootHost } from '@deepseek-ai/dsh-apiproxy' + +// ---- 1. 参数 ---- +// argv: dsc web [--port N];positionals[0] !== 'web' → usage 到 stderr,exit 1 +const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { port: { type: 'string', default: '3080' } }, + allowPositionals: true, +}) +if (positionals[0] !== 'web') { process.stderr.write('usage: dsc web [--port N]\n'); process.exit(1) } +const port = Number(values.port) +if (!Number.isInteger(port) || port <= 0 || port > 65535) { /* stderr + exit 1 */ } + +// ---- 2. env ---- +loadEnv('dsc') // 读 /.env 进 process.env;ENOENT 静默(app-boot 具名导出,已核实无 Loader 牵连) + +// ---- 3. host ---- +const host = await bootHost({ persistenceRoot: './.sessions' }) +// 缺 DEEPSEEK_API_KEY 时 LlmDeepSeek 在这里 throw → 顶层 rejection 打印后进程退出(fail loud,不 catch) + +// ---- 4. dist 根 ---- +// 选型:createRequire(同步、返回文件路径、workspace 软链下走真实 exports 映射; +// import.meta.resolve 返回 URL 还得 fileURLToPath,弃) +const require = createRequire(import.meta.url) +const distIndex = require.resolve('@deepseek-ai/dsc-web/dist/index.html') +// dist 不存在(没跑 vite build)时这里同步 throw ERR_MODULE_NOT_FOUND → +// catch 后打印「先跑 pnpm --filter @deepseek-ai/dsc-web build」,exit 1 +const distRoot = dirname(distIndex) + +// ---- 5. 静态服务 ---- +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.json': 'application/json', + '.map': 'application/json', +} +const server = createServer(async (req, res) => { + // 只服务 GET/HEAD,其余 405 + const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname) + const target = resolve(normalize(join(distRoot, pathname))) + // 路径穿越拒绝:target 必须等于 distRoot(即 `/`)或以 distRoot + '/' 为前缀,否则 403 + if (target !== distRoot && !target.startsWith(distRoot + '/')) { /* 403; return */ } + try { + const body = await readFile(target === distRoot ? distIndex : target) + res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' }) + res.end(body) + } catch { + // 未命中(ENOENT/EISDIR)一律回 index.html + text/html 200(SPA 将来路由) + res.writeHead(200, { 'content-type': MIME['.html'] }) + res.end(await readFile(distIndex)) + } +}) + +// ---- 6. listen + 打印 ---- +server.listen(port, '0.0.0.0', () => { + console.log(`dsc web: http://127.0.0.1:${port}`) +}) +// listen 失败(EADDRINUSE):server.on('error') → stderr + disposeAndExit(1) + +// ---- 7. 停机(照 jsonrpc-demo/src/bin.ts:39-51 样板,多关一个 http server)---- +let exiting = false +async function disposeAndExit(code: number): Promise { + if (exiting) return + exiting = true + try { + server.close() // 停止接受新连接;不等既有连接 drain(step1 不做) + await host.dispose() // = ctx.fiber.dispose() + } finally { + process.exit(code) + } +} +process.on('SIGTERM', () => { void disposeAndExit(0) }) +process.on('SIGINT', () => { void disposeAndExit(130) }) +``` + +边界结论(实现时不要改): + +- **不用** app-boot 的 `boot()`/`installFailLoud`/`resolveConfigPath`——那是 Loader 路径;本 bin 只借 `loadEnv`。 +- 未知路径回 index.html 用 **200**(不是 404);`/api/*` step1 无特判,同样回 index.html,step2 再切。 +- 路径穿越判定基准:`resolve(target)` 必须等于 distRoot 或以 `distRoot + '/'` 为前缀(普通字符串前缀即可,distRoot 来自 require.resolve 已是绝对真实路径)。 +- 打印行固定 `http://127.0.0.1:`(listen 的是 0.0.0.0,打印回环地址供本地浏览器点击;容器场景用户自己换 IP)。 + +## ⑥ 验收清单(从仓库根逐条执行) + +前提:根 `.env` 含 `DEEPSEEK_API_KEY`(step1 不发请求,但 LlmDeepSeek load 期查 key)。 + +| # | 命令 | 期望 | +|---|---|---| +| 1 | `pnpm install` | 退出 0;`node_modules/@deepseek-ai/dsc-web` 等五个软链出现 | +| 2 | `pnpm --filter @deepseek-ai/dsc-web build` | 退出 0;产出 `apps/web/dist/index.html` 与 `dist/assets/*.js` | +| 3 | `pnpm run demo:web &`(后台起) | 数秒内 stdout 出现 `dsc web: http://127.0.0.1:3080` | +| 4 | `curl -s http://127.0.0.1:3080/` | 返回 index.html 内容(含 `
`) | +| 5 | `curl -s http://127.0.0.1:3080/assets/<步骤2产出的js名>` | 返回 js;`curl -sI` 看 `content-type: text/javascript` | +| 6 | `curl -s http://127.0.0.1:3080/no/such/route` | 返回 index.html(SPA 回退,HTTP 200) | +| 7 | `curl -s --path-as-is 'http://127.0.0.1:3080/%2e%2e%2fpackage.json' -o /dev/null -w '%{http_code}'` | `403`(穿越拒绝。v2.1 修正:裸 `/../package.json` 即使带 `--path-as-is` 也测不到 403——server 侧 `new URL()` 先把 `/..` 折叠成 `/`,请求安全落为 SPA 回退 200+index.html、无泄漏;只有编码变体在 decodeURIComponent 后才出现 `..`、真正命中 403 分支) | +| 8 | 浏览器开 `http://<容器IP>:3080/` | 页面渲出 `dsc web · skeleton · http://<容器IP>:3080` | +| 9 | 前台 `pnpm run demo:web` 后 Ctrl-C | 进程退出(预期码 130;tsx/pnpm 链路下 shell 观察值可能是信号态,不必较真——能干净退出即过) | +| 10 | `kill -TERM ` | 退出码 0 | +| 11 | 缺 key 场景:`env -u DEEPSEEK_API_KEY DEEPSEEK_API_KEY= pnpm run demo:web`(或临时改名 .env) | 非零退出,stderr 含 `llm-deepseek: an API key is required` | +| 12 | 没跑步骤 2 就 `rm -rf apps/web/dist && pnpm run demo:web` | 非零退出,stderr 提示先跑 `pnpm --filter @deepseek-ai/dsc-web build` | + +验收 3-10 期间 `.sessions/` **不应该**出现(没有 agent、没有 session;SessionPersistenceJsonl 惰性建目录)。出现即说明 bootHost 多干了事。 + +## ⑦ step2 接缝(一句话,不复制契约) + +apiproxy 的 API 契约(`api/` 类型层、fetch 载体、SSE 流、web-runtime 侧 ApiClient/fold/store)**唯一权威在 `../20260719-1902-apiproxy-api-design/design.md`**,且其命名体系仍在演进——本文档不复制任何契约类型名。step1 只保证接缝物理位置:`/api/*` 请求将来在 bin.ts 静态服务 handler 最前面加一个前缀分支转给 apiproxy 的 fetch handler,静态部分零改动;bootHost 返回的 `ctx` 就是将来构造 ApiProxy impl 的输入。 + +## ⑧ 与 v1 的差异记录(给 review 者,不影响实现) + +- v1 的三个遗留问题全部已拍板落死:vite ^6 + plugin-react ^4;`@deepseek-ai/dsc` + bin `dsc` + 子命令 `web`;persistenceRoot `./.sessions`。 +- v1 写「apps/dsc deps 双列 peer+dev」参考 examples 模板——v2 改为**五包全平铺 dependencies**(用户拍板:apps 叶子不玩双列;client 包非 cordis 插件同理;apiproxy 将来正规化再改)。 +- v1 未定 client 包入口形态——v2 定为 src 直入口(main/exports 指 `./src/index.ts(x)`),因 step1 唯一消费者是 vite。 +- dist 解析在 createRequire 与 import.meta.resolve 二选一——v2 定 createRequire。 diff --git a/missions/tasks/20260719-1843-step1-skeleton-design/harness-boot-facts.md b/missions/tasks/20260719-1843-step1-skeleton-design/harness-boot-facts.md new file mode 100644 index 0000000000..4f2a9353fa --- /dev/null +++ b/missions/tasks/20260719-1843-step1-skeleton-design/harness-boot-facts.md @@ -0,0 +1,184 @@ +# Harness 接线事实清单:程序化 boot cordis root + agent spine + +核实日期:2026-07-19。所有相对路径均相对 worktree 根 `/weka-hg/prod/deepseek/permanent/ys/private/workspace/github/deepseek-harness/.vscode/worktrees/worktree-web2`。行号以当前 HEAD(9eb1fbd5d)为准。 + +## 1. 程序化 boot 最小做法(不经 Loader / cordis.yml) + +我们自己做一个 preset 放在 apps/dsc 下面,就像 agent-spine-demo 一样。(但是我们这里全拍平,不依赖其他 preset 包) +还允许开发者配置 cordis.yml 读取 ~/.dsc/cordis.yml +所以,我们这个也算一个 (portal)独立入口了。 + +### 1.1 核心 API:`new Context()` + `ctx.plugin()` + await fiber + +- `ctx.plugin(plugin, config)` 返回一个 thenable fiber:`vendor/cordis/src/registry.ts:315-335`。`wrapped.then` 直接代理到 `fiber.await()`(`registry.ts:330-333`),所以 **`await ctx.plugin(X, config)` 就是"等该插件 fiber 稳定并重抛启动错误"** —— 没有独立的 `ctx.start()`。 +- `Fiber.await()` 语义(等 inertia 清空、`_error` 存在则 throw):`vendor/cordis/src/fiber.ts:701-707`。 +- 停机侧对应物是 `ctx.fiber.dispose()`(Fiber 的 `dispose` 字段声明在 `vendor/cordis/src/fiber.ts:193`)。 + +### 1.2 仓库内现成的"纯程序化装满 spine"范例 + +最完整的一份在 `packages/context/workspace-context/tests/workspace-context.e2e.ts:37-53`: + +``` +ctx = new Context() // :37 +await ctx.plugin(LlmService) // :38 @deepseek-ai/dsh-llm +await ctx.plugin(SessionStore) // :39 @deepseek-ai/dsh-session +await ctx.plugin(SystemPrompt, { persona: '...' }) // :40 @deepseek-ai/dsh-system-prompt +await ctx.plugin(ToolRegistry) // :41 @deepseek-ai/dsh-tools +await ctx.plugin(AgentRegistry) // :42 @deepseek-ai/dsh-agent +await ctx.plugin(LocalFileSystem, { cwd: '/' }) // :43 @deepseek-ai/dsh-fs-local +await ctx.plugin(ToolFs) // :44 +await ctx.plugin(WorkspaceContext, { maxBytes: 65536 }) // :45 +await ctx.plugin(AgentLoop, { agents: [] }) // :46 @deepseek-ai/dsh-agent-loop +await ctx.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] }) // :47 +const handle = await ctx.agents.create({ sessionId, meta: { cwd }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' } }) // :48-52 +``` + +teardown 用 `await ctx?.fiber.dispose()`(同文件 `:27`)。等空闲的方式是订阅 `ctx.on('agent/status', ...)` 等 `'idle'`(`:56-65`)。 + +更小的官方 helper:`packages/support/agent-loop-testkit/src/index.ts:37-46`(`mountAgentLoopTestDependencies`)依次 `await ctx.plugin()` 挂 LlmService / SessionStore / SystemPrompt / ToolRegistry / AgentRegistry,注释明确"await 逐个装,失败即 reject"。 + +### 1.3 更省事的做法:直接装 spine bundle 插件 + +`@deepseek-ai/dsh-agent-spine-demo` 是一个"函数插件 bundle",其 `apply()` 一次性挂全默认 spine(`packages/examples/agent-spine-demo/src/index.ts:136-170`),子插件与传参逐个是: + +| 顺序 | 插件 | config 形状 | 行号 | +|---|---|---|---| +| 1 | `@cordisjs/plugin-timer` (Timer) | 无 | :144 | +| 2 | `@deepseek-ai/dsh-llm` (LlmService) | 无 | :145 | +| 3 | `@deepseek-ai/dsh-session` (SessionStore) | 无 | :146 | +| 4 | `@deepseek-ai/dsh-system-prompt` | `{ persona, toolOrder? }` | :148-151 | +| 5 | `@deepseek-ai/dsh-tools` (ToolRegistry) | `config.tools ?? {}` | :152 | +| 6 | `@deepseek-ai/dsh-skill` (SkillService) | `config.skills?.registry ?? {}` | :153 | +| 7 | `@deepseek-ai/dsh-skill-local` | `{ ...skills.local, dshHome }` | :154 | +| 8 | `@deepseek-ai/dsh-agent` (AgentRegistry) | 无 | :155 | +| 9 | `@deepseek-ai/dsh-tasks` (TaskService) | 无 | :156 | +| 10 | `@deepseek-ai/dsh-invariants` | 无 | :157 | +| 11 | `@deepseek-ai/dsh-tool-bash` | `{ ...toolBash, dshHome }` | :158 | +| 12 | `@deepseek-ai/dsh-workspace-context` | `config.workspaceContext`(`false` 时不装) | :159-161 | +| 13 | `@deepseek-ai/dsh-tool-skill` | `config.skills?.tool ?? {}` | :164 | +| 14 | `@deepseek-ai/dsh-tool-tasks` | `config.toolTasks ?? {}` | :165 | +| 15 | `@deepseek-ai/dsh-agent-loop` (AgentLoop) | `{ agents: config.agents ?? [], maxParallelToolCalls? }` | :166-169 | + +要点: + +- **装载顺序无关紧要**(cordis 按 `inject` 挂起 fiber 直到依赖服务出现),列表顺序只为可读性 —— 该文件 JSDoc 明说(`:130-134`)。但两个 session-prefix 生产者(workspaceContext 与 toolSkill)的注册顺序 = 渲染顺序(`:162-164`)。 +- bundle 不装 LLM 适配器、bash 执行器、持久化、UI —— 那些是"部署选择",由外层继续 `ctx.plugin()`(如 `LlmDeepSeek`、`@deepseek-ai/dsh-bash-local`、`SessionPersistenceJsonl`)。 +- bundle 的 `apply()` 内部 `ctx.plugin()` **不 await**;stdio-demo 单测挂完后靠 `setTimeout 80ms` 等子 fiber 稳定(`packages/examples/stdio-demo/tests/stdio-agent.spec.ts:19-27` 及其注释 "The app mounts its children inside apply() (not awaited there)")。程序化 boot 若要确定性等待,逐个 `await ctx.plugin()`(1.2 的做法)更稳。 + +### 1.4 stdio-demo 的组合形状(composeTerminalApp,作为 app 层参照) + +`packages/examples/stdio-demo/src/index.ts:143-173`:先按 TTY 选 UI 模式(readline 时装 `@cordisjs/plugin-logger-console`,`:147`),然后依次 `ctx.plugin(SessionPersistenceJsonl, { root })`(`:148`)、`ctx.plugin(UserInteractionService)`(`:149`)、选定的 `uiTui`/`uiStdio`(带 `welcome`+`sessionId`,`:150-161`)、`ctx.plugin(agentCore, { ...pickSpineConfig(config), agents: [{ id, provider, model, cwd: process.cwd(), sessionId | resumeSessionId }] })`(`:162-171`)、最后 `ctx.plugin(toolAskUser)`(`:172`)。 + +### 1.5 Loader 路径的 boot(对照,dsh-app-boot) + +`packages/ui/app-boot/src/index.ts:114-126` 的 `boot()`:`new Context()` → 设 `ctx.baseUrl`(`:116`)→ `await ctx.plugin(Loader)`(`:117`)→ 注册 include builtin(`:118`)→ `ctx.loader.create({ name: 'cordis:include', config: { path } })`(`:119-122`)→ **`await ctx.loader.await()`** 等整树稳定(`:123`;实现是 `vendor/loader/src/config/tree.ts:43-49`,循环 `Promise.allSettled` 所有 pending 任务)→ `assertEntriesLoaded()` 拒绝无 fiber 条目(`:124`,实现 `:89-95`)。 + +## 2. acp-demo 不预建 agent 的写法 + +- acp-demo 的 `apply()` 里 **根本不给 spine 传 `agents` 字段**:`ctx.plugin(agentCore, agentCore.pickSpineConfig(config))`(`packages/examples/acp-demo/src/index.ts:90`)。`pickSpineConfig` 的类型就是 `Omit`(`packages/examples/agent-spine-demo/src/index.ts:112`)。 +- 缺省落到 spine 的 `agents: config.agents ?? []`(`packages/examples/agent-spine-demo/src/index.ts:167`),最终是 AgentLoop schema 的 `.default([])`(`packages/core/agent-loop/src/index.ts:413-419`);AgentLoop 构造器只对 `config.agents` 里的条目预建 agent(`:440` 起的 for 循环),空列表即什么都不建。 +- 语义注释两处:spine 的 Config JSDoc "`agents` to the agent loop (an app that pre-creates no agents, like the ACP bridge, simply omits it)"(`packages/examples/agent-spine-demo/src/index.ts:43-44`);acp-demo Config JSDoc "NOT a pre-created agent — ACP creates agents at `session/new`"(`packages/examples/acp-demo/src/index.ts:27-28`)与 apply JSDoc "pre-creates NO agents (its `agents` list defaults to `[]`) ... creates one agent per `session/new`"(`:83-87`)。 +- agent 真正被创建的时机:ACP bridge 收到 `session/new` RPC 时 `await agents.create({ sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), setup })`(`packages/ui/acp/src/index.ts:654-668`,`agents.create` 在 `:662`)。 + +## 3. DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL / 根 .env 的读取链路 + +三层,全部与 dotenv 包和 `node --env-file` 无关: + +1. **bin 层读 `.env` 进 process.env**:`loadEnv()` 用 Node 内建 `process.loadEnvFile(resolve(dir, '.env'))`,dir 默认 `process.cwd()`;ENOENT 静默回退到环境(`packages/ui/app-boot/src/index.ts:40-52`,`loadEnvFile` 调用在 `:45`)。各 bin 在 boot 前调用:stdio-demo `src/bin.ts:17`、acp-demo `src/bin.ts:23`(replay 快照模式跳过)、jsonrpc-demo `src/bin.ts:20`。 +2. **cordis.yml 层用 `!!js` 把 env 显式喂进插件 config**:`examples/repl-agent/cordis.yml:18-19`(`apiKey: !!js process.env.DEEPSEEK_API_KEY`、`baseURL: !!js process.env.DEEPSEEK_BASE_URL`);acp-agent 同款(`examples/acp-agent/cordis.yml:10-11`)。 +3. **插件层兜底再读一次 process.env**:`@deepseek-ai/dsh-llm-deepseek` 的 `apply()` 里 `config.apiKey ?? process.env.DEEPSEEK_API_KEY`(缺 key 直接 throw,load 期 fail loud)与 `config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL`(`packages/llm/llm-deepseek/src/index.ts:82-86`)。所以**程序化 boot 只要 process.env 里有 key,`ctx.plugin(LlmDeepSeek, {})` 即可工作**(1.2 范例正是这么干的)。 + +## 4. 停机 / dispose + +统一原语:**`ctx.fiber.dispose()`**(root context 自己的 fiber)。各 demo 的触发方式: + +- **jsonrpc-demo(信号处理最完整的样板)**:`packages/examples/jsonrpc-demo/src/bin.ts:39-51` —— `disposeAndExit(code)` 带 `exiting` 单次门闩,`try { await ctx.fiber.dispose() } finally { process.exit(code) }`;接线为 `process.stdin.on('end') → 0`、`SIGTERM → 0`、`SIGINT → 130`(`:49-51`)。 +- **acp-demo**:仅快照模式在 stdin EOF 时 `void ctx.fiber.dispose().then(() => process.exit(0))`(`packages/examples/acp-demo/src/bin.ts:30-34`);正常运行 "editors normally own process lifetime"(`:8`),无信号处理。 +- **cli-demo**:bin 层不直接 dispose——SIGINT/SIGTERM 只 abort 一个 AbortController 并记退出码 130/143(`packages/examples/cli-demo/src/bin.ts:15-33`);dispose 在 cli.ts 内部:`runtime.dispose ?? (target => target.fiber.dispose())`(`packages/examples/cli-demo/src/cli.ts:409`),任务收尾时 `await disposeContext(ctx)`(`:438`)。 +- **stdio-demo**:bin 无信号处理(`src/bin.ts` 全文仅 19 行);退出由 stdio UI 插件驱动——stdin EOF 后 `maybeExit()` 等 agent idle,再经 200ms flush 定时器调 `exit(0)`(默认 `process.exit`)(`packages/ui/stdio/src/index.ts:212-229`,默认 exit 钩子 `:464`)。 +- 测试里的顺序惯例:先 `await ctx.fiber.dispose()` 再清理临时目录(`packages/context/workspace-context/tests/workspace-context.e2e.ts:26-31`)。 + +## 5. pnpm-workspace.yaml 现状(全文) + +`pnpm-workspace.yaml` 全文如下。**glob 是 `packages/*/*`(`:3`),目前没有 `apps/*`**;成员为 `vendor/*`、`packages/*/*`、`website`、`examples`(仅依赖解析、非构建目标,见 `:5-10` 注释)、`python/sdk-runtime`。 + +```yaml +packages: + - vendor/* + - packages/*/* + - website + # The runnable demo leaves join as ONE workspace member: examples/package.json + # declares the union of every leaf's cordis.yml plugins as workspace:*, so a + # plain-node (`:lib`) boot of any leaf (examples//cordis.yml) resolves its + # plugins through real package `exports`→lib by walking up to examples/node_modules. + # Members for DEPENDENCY RESOLUTION only — NOT build targets: tsdown's explicit + # globs (vendor/*, packages/*/*) exclude them. See the example-execute-over-tsx RFC. + - examples + # Deploy root of the single-exe build: a pure dependency manifest whose + # closure is what the exe bundles and what the Python runtime distributes. + - python/sdk-runtime + +peerDependencyRules: + allowedVersions: + typescript: '>=5 <7' + +# pnpm 10+ blocks any dependency shipping an install/build script until it is +# explicitly reviewed here (strictDepBuilds defaults to true: an unlisted script +# is a hard install error). Every such package MUST be listed; we deny by +# default and only allow scripts we need. esbuild (native binary) and lefthook +# (git hooks) genuinely need theirs. +allowBuilds: + esbuild: true + lefthook: true + # Pulled in by @earendil-works/pi-ai (optional LLM API backend). pnpm lists + # them only because they ship lifecycle scripts, but those are no-ops we don't + # need, so we deny them — install still succeeds. + '@google/genai': false + protobufjs: false + node-addon-require-builtin: false + +# The Landlock launcher family is our own sibling-repo release, consumed +# fresh (hours old at each coordinated bump) — the release-age quarantine +# would block every such bump, so the family is exempted BY NAME, not by +# pinned version. +minimumReleaseAgeExclude: + - node-addon-landlock-run + - node-addon-landlock-run-linux-arm64 + - node-addon-landlock-run-linux-x64 + # Cordis release candidates are source-vendored and pinned in vendor/README.md + # during the same-day sync that updates package manifests and the lockfile. + - '@cordisjs/plugin-loader@1.0.0-rc.5' + - cordis@4.0.0-rc.7 +``` + +tsdown 侧印证 "examples 目录非构建目标":根 `tsdown.config.ts:16` 只 bundle `workspace: ['vendor/*', 'packages/*/*']`。 + +## 6. demo 脚本运行方式与构建产物 + +- **demo 脚本全部是 tsx 跑 src**(根 `package.json:81-87`): + - `demo:echo` / `demo:repl` / `demo:tui` / `demo:cordis`:`node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples//cordis.yml`(`:81,82,84,86`;`--expose-internals` 是 HMR 需要) + - `demo:headless`:`node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml`(`:83`) + - `demo:acp`:`node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml`(`:87`,无 `--expose-internals`,因 ACP 无 HMR) +- **发布/built 路径是 plain node 跑 `lib/bin.js`**:built-bin e2e 明确 "run `lib/bin.js` under plain Node ... NO tsx"(`packages/examples/stdio-demo/tests/built-bin.e2e.ts:10,17,112`)。 +- **构建管线**:`build = tsc -b tsconfig.build.json && tsdown`(根 `package.json:16`)。tsc 先出 `lib/types/*.js + d.ts`,tsdown 从 `lib/types/index.js` bundle 出 `lib/index.js`(根 `tsdown.config.ts:12-27`,`dts: false`)。**带 bin 的包需要自己的 tsdown override 加第二个 entry**:`packages/examples/stdio-demo/tsdown.config.ts` 的 `entry: ['lib/types/index.js', 'lib/types/bin.js']`(acp-demo 同款)。 +- bin 字段指向构建产物:`"bin": { "dsh-stdio-demo": "lib/bin.js" }`(`packages/examples/stdio-demo/package.json:9-11`)、`"dsh-acp-demo": "lib/bin.js"`(`packages/examples/acp-demo/package.json:9-11`)。 + +## 7. 三个 examples 包 package.json 形状(新包模板参考) + +共同形状(三个包一致): + +- `"name": "@deepseek-ai/dsh-"`、`"version": "0.0.1"`、`"private": true`、`"type": "module"`、`"license": "BSD-3-Clause"` +- `"main": "lib/index.js"`、`"types": "lib/types/index.d.ts"` +- `exports`:`"."` → `{ types: ./lib/types/index.d.ts, default: ./lib/index.js }`;有 bin 的再加 `"./bin"` 同构;一律带 `"./src/*": "./src/*"` 和 `"./package.json": "./package.json"` +- `files`:`lib/index.js`(+ `lib/bin.js`)、`lib/types/**/*.d.ts`、`lib/types/**/*.d.ts.map`、`src` +- **依赖模式:所有运行时依赖同时出现在 `peerDependencies`(`^0.0.1` / cordis `^4.0.0-rc.7`)和 `devDependencies`(`workspace:^`)**,符合根约定 "cordis is a peerDependency (+ dev) of every harness package"。 + +逐包: + +- **stdio-demo**(`packages/examples/stdio-demo/package.json`):有 `bin`(`:9-11`)、`./bin` export(`:17-20`);peers 含 plugin-include/plugin-loader/plugin-logger-console、dsh-app-boot、dsh-agent、dsh-agent-loop、dsh-llm、dsh-agent-spine-demo、dsh-workspace-context、dsh-session、dsh-session-persistence-jsonl、dsh-stdio、dsh-tui、dsh-tool-ask-user、dsh-tools、dsh-user-interaction、cordis、schemastery(`:32-51`)。 +- **agent-spine-demo**(`packages/examples/agent-spine-demo/package.json`):无 bin;peers 是 spine 全家(timer、agent、agent-loop、invariants、home、llm、workspace-context、session、skill、skill-local、system-prompt、tasks、tool-bash、tool-skill、tool-tasks、tools、cordis,`:24-42`);**特例:`schemastery` 在 `dependencies` 而非 peer**(`:63-65`)。 +- **acp-demo**(`packages/examples/acp-demo/package.json`):有 `bin`(`:9-11`);peers 含 plugin-include/plugin-loader(无 logger-console —— stdout 纯 JSON-RPC)、dsh-app-boot、dsh-acp、dsh-agent-spine-demo、dsh-workspace-context、dsh-session-persistence-jsonl、dsh-tools、dsh-user-interaction、cordis、schemastery(`:32-44`)。 + +## 附:repl-agent cordis.yml 的 Loader 声明式全量清单(对照) + +`examples/repl-agent/cordis.yml` 挂载(id → 包名,含 config 要点):`hmr`(root `['.']`,`:9-12`)、`llm-deepseek`(apiKey/baseURL 走 `!!js` env,`:15-19`)、`bash` = dsh-bash-local(`timeoutMs: 60000`,`:22-25`)、`stdio-agent` = dsh-stdio-demo(provider/model、resumeSessionId `!!js`、persistenceRoot `./.sessions`、workspaceContext.maxBytes 65536、ui.mode readline、persona,`:28-48`)、`token-meter`(`:51-52`)、`compact-basic`(`:56-57`)、`subagent` + `subagent-spawn` + `subagent-fork` + 两个 `tool-subagent`(`:62-85`)、`workflow-workerthread` + `tool-workflow`(`:90-96`)、`tool-todo`(`:98-99`)、`fs-local`(`cwd: !!js process.cwd()`,`:104-106`)、`fs-policy`(`:108-109`)、`tool-fs`(`:111-112`)、`tool-fs-search`(`:117-118`)、`timeout-policy`(`:124-125`)、`spill-local` + `spill-policy`(`maxInlineBytes: 50000`,`:132-138`)。 diff --git a/missions/tasks/20260719-1902-apiproxy-api-design/README.md b/missions/tasks/20260719-1902-apiproxy-api-design/README.md new file mode 100644 index 0000000000..1ba87b765b --- /dev/null +++ b/missions/tasks/20260719-1902-apiproxy-api-design/README.md @@ -0,0 +1,68 @@ +# apiproxy 统一 API 层设计(step2 协议基础) + +任务:定义 apiproxy 对多形态(Web/Electron/TUI)暴露的统一 API 接口层 + web client 的 HTTP/SSE 架构。主会话直写(决策密度高),文档等用户 review。 + +## 用户拍板记录(2026-07-19) + +| 议题 | 结论 | +|---|---| +| 契约权威 | TS interface 权威 + fetch 载体(进程内注入 handler 当 fetch = opencode 同构点) | +| 流形态 | AsyncIterable + AbortSignal | +| 事件面 | 两条 SSE:全 session 一条 mux 聚合 + host 信息一条(沿用旧结论) | +| 接口分组 | 按业务域分文件:`sessions.ts` 一域一文件 | +| 路径映射 | RPC 风格,不考虑 REST 体验 | +| 错误模型 | 类型化 ResultType(不 throw) | +| 校验 | zod 双向校验,**不要 passthrough**;可 dev-only 开启 | +| 事件 payload | 原则透传 core 结构,不自造封装;tool presentation 先透传,文档标注遗留 | +| 历史读取 | 按简单来,不做多套(事件重放 + client 单一 fold) | +| mux 重连 | 不做 since 续传(签名留座),重连=重开流+重拉 history(采纳 opencode 对照建议) | +| 冷 session | attach 状态不对客暴露,只给 running(冷=false);history/prompt 隐式 resume | +| history 分页 | **按消息边界切页**(不从消息中间截断,chunk 随定稿消息归组),参数 maxMessages | +| SessionSummary | v1 不建索引:sessionId + 文件 mtime(updatedAt) + running 三字段 | +| prompt 载荷 | 直接收 core `ContentBlock[]`,不设 text 简化层 | +| schema 文件 | 一域一对:`sessions.ts`(类型)+ `sessions.schema.ts`(zod) | +| subscribed 帧 | 保留 lastSeq 字段(history 补缝竞态检测) | +| SessionListCursor(2026-07-19 20:00) | **不 brand**:v1 未实现占位用裸 `cursor?: string`,实现分页时再定是否 brand | +| HostInfo(2026-07-19 20:02) | 五字段定稿:version/cwd/provider?/model?/attachedSessions;**不设 protocolVersion**(client/host 绑定发布,无跨版本组合;独立发布 client 出现时再引入) | +| RPC 命名体系(2026-07-19 20:15) | ApiResult→**RpcResponse**(方向可辨识:Request 族 / Response 族 / Frame 族);**每方法具名 Request/Response,签名禁内联**;空 request 也具名;流方法 signal 独立第二参不进 Request;HostInfo 并入 HostDescribeResponse;`hostEvents` 方法名改 `host`(对齐 wire 路径 `events.host`,避免 EventsHostEventsRequest 推导怪名) | +| RPC map(2026-07-19 20:30) | 方向拍板:加 RpcMethodMap + ClientRequest\/ServerResponse\ 泛型索引;`events.host` 改名通过;map key 单复数授权设计层定(定单数 `session.list`,wire 路径同步);形态 A vs B 并排呈案 | +| RPC map 终选(2026-07-19 20:41) | **形态 B:函数签名即事实源**——参数/返回内联在方法签名,RpcMethodMap 登记方法,ClientRequest/ServerResponse/ServerValue infer 反推;「禁内联」放宽为「禁重复内联」;平铺具名 15 类型删除(zod 直接锚派生类型,报错展开代价用户接受);空 request 用 `{}` | +| RpcError 强类型化(2026-07-19 20:56) | **details 走错误码→类型 map**(RpcErrorDetailsMap,与 RpcMethodMap 同构第二张表);RpcError 用 map 展开分布式 union(泛型 interface 默认形式不窄化,弃);**details 必填**(internal 显式 `{}`),「必须真填」纪律升编译期强制;zod 用 discriminatedUnion('code') 逐支锚定 | +| 审批/问答形态(2026-07-19 21:00) | **「unary 对」定案**:请求下行 mux 帧(requested,稳定 id+session 锚)、回答上行 HTTP unary(respond 带 id),不做双向帧;域从不做清单**升格为本轮出协议设计**(实现可排后);细节提案见 core-coverage.md 审批/问答节,随 L1-L7 一并裁决 | +| rpcId + 信封(2026-07-19 21:04) | **所有 unary 指令带 client mint 的 rpcId**(不做只 prompt 带的区分);**wire 两层分离**:RpcRequestEnvelope(rpcId/method/payload) / RpcResponseEnvelope(rpcId/result);ApiProxy 签名不感知信封(载体层统一包/解);method 字段保留(日志自含+path 校验);RpcId brand(首个 client mint id,构造函数照 SessionId 先例);SSE open 不带 rpcId;prompt 的 rpcId 经 MessageSource 透传为 provisional 关联机制(转正执行仍 v1 不做) | +| 整轮裁决(2026-07-19 21:13) | **L1/L2/L5 进契约**(SessionSummary+session-added 帧加 parentSessionId?、SessionSummary 加 cwd?、HostFrame 加 host/agent-error);**L3/L4/L6/L7 类型写全预留**(design §8,不进 map——fail loud 优于 not-implemented 兜底);**审批/问答提案整体采纳**并入正文 §3.4(先到先赢/resolved 收敛/subscribed 基线重放/ApprovalRequestId 复用+QuestionAskId host mint 均按推荐);rpcId≠资源 id 辨析入档;design.md 定稿 v1.5 | +| 问答 id 统一(2026-07-19 21:19) | **取消 QuestionAskId**:问题标识复用 `RpcId` 类型(host 受理 ask 时同一 `RpcId()` mint);RpcId 语义扩为「**交互发起方 mint**」;**审批仍透传 core ApprovalRequestId,有意不对称**(durable 审计事件关联+透传非自造);rpcId≠资源 id 辨析措辞更新(类型统一、语义仍分立) | +| 帧信封对称化(2026-07-19 21:19) | **每条 SSE 帧包 `RpcFrameEnvelope{rpcId, frame}`**,rpcId 由 server 发帧时 mint(标识这一次推送);职责=日志对账+去重/追溯接缝,**不承担 cursor**(续传锚仍是 event.seq);流侧同样签名不感知信封(AsyncIterable\ 不变,createApiClient 内拆);信封模型全对称:Request/Response/Frame 三信封 | +| 签名信封化(2026-07-19 21:5x) | **推翻「签名不感知信封」**:ApiProxy 方法签名显式收 `RpcRequest

` 封装(rpcId 进签名不进业务 payload);「单次 HTTP 所以 rpcId 不重要」论证被用户否定——rpcId 是逻辑层关联,不因传输自带信道而省略 | +| 四象限消息模型(2026-07-19 22:00–22:1x,v2.0) | **通道与消息彻底解耦**(HTTP=C→S 通道、SSE=S→C 通道,仅此而已);wire 全形=**四具名判别 union**(ClientRequest/ServerResponse/ServerRequest/ClientResponse,22:1x 用户坚持字面四具名,判别子=type 四字面量);**纯推送=不期待应答的 server-request,严格二分不设 notify**(22:1x 用户采纳设计层方案);**respond 重建模为 client-response**:回填 requested 帧 rpcId、不 mint 新 id、不进 RpcMethodMap,wire=POST /api/respond 单端点、HTTP 应答体=RpcReceipt 载体回执;两个 not-pending 错误码删除;泛型工具撞名改 RequestPayload\/ResponseValue\;流签名 yield RpcRequest\<帧\>(rpcId 暴露给业务层);流程放宽:文档更新完直接生码不等确认 | +| client 载体类体系(2026-07-20 shape-a + abstract-base,commit 893421d50;本行由 rfc-consolidation 代笔补记——apiproxy-design 静默中,RFC 第二篇写作核码顺手补一致) | **createApiClient 工厂废除,改 AbstractApiClient 抽象基类**:协议不变量(mint/四象限包解/zod/SSE 解帧/超时/rpcId 回显校验)全在基类,平台差异=两切面(抽象 doFetch 传输 + 可覆写 onEnvelope 观测);**IApiClient=caller 视图(shape a)**:unary 收业务 payload 直传、载体 mint、业务代码永不 mint,与 ApiProxy(impl 窄形契约)由基类桥接;**实例级 envelope 观测**:subscribeEnvelopes 批量订阅(微任务合批、异常隔离、无订阅者零成本),旧 onEnvelope 选项+ApiEnvelopeTapEvent 废除,rpcLog 降纯订阅者;子类=InProcessApiClient(同构点新写法)/WebApiClient/FixtureApiClient(协议层覆写虚方法,假信封包装器删除);design.md §4.1/§5 已同步 | + +## 文件索引 + +| 文件 | 内容 | +|---|---| +| `design.md` | API 层设计文档(主产出) | +| `opencode-crosscheck.md` | opencode 调研对照表(同构面验证/CQRS 同向/重连砍 cursor,建议已采纳) | +| `core-coverage.md` | core 能力面 × 契约覆盖度盘点(7 域四态标注 + 漏判清单 L1-L7 已裁决,存档;契约以 design.md 为准) | + +## 进展 + +| 时间 | 事项 | +|---|---| +| 2026-07-19 19:02 | 主会话开写 design.md;核实 core 类型面(SessionEvent/seq/foldSurface/Agent 原语) | +| 2026-07-19 19:06 | design.md v1 落盘:三域接口(sessions/host/events)、ApiResult、fetch 载体映射、client 分层、不做清单、3 个开放问题 | +| 2026-07-19 19:10 | opencode 调研回队;对照写入 opencode-crosscheck.md(同构面/CQRS/无 cursor 重连三判断) | +| 2026-07-19 19:48 | 用户拍板 Q1–Q8;design.md 升 v1.1:重连重建、消息边界分页、ContentBlock 透传、schema 文件对、lastSeq 保留;开放问题清零(时间按 design.md 文件 mtime 推断) | +| 2026-07-19 20:00 | 用户拍板 SessionListCursor 取消 brand;design.md 同步(id 纪律 + §3.1 `cursor?: string`) | +| 2026-07-19 20:02 | 用户拍板 HostInfo 五字段定稿(去 protocolVersion,命名回 version);design.md §3.2 落 interface 全文 + 决策边界注记 | +| 2026-07-19 20:15 | 用户拍板 RPC 命名体系重构;design.md 全文替换:RpcResponse/RpcError(rpc.ts)、11 个具名 Request/Response、Frame 族独立、新增「命名 convention」小节 | +| 2026-07-19 20:30 | 用户三点裁决:key 单复数授权设计层(定单数)、events.host 通过、map 形态待终选;design.md 落「RPC map 两种形态」对比小节(A 类型对 / B 函数签名 infer,五维差异表 + 推荐 A) | +| 2026-07-19 20:41 | 用户终选形态 B;design.md 升 v1.3 分批收尾:map 节改终选结论、§3 三域内联回归签名(删 15 个平铺具名)、convention 改「禁重复内联」、zod 锚 infer 派生、布局落 rpc-map.ts、wire 表 key 对齐 | +| 2026-07-19 20:54 | core-coverage.md 盘点完成(session/agent/subagent/tasks/审批问答/杂项/LLM 七域,file:line 为证);漏判清单 L1-L7:建议进 v1 三条(L1 谱系/L2 cwd/L5 agent-error 帧)、留接缝四条(L3 fork/L4 inject/L6 tasks/L7 provider 枚举),待用户逐条裁决 | +| 2026-07-19 20:56 | 用户拍板 RpcError.details 强类型化,design.md §2 重写(RpcErrorDetailsMap + 分布式 union + details 必填 + zod discriminatedUnion + 扩展路径);rpc-compare 三采纳项落档:details 真填纪律(并入 §2 升编译期强制)、client unary 超时注记(§5)、并发 resume 去重注记(§3.1) | +| 2026-07-19 21:00 | 用户拍板审批/问答「unary 对」形态并升格为本轮出协议;core-coverage.md 落协议提案节(方法/帧、id 纪律、竞争语义、subscribed 基线重放恢复、core 事实对齐六表),补核 user-interaction 无 request 级 id、ask 不落日志两事实;随 L1-L7 待整体修订轮裁决 | +| 2026-07-19 21:04 | 用户拍板全指令 rpcId + 信封两层分离;design.md 升 v1.4(§2 信封类型+纪律、§4 wire 两级 parse、id 纪律 RpcId、convention 二层分离、不做清单改写) | +| 2026-07-19 21:13 | 用户整轮裁决;design.md 定稿 **v1.5**:L1/L2/L5 合入(批1)、审批/问答域并入正文 §3.4+根接口+map+四帧+两错误码(批2)、§8 预留接缝类型 L3/L4/L6/L7(批3)、版头/README/core-coverage 标注(批4) | +| 2026-07-19 21:19 | 用户两条修订并入 v1.5:QuestionAskId 取消(问答 id 复用 RpcId,RpcId 语义扩「交互发起方 mint」,审批有意不对称留 ApprovalRequestId);帧信封对称化(RpcFrameEnvelope{rpcId,frame},SSE data 改信封 JSON,流侧签名不感知,rpcId 不承担 cursor);design.md §2/§3.4/§4/id 纪律/convention/版头六处同步,core-coverage 提案节标注修订 | +| 2026-07-19 21:55 | W1 契约包(旧三信封模型)dispatcher 直写落盘 14 文件 typecheck 绿;Wire 锚定修正回写 §0.5(exactOptionalPropertyTypes 与 zod .optional 不兼容) | +| 2026-07-19 22:00–22:3x | 用户三轮拍板推到四象限模型(签名信封化→通道解耦→四具名 union+二分裁决);design.md 升 **v2.0**:§2 重写(四具名/窄形/RpcReceipt/错误码删两个)、§3 签名全改 RpcRequest

、§3.3 流 yield RpcRequest<帧>、§3.4 respond 重建模、§4 wire 四象限表、rpc-map 6 key+RequestPayload/ResponseValue、convention 同步、§8 补 hostInstanceId 预留(ui-design 提出);期间主会话短暂接管又交还(用户澄清 owner 不变) | diff --git a/missions/tasks/20260719-1902-apiproxy-api-design/core-coverage.md b/missions/tasks/20260719-1902-apiproxy-api-design/core-coverage.md new file mode 100644 index 0000000000..f6f0126860 --- /dev/null +++ b/missions/tasks/20260719-1902-apiproxy-api-design/core-coverage.md @@ -0,0 +1,170 @@ +# core 能力面 × 契约 v1.3 覆盖度盘点 + +> 2026-07-19 起盘(分批落盘中)。方法:逐包读 core 源码 types/service 面(file:line 为证),对照 design.md v1.3 标注四态:**已覆盖** / **有意不做**(引拍板)/ **接缝已留**(说在哪)/ **漏判**(需新裁决,汇总见文末清单)。 +> 背景:rpc-compare 发现契约漏 subagent 谱系,根因是当初只按 UI 需求反查 core、未做系统盘点;本文件补这道工序。 + +## 1. session 域(packages/core/session) + +### 1.1 SessionEventMap 全类型表(types.ts:180-252) + +| 事件 | core 事实 | 契约状态 | +|---|---|---| +| `turn/start` / `turn/end` | types.ts:187/193,turn 边界 + TurnTrigger/TurnEndReason(merge-extensible,types.ts:79-124) | **已覆盖**——mux `session/event` + history 纯透传(design §3.3 透传纪律) | +| `step/start` / `step/end` | types.ts:195/197 | **已覆盖**(同上透传) | +| `user/message` | types.ts:199 | **已覆盖**(透传;client fold 消费) | +| `prompt/blocked` | types.ts:204,veto 的 durable 记录 | **已覆盖**(透传)。UI 是否渲染是 client fold 决策,不是契约缺口 | +| `context/message` | types.ts:212-217,含 `meta`(模型不可见 durable JSON) | **已覆盖**(透传) | +| `assistant/chunk` | types.ts:219,token 级 | **已覆盖**——「token 流即事件流」(design §3.3) | +| `assistant/message` | types.ts:226,含 `usage?: TokenUsage` | **已覆盖**(透传)。**usage/token 统计随之免费到达 client**,无需独立统计接口(§7 LLM 层回引此行) | +| `tool/call` / `tool/result` | types.ts:232/242;result 带 `meta?: unknown`(tool 私有 presentation 载荷) | **已覆盖**(透传);render intent(presentCall/presentResult)**有意不做**——design §3.3 tool presentation 拍板「先透传,additive 附件帧留座」 | +| `steering/message` | types.ts:244 | **已覆盖**(透传) | +| `todo/write` | types.ts:246,全量快照、last-write-wins、log-only | **已覆盖**(透传);client fold 照 last-write-wins 折即可,无需独立 todo 接口(§6 回引此行) | +| `request/header` | types.ts:251,EpochHeader 快照(config/system/tools/messagePrefix) | **已覆盖**(透传)。UI 可从中读 provider/model 现值变化 | +| merge-extensible 扩展键 | types.ts:180 map 声明 | **已覆盖**——design §3.3:client fold 对未知 type documented-default,schema 留「合法信封+未知类型」分支 | + +### 1.2 Session/SessionStore 服务面(index.ts) + +| 能力 | core 事实 | 契约状态 | +|---|---|---| +| `session/created` / `session/disposed` 事件 | index.ts:47/57 | **已覆盖**——HostFrame `host/session-added`/`removed`(design §3.3) | +| `session/event` 追加流 | index.ts:69 | **已覆盖**——mux 的源 | +| `session/flush` 检查点 | index.ts:79 | **有意不做**(不对客暴露)——durability 是 host 内部事务,client 只见已落地事件 | +| `store.get/list` | index.ts:818/826(live only) | **已覆盖**——sessions.list(live+冷合并的持久化清单,v1 mtime 三字段拍板) | +| `SessionHeader.parentSession` + `seedLength` | types.ts:47/52;fork 时写入 index.ts:854-855 | **漏判 →【L1】**——谱系在 core 持久化面存在,契约 SessionSummary/HostFrame 均未携带 | +| `SessionHeader.cwd` / `createdAt` | types.ts:43/45 | **漏判 →【L2】**——create 收 cwd 入参但 list/describe 均不回吐;createdAt 被 v1「mtime 即 updatedAt」拍板部分覆盖但非同一语义 | +| `SessionStore.fork()` | index.ts:843-857;SessionForkSource index.ts:546;错误码 SessionForkErrorCode index.ts:556-562(turn/end 边界约束 :890-895) | **漏判 →【L3】**——core 有完整 fork 原语(opencode 也有 POST /session/:id/fork 对照),契约无 session.fork 方法 | +| repair(`interruptedTurnClosers`) | index.ts:25 导出;repair.ts;persistence 加载时闭合 crash 孤儿 turn(TurnEndReason `interrupted`,types.ts:120) | **已覆盖**(间接)——修复产物就是 `turn/end interrupted` 事件,随 history 透传到达;修复动作本身是 host 内部行为,无需接口 | +| surface(`foldSurface`/`SurfaceOp`/replace) | index.ts:26-27 导出;types.ts:262-309(SurfaceOp append/replace,compaction 用) | **已覆盖**——design §5「优先复用 core foldSurface」;replace 语义随事件透传,client fold 天然处理 compaction | +| `deriveMessages`/`requestHeader` 折叠 | index.ts:469/432 | **有意不做**(server 不代折)——「历史=事件重放,client 单一 fold」拍板(README 拍板表) | + +## 2. agent 域(packages/core/agent) + +| 能力 | core 事实 | 契约状态 | +|---|---|---| +| `agent/created` / `agent/disposed` | types.ts:147/156(注册/注销时 emit) | **已覆盖**——HostFrame added/removed 的 agent 侧对应(HostFrame 语义=「session 出现/消失」,v1 agent 与 session 同生命周期) | +| `agent/status`(idle⇄running→disposed) | types.ts:165;AgentStatus types.ts:47 | **已覆盖**——HostFrame `host/session-status` running 布尔。三态压两态是拍板(冷 session 拍板:attach 不暴露,disposed 即 removed) | +| `agent/queued`(入箱通知) | types.ts:175 | **有意不做**——prompt() 返回 accepted 即达意;入箱细节属 host 内部(CQRS:渲染靠 session 事件) | +| send/steer/cancel 原语 | types.ts:103/110/127 | **已覆盖**——prompt(mode: queue/steer)、cancel 1:1 映射(design §3.1) | +| `inject`(注入上下文不跑模型) | types.ts:119 | **漏判 →【L4】**——core 第三条输入原语,契约只映射了 send/steer;UI 场景(如「贴文件给 agent 但不触发回复」)v1 是否需要待裁决 | +| `whenIdle()` | types.ts:130 | **有意不做**——client 由 `host/session-status` 事件驱动,不需要 promise 面 | +| `AgentRegistry.create/resume`(CreateAgentOptions:sessionId/meta{cwd,parentSession,seedLength}/seed/agentOptions/setup) | index.ts:44-90、:352/:371 | **已覆盖**(部分)——sessions.create 走此路(cwd 已在契约入参);**meta.parentSession/seed 是 fork/spawn 用的**,与【L3】同源,client 侧 v1 不透出 | +| `AgentRegistry.get/list/roots/isOwnedBy` | index.ts:530/550/560+/543 | list/get **已覆盖**(sessions.list + running);`roots()`/`isOwnedBy`(运行时归属树)**漏判 →【L1】共同体**——UI 要画 subagent 树需要谱系,见 L1 处置提案 | +| initiator scope(`withInitiator`/`initiator()`) | index.ts:288/:258;RFC 2026-07-15-agent-initiator-scope | **有意不做**——进程内 AsyncLocalStorage 机制,本质不可序列化,不属 wire 契约;UI 需要的「谁创建了谁」由持久谱系(L1)承担 | +| 扩展 seam 事件(pre-step/prompt-submit/request/session-prefix/step-result/post-step/request-error/turn-continuation/turn-stop、agent/error) | types.ts:204-311 | **有意不做**(对客)——插件扩展 seam,是 host 进程内 waterfall/serial 钩子;durable 后果已进 session log 透传(如 prompt/blocked、turn/end error)。`agent/error` 的无 turn 位置失败 →【L5】边缘:live-only 诊断无 session 事件时 client 不可见,待裁决是否要 stream/error 级 host 通知 | + +## 3. subagent 包组(packages/subagent/*:spawn/fork/inprocess/subprocess/acp + tool-subagent) + +| 能力 | core 事实 | 契约状态 | +|---|---|---| +| 子 agent 创建(spawn=白纸 / fork=继承 turn 前缀) | subagent/src/types.ts:52-101(StartRequest:parent 必填、读 parent.session.header 拿 cwd+stamp parentSession,:56-61);SubagentRun.id=子 session id、`parentSession` 记录 parent(:148-154) | **对 UI 的可观测面 = 普通 session**:子 agent 就是 registry 里一个 live agent + store 里一个 session,mux/hostEvents 天然看得见。**缺的只是谱系标注 →【L1】**(host/session-added 无 parentSessionId,UI 无法区分「用户开的」和「agent spawn 的」) | +| 运行时 run 面(result promise/dispose/sendMessage?/resume?) | types.ts:148-185 | **有意不做**——run 生命周期属 parent agent 的工具调用(tool/call `task` → tool/result),已随事件透传;client 不直接操纵子 run | +| stopReason / structured output | types.ts:109-141 | **已覆盖**(间接)——结果进 parent 的 tool/result 透传 | +| ACP/subprocess 远程子 agent | subagent-acp、subagent-subprocess | **同上**——远程 run 无本地 session;parent 侧 tool 事件已覆盖其可观测面;v1 不做远程子会话浏览(不做清单精神,未明文 → 盘点顺手补进 §6 不做清单措辞即可,不算漏判) | + +## 4. ctx.tasks 后台任务(packages/tasks/tasks) + +| 能力 | core 事实 | 契约状态 | +|---|---|---| +| `tasks.list/get/wait`(TaskSnapshot:kind/label/status/detail/output/startedAt/finishedAt) | index.ts:153/167/226/326 | **漏判 →【L6】**——运行时全局后台任务注册表(bash 后台、subagent run 等挂在这),UI「后台任务列表」是常见诉求;但 v1 UI 范围未含此面板,处置建议偏「留接缝」 | +| `onTaskDone` 完成通知 | index.ts:283 | 同【L6】——若做任务面板需 HostFrame 或独立流;不做面板则无需 | +| 任务归属(owner: Agent、session-scoped 授权) | index.ts:44-48(TrackedTask.owner)、list(caller) 过滤 | 同【L6】附注:core 已有按 agent 过滤语义,接口若做可直接映射 | + +## 5. 审批与问答(packages/ui/user-approval、user-interaction) + +| 能力 | core 事实 | 契约状态 | +|---|---|---| +| `approval/request` waterfall(待决问题推给 answerer 链) | user-approval/src/index.ts:23-32;ApprovalOutcome :91(allowed-once/rejected/cancelled/unavailable,fail-closed) | **有意不做(v1)**——design §6 不做清单明文「审批/问答域」。**结构性事实需记录**:这是 client→server 反向要答案的面,纯 mux 单向流装不下,将来要么复用 unary(poll/answer 方法对)要么加双向帧——接缝形态建议在拍板时一并定 | +| `approval/asked` / `approval/decided` session 审计事件 | index.ts:35-60(log-only,merge into SessionEventMap) | **已覆盖**——merge-extensible 事件随 mux 透传(design §3.3 未知类型分支),UI 已可"看到"审批发生过;缺的只是"参与决定"(上行) | +| `approval/policy`(ask/never,session 内覆写) | index.ts:108 + SessionEventMap merge | **已覆盖**(事件透传);改 policy 的命令面归审批域一并 v2 | +| `ctx.userInteraction.ask`(AskUserQuestionRequest/Answer,单 provider 注册制) | user-interaction/src/index.ts:43-71(registerProvider 单占 :96-107) | **有意不做(v1)**——同上不做清单。附注:单 provider 语义 ⇒ Web client 接管问答时要经 host 侧代理 provider 中转(provider 在 host 进程注册、答案从 wire 上取),这决定将来接缝在 impl 不在契约新增语义 | + +## 6. workflow / todo / skill / compact 可观测面速查 + +| 包 | core 事实 | 契约状态 | +|---|---|---| +| todo(packages/todo) | 唯一持久面 = `todo/write` session 事件(types.ts:246) | **已覆盖**——透传 + client fold last-write-wins(§1.1 已列) | +| compact(packages/compact) | 产物 = surface `replace` 事件 + `context/message`(SurfaceOp types.ts:292-294) | **已覆盖**——透传;client fold 处理 replace 即正确渲染压缩后视图(design §5 foldSurface 复用) | +| skill(packages/skill) | 装载产物 = `context/message`(skill 内容注入)+ tool/call 事件 | **已覆盖**(透传);skill 目录浏览/管理面 v1 无 UI 诉求,**有意不做**(catalog 工具是模型面不是 client 面) | +| workflow(packages/workflow) | worker-thread 引擎;对 session 的可观测面 = 其 tool/call、tool/result + 子 agent session(同 §3) | **已覆盖**(间接);workflow 进度独立流 v1 不做,与 L6 任务面板同性质 | +| guard(packages/guard) | loop-hygiene 插件,干预结果落 session 事件(steering/turn-stop) | **已覆盖**(透传,无独立面) | + +## 7. LLM 层(packages/llm) + +| 能力 | core 事实 | 契约状态 | +|---|---|---| +| usage/token 统计 | `assistant/message.usage?: TokenUsage`(session types.ts:226,与消息同travel);request/header 里 config | **已覆盖**——透传即达(§1.1 已列);聚合统计(session 累计 token)是 client fold 的算术,不需要 server 接口 | +| adapter 注册面(provider 现值) | LlmService 注册表;AgentOptions.provider/model(agent types.ts:21-26) | **已覆盖**——host.describe 的 provider/model 现值(20:02 拍板);**adapter 列表枚举**(UI 下拉「可用 provider 有哪些」)**漏判 →【L7】**:describe 只给现值不给候选集,「模型切换」在不做清单但「枚举可选项」是它的读前提,处置建议留接缝 | +| 模型切换(运行中改 provider/model) | agent/request waterfall 可换 config | **有意不做**——design §6 不做清单明文 | + +## 漏判清单(已裁决,2026-07-19 21:13:L1/L2/L5 合入 design.md v1.5;L3/L4/L6/L7 类型预留 design §8;审批/问答提案整体采纳并入 §3.4) + +| # | 缺口 | core 事实(file:line) | 建议处置 | 一句话理由 | +|---|---|---|---|---| +| **L1** | **subagent/fork 谱系不可见**(已知条目):host/session-added 与 SessionSummary 均无 parent 信息,UI 无法画子 agent 树、无法区分用户开的还是 agent spawn 的 | SessionHeader.parentSession/seedLength(session types.ts:47/52);fork 时写入(session index.ts:854);spawn 时 stamp(subagent types.ts:56-61 REQUIRED parent) | **进 v1 契约**。补法:① `host/session-added` 帧加可选 `parentSessionId?: SessionId`(从 `session.header.parentSession` 读,无谱系时缺省);② SessionSummary 同补 `parentSessionId?`(冷 session 列表也要能画树;jsonl 后端从持久化 header 读)。运行时归属(registry owner/roots,agent index.ts:543/560)**不透出**——durable 谱系已够 UI 用,运行时树是进程内概念 | 字段 core 已持久化、读取零成本;缺它 UI 树状视图无法做,且 additive 可选字段不破坏现契约 | +| **L2** | session 元数据有入无出:create 收 `cwd` 但 list/history 均不回吐;createdAt 同 | SessionHeader.cwd/createdAt(session types.ts:43/45) | **进 v1 契约**(顺手):SessionSummary 加 `cwd?: string`。createdAt **不加**——v1「mtime=updatedAt」拍板已覆盖排序诉求,再加是第二时间语义 | 多 session 不同 cwd 时列表页无法标注工作目录;一字段事,与 L1 同一次 SessionSummary 改动 | +| **L3** | fork 无契约方法:core 有完整原语+类型化错误码,opencode 有同款端点 | SessionStore.fork(session index.ts:843-857);SessionForkErrorCode :556-562;turn/end 边界 :890-895 | **留接缝**:v1 不加 `session.fork`(UI 无 fork 按钮诉求);接缝=将来 RpcMethodMap 加 `'session.fork'` 一行 + SessionForkErrorCode 并入 RpcErrorCode 按域扩展,零结构变化 | 形态 B 下加方法是纯 additive;现在加则要陪审 UI 交互(fork 点选择、边界约束提示)不值 v1 | +| **L4** | `agent.inject` 第三输入原语无映射:prompt 只有 queue/steer | Agent.inject(agent types.ts:119);idle 时一次性 turn 语义 types.ts:84-89 | **留接缝**:prompt 的 `mode` union 将来加 `'inject'` 即可(merge 进闭合 union + impl 分发)。v1 Web UI 无「注入不触发回复」交互 | 三原语中 inject 是插件/自动化面(文件变更通知等 host 内部已在用);人机 UI 场景未出现,union 扩展零迁移 | +| **L5** | 无 turn 位置的 live 失败 client 不可见:`agent/error` 在 session log 无对应事件时(如 flush 失败、驱动崩溃)UI 只能看到 session 卡死 | agent/error emit(agent types.ts:311,"even when the error has no in-turn position") | **进 v1 契约**(轻量):HostFrame 加 `{ type: 'host/agent-error'; sessionId; message: string }`——只做诊断展示不做恢复语义 | 不加则「agent 停了但 UI 永远转圈」无解释渠道;一帧类型,Frame union additive | +| **L6** | 后台任务注册表无接口:bash 后台/长任务在 ctx.tasks,UI 任务面板无数据源 | tasks.list/get/wait/onTaskDone(tasks index.ts:153/167/226/283);TaskSnapshot :326 | **留接缝**:任务面板不在 v1 UI 范围;接缝=将来新 `tasks` 域(一域一文件 + RpcMethodMap 数行 + 完成通知并入 hostEvents 或独立流)。设计已天然支持新域(design §1「新域=新文件对+根接口一字段」) | v1 UI 无此面板;域级 additive 是本契约的标准扩展路径,无需预留字段 | +| **L7** | provider 候选集不可枚举:describe 给现值,UI「切换模型」下拉无数据源 | LlmService adapter 注册表;AgentOptions.provider/model(agent types.ts:21-26) | **留接缝**:模型切换整域在不做清单,枚举是其读前提,一起进将来的 provider 域;不单独提前 | 只读枚举脱离切换动作无用户价值;避免半个域 | +| — | 审批/问答:形态已拍板(2026-07-19 21:0x,mux 下行帧 + HTTP unary 上行),协议提案见下节,随本清单一并裁决 | 见下节逐条 file:line | 本轮定契约形状,实现可排后 | — | + +## 审批/问答域协议提案(已采纳并入 design.md §3.4;**21:19 修订**:QuestionAskId 取消、问题标识复用 RpcId——下文为原提案存档,以 design.md 为准) + +**已定**:请求下行 = mux 控制帧(稳定 id + session 锚点);回答上行 = 普通 unary(respond 带 id 回传)。不做双向帧/双工通道——SSE 本就是 server→client,回送有 HTTP。 + +### A. 方法与帧 + +```ts +// RpcMethodMap 增两行(key 按域单数 convention) +'approval.respond': ApprovalApi['respond'] +'question.respond': QuestionApi['respond'] + +export interface ApprovalApi { + /** 回答一个待决审批。outcome 只收 client 可给的子集(cancelled/unavailable 是 host 侧结局)。 */ + respond(input: { sessionId: SessionId; id: ApprovalRequestId; outcome: 'allowed-once' | 'rejected' }): + Promise> +} +export interface QuestionApi { + /** 整批回答一次 ask(core 事实:一次 ask 多题一个 answer,user-interaction index.ts:63-67)。 */ + respond(input: { sessionId: SessionId; id: QuestionAskId; answer: AskUserQuestionAnswer }): + Promise> +} + +// MuxFrame 增四帧(Frame 族,session 锚点) +| { type: 'approval/requested'; sessionId: SessionId; id: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } +| { type: 'approval/resolved'; sessionId: SessionId; id: ApprovalRequestId; outcome: ApprovalOutcome } +| { type: 'question/requested'; sessionId: SessionId; id: QuestionAskId; questions: AskUserQuestionItem[] } +| { type: 'question/resolved'; sessionId: SessionId; id: QuestionAskId; outcome: 'answered' | 'cancelled' } +``` + +- requested 载荷 = core 类型透传:审批帧字段即 ApprovalRequest 去 agent(换 sessionId 锚)去 signal(user-approval index.ts:190-212);问题帧直接透传 `AskUserQuestionItem[]`(user-interaction index.ts:29-41,model 自带题内 id)。 +- **resolved 帧是收敛面**:多 client 同看一 session 时,别人答掉/超时取消/policy 决掉,观察方靠 resolved 撤卡片;自己答成功也等 resolved 帧统一收敛(respond 的 accepted 只表示受理)。 + +### B. id 纪律(按现行纪律推导) + +- **审批:复用 core `ApprovalRequestId`**(已 brand,user-approval index.ts:76)——SessionId 同款先例:type-only import、id 全部源自 server(requested 帧),client 只回传。 +- **问答:host 造 `QuestionAskId`**(api 层新 brand:`Branded<'question-ask-id'>`)——core 事实:user-interaction **无 request 级 id**(AskUserQuestionRequest 只有题内 model 自给的 string id,index.ts:29-33),host 代理 provider 受理 ask() 时 mint UUID。与 cursor 占位不同(那是未实现故不 brand),此 id 实装即进签名,按「opaque 跨界 id 必 brand」仓规上 brand。 + +### C. 竞争语义 + +- **先到先赢,host 内存 pending 表是唯一裁判**:一个 id 只被 settle 一次。竞争方:client respond vs `signal` abort(tool 取消/step 中止 → cancelled,user-approval index.ts:206-211)vs 另一 client respond。policy `'never'` 在 answerer 链之前解决(index.ts:100-108),**requested 帧根本不发**——天然对齐。core 无审批超时(signal 是唯一撤回通道),不发明。 +- **迟到/重复回答**:RpcErrorDetailsMap 增两码——`'approval-not-pending': { id: ApprovalRequestId }`、`'question-not-pending': { id: QuestionAskId }`(分域两码不合一:details 类型不同,且按域扩展是既定纪律)。 + +### D. 刷新恢复(推荐:subscribed 基线重放) + +**推荐**:client 重开 mux 后,host 在每个 session 的 `session/subscribed` 帧之后立即重放该 session 仍 pending 的 `*/requested` 帧(来源=host 内存 pending 表)。理由:单一事实源(全走 mux),与 lastSeq 补缝流程同构,client 无需第二条 bootstrap 路径做 join。**不推荐** host.describe 带 pending 列表:跨 session 聚合 + 与流竞态,两处真相。 +不能从 history 推 pending(审批虽有 `approval/asked`/`decided` 审计事件,但 crash 后 asked-without-decided 是永久悬案——pending 真相只在 host 内存,重放帧无此歧义)。 + +### E. core 事实对齐(盘点批3 + 本批补核) + +| core 事实 | file:line | 提案对齐 | +|---|---|---| +| approval 走 policy → answerer waterfall 链,fail-closed unavailable | user-approval index.ts:23-32、:100-108 | host 侧注册一个「wire answerer」进链:收 approval/request → 发 requested 帧 → 等 respond/abort → 返回 outcome。链上仍可有其他 answerer(组合语义不变) | +| `approval/asked`/`decided` 已是 session 审计事件(log-only) | index.ts:35-60 | 保持透传不动;帧是 live 控制面、事件是 durable 审计,职责分离不算重复造 DTO | +| ApprovalOutcome 四值闭合 union | index.ts:91 | resolved 帧透传全集;respond 入参窄化为 client 可给二值 | +| userInteraction 单 provider 注册制 | user-interaction index.ts:96-107 | host 代理 provider 是唯一注册者(盘点 §5 已注),多 web client 竞争在 wire 层由 pending 表裁决,不违单 provider | +| ask() 不落 session 日志 | user-interaction 全文无 SessionEventMap merge(本批 grep 核实) | 问答无审计事件可依赖 → requested/resolved 帧是问答唯一可观测面,D 的内存重放是唯一恢复路径(自洽) | +| 一次 ask 多题、整批回答 | index.ts:43-67 | respond 收整个 AskUserQuestionAnswer,不拆单题方法 | + +**汇总(裁决后)**:L1/L2/L5 **已合入 v1.5**(SessionSummary.parentSessionId?/cwd?、session-added 帧 parentSessionId?、host/agent-error 帧);L3/L4/L6/L7 **类型已预留**(design §8 完整签名,不进 map——fail loud 优于 not-implemented 兜底);审批/问答提案(下节)**整体采纳**并入 design §3.4。本文件转为盘点存档,后续契约变更以 design.md 为准。 diff --git a/missions/tasks/20260719-1902-apiproxy-api-design/design.md b/missions/tasks/20260719-1902-apiproxy-api-design/design.md new file mode 100644 index 0000000000..ad2fe70203 --- /dev/null +++ b/missions/tasks/20260719-1902-apiproxy-api-design/design.md @@ -0,0 +1,413 @@ +# apiproxy 统一 API 层 · 设计(v2.0:四象限 RPC 消息模型) + +> 2026-07-19 主会话起草;19:48 Q1–Q8 修订;20:15 RPC 命名体系(RpcResponse);20:41 形态 B 终选;20:56 RpcError 强类型化;21:04 rpcId+信封两层;21:13 整轮裁决(L1/L2/L5+审批问答+§8 预留);21:19 问答 id 复用 RpcId+帧信封对称;**22:00-22:3x 四象限统一消息模型定型(v2.0)**:通道与消息解耦、四具名判别 union、签名显式收窄形 RpcRequest

、respond 重建模为 client-response、泛型工具改名 RequestPayload/ResponseValue。拍板全记录见同目录 README.md。 +> 定位:`packages/host/apiproxy` 对 Web / Electron / TUI 暴露的**唯一契约层**;web client 的 HTTP/SSE 只是它的一种承载。 + +## 0. 总原则(已拍板) + +1. **TS interface 是权威契约**,HTTP/SSE 是载体。同进程形态(Electron main、测试)直接注入 handler 当 fetch,跨进程走真 HTTP——签名完全一致(opencode 同构点,已经调研证实其 fetch 面连 Worker RPC 边界都能过)。 +2. **透传 core 数据结构**:wire 上的事件/消息/内容块就是 `SessionEvent` / `ContentBlock` 等 core 类型,不自造第二套 DTO。类型经 `import type` 依赖链直达浏览器。 +3. **RPC 风格**,按业务域分组,一域一对文件(`sessions.ts` 类型 + `sessions.schema.ts` zod)。 +4. **错误 = 类型化 RpcResponse 信封**(`RpcResponse` + `RpcError`),方法不 throw 业务错误。 +5. **zod 双向校验**(C→S 命令、S→C 事件都 parse),schema 用 `satisfies z.ZodType` 锚定编译期防漂移——形态 B 下 `T` 是 infer 派生类型(`satisfies z.ZodType>`),锚定等价可行,代价(报错信息展开为字面量结构)已被用户接受(2026-07-19 20:41);不 passthrough。可 dev-only 开启(开关是实现细节不进签名)。**实现修正(21:55,W1 落地发现)**:仓库 `exactOptionalPropertyTypes` 与 zod `.optional()` 输出类型(`T | undefined`)不兼容,锚定统一写 `satisfies z.ZodType>`——`Wire` 是深度「| undefined」宽化(api/rpc.schema.ts 定义并注释),JSON wire 上缺席与 undefined 同形故不损失校验语义;透传宽分支(SessionEvent/ContentBlock/帧 union/RpcError discriminatedUnion)与 brand id schema 用显式 cast + 注释。 +6. **历史 = 事件重放**:一套 fold(client 侧),历史分页拉 + live 增量贴同一条代码路径;server 不做物化快照第二套。 +7. **重连 = 重建**:v1 不实现续传 cursor(签名留可选 `since`),断线重连一律重开流 + 重拉 history(opencode 同款)。 + +## 1. 分层与文件布局 + +``` +packages/host/apiproxy/src/ + api/ ← 契约层(纯类型 + zod schema,浏览器可 import) + index.ts ← export interface ApiProxy { sessions, host, events } + sessions.ts ← SessionsApi 接口(方法签名 = 出入参事实源) + sessions.schema.ts ← 上者的 zod schema(一域一对文件,同名 .schema.ts 后缀) + host.ts / host.schema.ts + events.ts / events.schema.ts ← 流签名 + 帧类型 + 帧 schema + approvals.ts / approvals.schema.ts ← 审批域(v1.5,§3.4) + questions.ts / questions.schema.ts ← 问答域(v1.5,§3.4;问题标识复用 RpcId,无新 brand) + rpc.ts ← RpcResult / RpcError / RpcId / 窄形 RpcRequest·RpcResponse / 四具名 wire 全形 + RpcMessage / RpcReceipt + rpc-map.ts ← RpcMethodMap + RequestPayload / ResponseValue + impl/ ← Node 侧实现(boot harness core、实现 ApiProxy) + fetch/ + handler.ts ← toFetchHandler(api): (Request) => Promise + client.ts ← IApiClient + AbstractApiClient + InProcessApiClient(§4.1 类体系) +``` + +- `api/` 零 Node 依赖;`impl/` 只在 host 进程加载;client 包只 import `api/` + `fetch/client.ts`。 +- 新域(provider、approvals…)= 新的一对文件 + `ApiProxy` 根接口一个字段。 + +### 依赖方向 + +``` +apps/dsc ──► apiproxy/impl ──► harness core + │ implements + ▼ + apiproxy/api(契约,唯一权威) + ▲ import type + AbstractApiClient 子类 +web-runtime ──► apiproxy/fetch/client ──► HTTP or 注入的 handler +``` + +### id 纪律(branding,2026-07-19 拍板) + +- **`SessionId`:复用 core 的 branded 类型**(type-only import 自 dsh-session,浏览器零运行时)。契约中所有 sessionId 一律 `SessionId`。id 全部源自 server 响应(list/create),client 只回传,无需构造器;zod parse 在 shape 校验后一次 cast 上 brand(每个 `.schema.ts` 一个 cast 点)。brand ≠ 存在性校验——`session-not-found` 仍由 impl 判。 +- **cursor 不 brand(2026-07-19 20:00 拍板)**:v1 未实现的预设占位不提前上 brand,签名用裸 `string`(`cursor?: string`);将来实现分页时再决定是否 brand。 +- **`RpcId` brand(2026-07-19 21:04 随信封拍板;21:19 语义扩展)**:opaque + 跨界往返 → brand;与 cursor 占位不同,v1 实装即进签名。mint 方 = **交互发起方**:unary 调用由 client mint(应答只回显);server 发起的交互由 host mint——问答 ask 的问题标识、每条 SSE 帧的推送标识(帧信封)。单一品牌单一构造函数 `RpcId()`(core `SessionId()` 先例),谁发起谁构造。 +- **事件内部 id 免费**:`CallId` 等随 `SessionEvent` 透传,core 已 brand,本层不重复定义。 +- **seq 一族有意不 brand**(`beforeSeq` / `lastSeq` / `since` 值):非 opaque——要做大小比较、且从透传的 `event.seq`(core 裸 `number`)派生;只在本层 brand 会逼每处派生 cast,与透传相抵。v1 仅 seq 一族数字无混用风险;出现第二族(revision/generation)再上 BrandedNumber。 +- 闭合 union(`mode`、错误码)不 brand——union 是更强的约束。 + +### 命名 convention(2026-07-19 20:15 拍板,20:41 随形态 B 终选改写) + +- **方向在消息 tag 上可辨识**(22:00 四象限重写):wire 全形 = `ClientRequest`/`ServerResponse`/`ServerRequest`/`ClientResponse` 四具名判别 union(§2);签名窄形 = `RpcRequest

`/`RpcResponse`;payload 派生 = `RequestPayload`/`ResponseValue`。帧是 ServerRequest 的 payload(具名帧 union 保留,§3.3)。 +- **禁重复内联**(20:15「每方法具名/签名禁内联」拍板随 B 放宽):参数/返回的字面量结构只住方法签名一处(事实源),签名之外——handler、client、store、测试——一律 `RequestPayload` / `ResponseValue` 泛型引用,不复写字面量、不另起具名平铺类型。 +- **空 request 写空字面量 `{}`**:将来加字段就地扩展签名,泛型引用处零迁移。 +- **`AbortSignal` 不进 input**:input 定义为 wire 载荷,与 schema 一一对应;signal 不可序列化,混入会迫使 schema omit 字段、破坏 `satisfies z.ZodType` 锚定。流方法签名为 `(input, signal: AbortSignal)`——signal 是本地控制参数,独立第二参。 +- **信封 = `RpcResponse`**(rpc.ts):unary 一律 `Promise>`;`T` 是业务返回结构,信封管成败。 +- **RPC map key 用域单数**:`session.list` / `host.describe` / `events.mux`(events 本身无单复),wire 路径同步 `/api/session.list`。单复数经用户授权由设计层定(2026-07-19 20:30)。 +- **schema 命名按 map key 推导**:`sessionListRequestSchema` / `sessionListValueSchema`(住 `<域>.schema.ts`),锚定对应泛型引用(见 §0.5)。 +- **消息层/业务层两层,签名显式感知窄形**(21:04 两层分离拍板 → 22:00 四象限重写):wire 全形=四具名判别 union(§2),业务 payload 纯净内嵌;域接口签名收/吐窄形 `RpcRequest

`/`RpcResponse`(rpcId 显式,业务 payload 内不混 rpcId);全形补全(type tag/method)收口在 fetch 载体层。 + +### RPC map:函数签名即事实源(2026-07-19 20:41 终选形态 B) + +**方法签名是唯一权威**:接口方法的参数/返回结构直接内联写在签名里;`RpcMethodMap` 登记方法本身;Request/Response 一律经条件类型从签名反推,任何签名之外的地方只引用泛型。 + +```ts +// rpc-map.ts —— map 只登记 client-request 方法(respond 是 client-response 不在此,22:00 四象限) +export interface RpcMethodMap { + 'session.list': SessionsApi['list'] + 'session.create': SessionsApi['create'] + 'session.history': SessionsApi['history'] + 'session.prompt': SessionsApi['prompt'] + 'session.cancel': SessionsApi['cancel'] + 'host.describe': HostApi['describe'] +} +// 22:1x 撞名重命名(wire 四具名占用原名 ClientRequest/ServerResponse): +export type RequestPayload = Parameters[0]['payload'] +export type ResponseValue = + Awaited> extends RpcResponse ? T : never +``` + +- map key 即 wire 路径段(`POST /api/session.list`),`toFetchHandler` / `AbstractApiClient` 对 map key 类型安全机械遍历。 +- 流方法不进 `RpcMethodMap`(不是 unary RPC):`events.mux` / `events.host` 的 input 结构同样内联在签名,帧类型是具名 union(§3.3)。 +- **平铺具名 Request/Response 类型删除**(非降级为派生别名):别名是同一事实的第二个名字,与「任何地方都引用泛型」相抵;zod 直接锚 `RequestPayload<'session.list'>`,不需要中间名。 +- 备选未采用:形态 A(类型对 map,具名 interface 为事实源 + map 登记类型对),2026-07-19 20:30 曾并排呈案,用户终选 B。 + +## 2. RPC 消息模型:四象限统一信封(2026-07-19 22:00 定型,推翻 21:04「签名不感知信封」) + +**通道与消息解耦**:HTTP = client→server 物理通道,SSE = server→client 物理通道,仅此而已。逻辑消息独立于通道,每个 wire 消息统一带 `initiator`(谁发起)× `kind`(request/response)——四象限:① client-request(经 HTTP body)② server-response(经 HTTP 应答,回填①的 rpcId)③ server-request(经 SSE 帧,server mint——审批/问答 requested 即此类)④ client-response(对③的应答,物理经 HTTP 发出,逻辑 kind=response、回填③的 rpcId)。kind/direction 在消息上而非靠通道推断——将来「client-request 的 response 走 SSE 送回」(订阅型/长回答)只是 ② 换了通道,信封不变。 + +```ts +// api/rpc.ts + +/** 消息关联 id:request=发起方 mint(谁发起谁构造,RpcId() 照 core SessionId() 先例);response=回填对应 request 的 rpcId,不 mint 新 id。 */ +export type RpcId = Branded<'rpc-id'> +export type RpcInitiator = 'client' | 'server' + +/** 业务成败结果(原 RpcResponse 更名:RpcResponse 现在是消息层名字)。 */ +export type RpcResult = { ok: true; value: T } | { ok: false; error: RpcError } + +/** 签名层窄形·请求(两个方向通用):rpcId 显式进签名,kind/initiator/method 由调用位置决定、载体层补全。 */ +export interface RpcRequest

{ + rpcId: RpcId + payload: P +} + +/** 签名层窄形·应答(两个方向通用):rpcId 恒为对应 request 的回填。 */ +export interface RpcResponse { + rpcId: RpcId + result: RpcResult +} + +/** wire 全形 = 四具名类型的判别 union(22:1x 用户裁决字面四具名;判别子 = type 四字面量,initiator/kind 由 tag 自明不设冗余字段)。 */ +export interface ClientRequest { + type: 'client-request'; rpcId: RpcId; method: string; payload: unknown +} +export interface ServerResponse { + type: 'server-response'; rpcId: RpcId; result: RpcResult +} +/** server 发起的消息:需应答的交互(approval/question requested,rpcId 稳定)与纯推送(session/event 等,rpcId 标识该次推送)共用此形——是否期待应答由 method 静态区分(22:1x 用户采纳设计层严格二分,不设第三 kind)。 */ +export interface ServerRequest { + type: 'server-request'; rpcId: RpcId; method: string; payload: unknown +} +export interface ClientResponse { + type: 'client-response'; rpcId: RpcId; result: RpcResult +} +export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse + +/** 载体回执(非 RpcMessage——属载体层,同「HTTP status 只表载体」纪律):承载 client-response 的 POST 的 HTTP 应答体。 */ +export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' } +``` + +**窄形与全形的关系**:`RpcRequest

` / `RpcResponse`(上文)是**域接口签名视角**的窄形(只含业务层必须感知的 rpcId+载荷);四具名是 **wire 权威全形**——载体层把窄形补全为全形(补 type tag 与 method),方向不靠通道推断。 +**撞名重命名(全链一致)**:wire 四具名占用 ClientRequest/ServerResponse 名字,rpc-map 的派生泛型工具改名——`ClientRequest` → **`RequestPayload`**(= `Parameters[0]['payload']`,提取 payload 穿过 RpcRequest 窄形)、`ServerResponse`/`ServerValue` → **`ResponseValue`**(= 返回的 `RpcResponse` 中 infer `T`;原两名合一,中间形无消费者)。 + +**四象限纪律**: +- **签名显式收信封窄形**(推翻「签名不感知」):unary 方法 `method(request: RpcRequest<{…}>): Promise>`——业务字面量仍只住签名(形态 B 不变),但包在 `RpcRequest<>` 里;impl 必须回显 `request.rpcId` 进返回的 `RpcResponse`(server 感知 rpcId 是模型要求,不因 HTTP 自带信道而省略)。流方法 yield `RpcRequest<帧>`(server-request 窄形)——可应答帧的 rpcId 是 client 回填应答所必需,必须暴露给业务层,不再有「载体层拆掉」一说。 +- **纯推送 = 不期待应答的 server-request,严格二分不设第三 kind(22:1x 用户采纳设计层方案)**:是否期待应答是 method/帧型的静态语义(登记表可查),不是每条消息的动态属性;接收方对两者处理本就相同(处理、不回)。迟到应答走既有 late-response 丢弃路径(RpcReceipt not-pending)。 +- **rpcId mint 规则**:client-request=client mint;server-request=server mint——**其 rpcId 是稳定逻辑请求 id**(ask 受理时 mint 一次,subscribed 基线重放时原样复用,client 以它回填应答);notify 的 rpcId 标识该次推送(每次发射新 mint)。response 一律回填、绝不 mint 新 id(对称性:谁发起谁 mint,应答方回填)。 +- **method 字段**:request 全形必带(unary=map key;帧载 request=帧的 type,与 payload.type 重复是「帧保持 fold 可直接消费」的代价);response 无 method(rpcId 已关联)。handler 仍校验 unary 的 path==method。 +- **client-response 的 HTTP 应答 = RpcReceipt 载体回执**,不是逻辑消息(response 不再有 response);迟到/重复应答 → `{accepted:false, reason:'not-pending'}` + server 日志,逻辑收敛面仍是 resolved 帧。**approval-not-pending / question-not-pending 两错误码随之删除**(其宿主——respond 作为 unary 方法的 RpcResult——已不存在)。 +- **rpcId 不承担 cursor**:durable 事件续传/补缝锚仍是透传的 `event.seq`;prompt 的 rpcId 额外经 `MessageSource` 透传进 `user/message`(provisional 关联机制,执行 v1 不做,§6)。 +- **zod 分层**:wire 全形 schema 一个(kind 判别 + method 合法性)+ 业务 payload schema 按 method/帧型分派,两级 parse。 + +### 2.1 错误模型(details 强类型化,20:56 拍板;22:00 随四象限删两码) + +```ts +export interface RpcErrorDetailsMap { + 'bad-request': { issues: z.ZodIssue[] } // zod 校验失败明细 + 'session-not-found': { sessionId: SessionId } + 'agent-busy': { reason: string } // core 拒绝原因透传 + 'internal': {} // 无结构化信息可给(message 已在信封) +} +export type RpcErrorCode = keyof RpcErrorDetailsMap + +// map 展开的分布式 union(非泛型 interface):code 是判别子,switch 后 details 自动窄化。 +export type RpcError = { + [C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] } +}[RpcErrorCode] +``` + +- **details 必填**(internal 显式 `{}`):与「必须真填」纪律互锁(rpc-compare 2026-07-19),漏填=编译错误;bad-request 放 zod issues、session-not-found 放 sessionId、agent-busy 放 core 拒绝原因。 +- **zod**:`rpcErrorSchema = z.discriminatedUnion('code', [...])` 逐支锚定;新码=map 加行+schema 加支。 +- transport 故障(断网、进程没起)由 fetch 载体抛异常,与业务错误两层不混;流正常结束=server 关流,中途错误以 `stream/error` 帧收敛,断线由载体抛异常。 + +## 3. ApiProxy 根接口 + +```ts +export interface ApiProxy { + sessions: SessionsApi + host: HostApi + events: EventsApi + /** 对 server-request 的应答入口(client-response,回填其 rpcId);不是域方法(22:00 四象限,§3.4)。 */ + respond(message: ClientResponse): Promise +} +``` + +### 3.1 SessionsApi(sessions.ts) + +```ts +export interface SessionSummary { + sessionId: SessionId + updatedAt: number // 持久化文件 mtime(v1 不建索引,list 时 readdir+stat) + running: boolean // attached agent 的 status;冷 session(未 attach)恒 false + parentSessionId?: SessionId // fork/spawn 谱系(session.header.parentSession 透传);根 session 缺省(v1.5,L1) + cwd?: string // session 工作目录(header.cwd 透传);未记录则缺省(v1.5,L2) +} + +// 方法签名即事实源(形态 B):参数/返回结构只住在这里, +// 其余一切引用 RequestPayload<'session.*'> / ResponseValue<'session.*'>。 + +export interface SessionsApi { + /** 列出已持久化 session(updatedAt 倒序)。v1 全量返回;cursor 留座不实现。 */ + list(request: RpcRequest<{ cursor?: string }>): Promise> + + /** 创建新 session(并创建对应 agent,空闲待命)。 */ + create(request: RpcRequest<{ cwd?: string }>): Promise> + + /** + * 读取历史事件窗口,**页边界对齐消息边界**:一页 = 整数条消息所辖的全部原始事件 + * (含其 chunk / tool 事件),绝不从一条消息中间截断。尾页(beforeSeq 缺省)额外 + * 含「进行中 partial」——最后一条未定稿消息已有的 chunk 事件。 + * 返回仍是原始 SessionEvent[] 透传,client 用统一 fold 重建。 + */ + history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): + Promise> + + /** 发送。content 直接用 core 的 ContentBlock[];mode 1:1 映射 queue→send、steer→steer。 */ + prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): + Promise> + + /** 停止:清两条 FIFO + abort 当前 step(agent.cancel 的 1:1)。 */ + cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> +} +``` + +(22:00 签名信封化:一切 unary 收 `RpcRequest

`、返 `RpcResponse`(rpcId 回填);业务字面量仍只住签名(形态 B),impl 感知并回显 rpcId。) + +```ts +``` + +- **冷 session 隐式 resume**:`history()` / `prompt()` 命中未 attach 的 session 时 impl 内部自动 resume/attach,client 无感;attach 与否不对客暴露(`running` 已覆盖 UI 所需)。实现注记:并发触发同一 session 的 resume 必须去重(`Map` 在途表,照 jsonrpc server `sessionCreations` 先例;rpc-compare 2026-07-19)。 +- `SessionSummary` 保持三字段最小面;title/eventCount/待处理计数等后续按需 additive。 +- prompt 幂等(commandId)v1 不做;input 加可选字段即是接缝。 +- history 分页实现注记:server 从尾向前扫 surface 消息事件(`user/message` / `assistant/message` / `steering/message`)计数到 `maxMessages`,在消息组边界切 `beforeSeq`;chunk 归属其定稿消息(`sourceEventSeqs` 锚定),页内事件保持原始 seq 序。 + +### 3.2 HostApi(host.ts) + +```ts +export interface HostApi { + /** + * host 一次性快照。空 request 用空字面量 `{}`(将来加字段就地扩展)。 + * version = host 应用(apps/dsc)package.json 版本;cwd = host 进程工作目录 + * (session 持久化与工具执行的根);provider/model = 新建 agent 未显式指定时 + * 生效的默认值,host 未配置显式默认则缺省(adapter 内部兜底); + * attachedSessions = 当前已 attach(有活 agent)的 session 数。 + */ + describe(request: RpcRequest<{}>): Promise> +} +``` + +- **不设协议版本**(2026-07-19 20:02 拍板):client/host 绑定发布,wire 兼容判断无消费者;将来若出现独立发布的 client 再引入 protocolVersion。 +- provider/model 形状对齐 core `AgentOptions`(可选裸 `string`,非 branded),透传原则;缺省表示 host 未配置显式默认(adapter 内部兜底)。 + +### 3.3 EventsApi(events.ts)——两条流 + +```ts +export interface EventsApi { + /** + * 全 session 聚合 mux 流。打开即对每个 attached session 发 subscribed 控制帧。 + * since:续传接缝,v1 不实现(传了也忽略);重连走「重开流 + 重拉 history」。 + * signal 是本地流控制参数,独立于 input(不上 wire)。 + * 22:00 四象限:yield 的是 server 消息窄形 { rpcId, payload: 帧 }——rpcId 必须暴露给业务层 + * (approval/question requested 帧的应答要回填它),不再有「载体层拆掉信封」。 + */ + mux(request: RpcRequest<{ since?: Record }>, signal: AbortSignal): AsyncIterable> + + /** host 级信息流:session 创建/销毁、运行状态翻转。空 payload 用 `{}`。 */ + host(request: RpcRequest<{}>, signal: AbortSignal): AsyncIterable> +} + +// ---- Frame 族(server→client 推送,具名 union 保留,不适用 infer) ---- + +export type MuxFrame = + | { type: 'session/event'; sessionId: SessionId; event: SessionEvent } // 核心:纯透传 + | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number } // 控制帧(lastSeq 保留:缝检测) + // ---- 审批/问答控制帧(v1.5,§3.4):requested 下行提问,resolved 收敛 ---- + | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } + | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome } + | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } // 问题标识=信封 rpcId(帧 payload 无独立 id) + | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' } + | { type: 'stream/error'; error: RpcError } + +export type HostFrame = + | { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId } // 谱系锚(v1.5,L1) + | { type: 'host/session-removed'; sessionId: SessionId } + | { type: 'host/session-status'; sessionId: SessionId; running: boolean } + | { type: 'host/agent-error'; sessionId: SessionId; message: string } // 无 turn 位置的 live 失败诊断(agent/error 无 session 事件时的唯一出口;v1.5,L5) + | { type: 'stream/error'; error: RpcError } +``` + +**透传纪律**:`session/event` payload 就是 core `SessionEvent`(自带 seq;`assistant/chunk` 原样过——token 流即事件流,无独立 delta 帧)。`SessionEventMap` 是 merge-extensible:client fold 对未知 type documented-default(计数忽略);事件 schema 在 union 层面留「合法信封 + 未知类型」分支,信封(seq/type 结构)仍严格——这不是字段级 passthrough。 + +**`subscribed.lastSeq` 的用途(已拍板保留)**:client 拉完 history 后对比 history 尾 seq 与 lastSeq,有缝(开流与拉历史之间 session 前进了)就再补一次 history;一个字段消掉一类竞态。 + +**tool presentation(已拍板:先透传,标注遗留)**:v1 卡片直接渲 `tool/call` / `tool/result` 原始 args/result;`presentCall/presentResult` 的 render intent(generic/terminal/diff/locations)在 Node 侧才有,后续以 additive 附件帧或旁挂字段引入,不动透传主体。 + +### 3.4 审批/问答域(approvals.ts / questions.ts,v1.5 采纳,2026-07-19 21:13) + +**形态(21:00 拍板;22:00 四象限重建模)**:审批/问答的 requested 帧 = **server-request**(rpcId=server mint 的稳定逻辑请求 id);client 的回答 = **client-response**(回填该 rpcId,物理经 `POST /api/respond` 发出,逻辑上是应答不是新调用——**不再是 unary 方法,不 mint 新 rpcId**)。`*/resolved` 帧是收敛面——多 client、tool 取消、policy 决掉都靠它撤卡片;client-response 的 HTTP 应答体是载体回执 `RpcReceipt`(见 §2),最终结局统一看 resolved。 + +```ts +// approvals.ts / questions.ts —— 应答 payload 形状(client-response 的 result.value 位) +/** 审批应答:outcome 只收 client 可给的二值(cancelled/unavailable 是 host 侧结局)。 */ +export interface ApprovalResponsePayload { + sessionId: SessionId + approvalId: ApprovalRequestId // core 审计关联(impl 对账 asked/decided 用);wire 关联以回填的 rpcId 为准 + outcome: 'allowed-once' | 'rejected' +} +/** 问答应答:整批回答一次 ask(core:一次 ask 多题一个 answer,不拆单题)。 */ +export interface QuestionResponsePayload { + sessionId: SessionId + answer: AskUserQuestionAnswer +} +``` + +- **respond 不进 RpcMethodMap**(map 只登记 client-request 方法):client-response 是对 server-request 的应答,wire 承载 `POST /api/respond`(单端点,body=ClientResponse 全形,rpcId 即路由键——host 从 pending 表查该 rpcId 属审批还是问答再按对应 payload schema parse)。ApiProxy 根接口相应无 approvals/questions 域方法;client 侧发应答走 `IApiClient.respond(message: ClientResponse)` 载体级入口(AbstractApiClient 实现,§4.1)。 +- **id 双层**:wire 关联 = requested 帧的 rpcId(server mint、重放复用、client 回填);`approvalId`(core `ApprovalRequestId` 透传)保留在审批 payload 内层供 impl 对账 durable 审计事件 `approval/asked`/`decided`——它是 core 已 brand 的透传非本层自造(21:19 拍板的不对称理由继续成立)。问答无 core id,payload 不含资源 id(rpcId 已足)。 +- **竞争语义:先到先赢**,host 内存 pending 表(keyed by rpcId)是唯一裁判,一个 rpcId 只 settle 一次。竞争方:client-response vs tool `signal` abort(→cancelled)vs 另一 client。policy `'never'` 在 answerer 链之前解决,requested 帧根本不发。core 无审批超时,不发明。迟到/重复应答 → `RpcReceipt {accepted:false, reason:'not-pending'}`(载体回执,非业务错误码——两个 not-pending 错误码已随四象限删除)。 +- **刷新恢复:subscribed 基线重放**——mux 重开后,host 在每个 session 的 `session/subscribed` 帧后立即重放该 session 仍 pending 的 `*/requested` 帧(**rpcId 原样复用**,来源=内存 pending 表)。单一事实源走 mux;不从 history 推(问答不落日志;审批 crash 后 asked-without-decided 是悬案)。 +- **impl 结构**:host 注册「wire answerer」进 `approval/request` waterfall 链(收请求→mint rpcId 发 server-request 帧→等 client-response/abort→返回 outcome,链上其他 answerer 组合语义不变);问答侧 host 代理 provider 是 `userInteraction.registerProvider` 的唯一注册者。`approval/asked`/`decided` 审计事件照旧透传——帧=live 控制面,事件=durable 审计,职责分离。 + +## 4. fetch 载体(RPC 映射,机械可推导) + +| 逻辑消息(四象限) | wire 承载 | +|---|---| +| client-request | `POST /api/`(即 `/api/session.list`,域单数),body=`ClientRequest` 全形 JSON | +| server-response | 上述 POST 的 HTTP 应答体,`ServerResponse` 全形 JSON,HTTP 200 恒定 | +| server-request / 纯推送 | SSE 帧:`GET /api/events.mux` / `GET /api/events.host`,`data:` = `ServerRequest` 全形 JSON | +| client-response | `POST /api/respond`(单端点),body=`ClientResponse` 全形 JSON;HTTP 应答体=`RpcReceipt` 载体回执 | + +- `toFetchHandler(api)`:unary 路径查方法 → 全形 zod parse(type/rpcId/method 结构 + path==method 校验)→ payload 按 method 分派 schema parse(拒收 = `bad-request`)→ 调 api 方法(传窄形 `RpcRequest

`)→ 回填 rpcId 封 `ServerResponse`;`/api/respond` → ClientResponse 全形 parse → rpcId 查 pending 表路由到审批/问答 → payload schema parse → 返回 `RpcReceipt`;流方法包 SSE Response,帧以 `ServerRequest` 全形发出(method=帧 type;可应答帧 rpcId=稳定逻辑 id,纯推送=每次新 mint)。 +- HTTP status 只表载体:404 路径不存在 / 400 body 非 JSON / 500 handler 自身炸;业务错误一律 200 + ServerResponse(RpcResult error 位)。 + +### 4.1 client 载体:AbstractApiClient 类体系(2026-07-20 shape-a + abstract-base 拍板;commit 893421d50 落地,取代 createApiClient 工厂形) + +**协议不变量住抽象基类,平台差异是两个继承切面**:抽象方法 `doFetch(url, init)`(传输)+ 可覆写 `onEnvelope`(观测)。 + +- **`IApiClient`(caller 视图,shape a)**:与 ApiProxy 同域树,但 unary 方法**收业务 payload 直传**——载体 mint rpcId 并包信封,**业务代码永不 mint**;需要本次调用 rpcId 的从返回 `RpcResponse` 回显里读。三者关系:ApiProxy = impl 侧实现的窄形签名契约;IApiClient = client 侧消费的 payload 直传视图;AbstractApiClient 桥接两者。域方法逐 key 从 RpcMethodMap 派生,map 加行即机械更新。 +- **`AbstractApiClient` 持有的协议路径**:`callUnary`(mint→tap→POST 全形→ServerResponse parse→**rpcId 回显校验**(不符 throw)→tap→吐窄形);`readSse`(streaming fetch 非 EventSource、`\n\n` 分帧、ServerRequest 全形 parse、tap、吐窄形 `RpcRequest<帧>`);`respond` 透传(rpcId 是回填不 mint);unary 超时 `AbortSignal.timeout`(默认 30s 构造参数可调,流不设超时);`resolveBase`(浏览器=同源 origin,无 location 环境=`http://dsh.internal` 假 authority)。`callUnary`/`openMux`/`openHost` 是 **protected virtual**——假载体(fixture)在协议层覆写,不再需要信封包装器。 +- **实例级 envelope 观测切面**:四象限全形均过 `onEnvelope`;基类实现=**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费者;实例持有防跨实例/测试泄漏)。观测者经 `subscribeEnvelopes(listener)` 批量订阅(收 `readonly RpcMessage[]`,返回退订函数);listener 异常隔离(观测不得反噬载体);无订阅者零缓冲成本。原 `onEnvelope` 构造选项与 `ApiEnvelopeTapEvent` 四支形状废除——tap 事件即 RpcMessage 全形本身,kind=type tag。rpcLog 调试面板降级为纯订阅者(连接主体身份取消)。 +- **子类表**:`InProcessApiClient`(apiproxy 本包;doFetch=注入的 `{fetch}` handler;**同构点新写法** `new InProcessApiClient(toFetchHandler(api))` 全程不过网络——dsc -p headless 即此);`WebApiClient`(web-runtime;doFetch=globalThis.fetch 同源);`FixtureApiClient`(web-runtime;协议层覆写四虚方法,自己就是假 server、帧 rpcId 由它 mint);将来 Electron IPC 桥=又一子类只换 doFetch。 + +## 5. web client 侧(web-runtime)架构 + +``` +WebApiClient(AbstractApiClient 子类,§4.1) + │ +ConnectionController ← boot 开两条流;断线指数退避重连;重连后对每个打开的 + │ session 重拉 history 重建(v1 无续传) +SessionFold(纯函数) ← SessionEvent[] → UI 树;历史与 live 同一 fold; + │ tool call/result 按 CallId 合并;优先复用 core foldSurface +Session/SessionManager ← 业务对象层(step-session 设计);React 经 uSES 订阅对象快照, + store 只承载跨视图展示态 +``` + +- 打开 session 主路径:开 mux(收 `subscribed.lastSeq`)→ `history()` 拉尾页 → 比对 lastSeq 补缝 → live 帧续贴。 +- 单客户端互斥(ClientSlot)v1 不实现:第二个页面各自收流,行为未定义但不崩。 +- unary 请求 client 侧设超时(`AbortSignal.timeout` 在 AbstractApiClient.callUnary 内,默认 30s 构造参数可调):浏览器 fetch 默认无超时,host hang 会让请求永久 pending(rpc-compare 2026-07-19)。流不设超时(长连接本性)。 + +## 6. 不做清单(v1) + +- mux `since` 续传实现(签名留座) +- rpcId 幂等去重的执行(rpcId 已全量上 wire 是其接缝,2026-07-19 21:04);provisional 转正的**关联机制已就位**(rpcId 经 MessageSource 透传进 `user/message`),client 侧转正逻辑 v1 不做 +- SessionSummary 索引(eventCount/title/待处理计数)、keyset 分页 +- ~~审批/问答域~~(v1.5 升格进契约,§3.4;**实现排期仍可后置**);provider 配置事务、模型切换(类型接缝见 §8) +- tool presentation 附件(§3.3 标注) +- ClientSlot、connectionGeneration/streamId fencing + +## 7. 裁决记录 + +三批问题(Q1–Q8 等)已全部拍板,无开放问题;全记录见 README.md 拍板表。opencode 对照结论见 `opencode-crosscheck.md`(重连砍 cursor 的建议已被采纳,即 §0.7)。core 覆盖度盘点与漏判裁决(L1-L7)见 `core-coverage.md`;L1/L2/L5 已合入本文(v1.5),L3/L4/L6/L7 类型预留见 §8。 + +## 8. 预留接缝类型(L3/L4/L6/L7,2026-07-19 21:13 裁决:类型写全,暂不实现) + +**纪律**:以下签名是将来实现时可直接照抄的定稿形状,但**不进 `RpcMethodMap`、不进 ApiProxy 根接口**——map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),优于 not-implemented 兜底码:后者要求每个方法实现「假在场」,让「契约有」和「能用」脱钩,违反 misconfiguration-fails-loud 家规。实现某条时:把签名抄进对应域接口 + map 加一行 + schema 加一对,即完成升格。 + +```ts +// ---- L3 fork(session 域;core 原语 SessionStore.fork 完整,错误码 SessionForkErrorCode) ---- +// map key(届时):'session.fork' +fork(input: { sessionId: SessionId; boundary?: number; childSessionId?: SessionId }): + Promise> +// RpcErrorDetailsMap 届时加:'fork-rejected': { code: SessionForkErrorCode }(core 五码透传: +// SESSION_NOT_FOUND/SESSION_NOT_LIVE/SESSION_ALREADY_EXISTS/INVALID_BOUNDARY/OPEN_TURN) + +// ---- L4 inject(prompt.mode union 扩展;core Agent.inject 第三输入原语) ---- +// 非新方法——现 prompt 签名的 mode 加一值: +prompt(input: { sessionId: SessionId; mode: 'queue' | 'steer' | 'inject'; content: ContentBlock[] }): + Promise> +// inject 语义:注入上下文不触发模型回复(idle 时 core 包一次性 turn);impl 分发 agent.inject()。 + +// ---- L6 后台任务面板(新 task 域;core ctx.tasks TaskSnapshot) ---- +// map key(届时):'task.list';域文件 tasks.ts / tasks.schema.ts +list(input: { sessionId?: SessionId }): // 缺省=全部;带 sessionId=按 owner agent 过滤(core list(caller) 语义) + Promise> // TaskSnapshot 透传 core(dsh-tasks) +// 完成通知(届时)HostFrame 加: +// | { type: 'host/task-done'; taskId: TaskId; status: 'completed' | 'killed' | 'failed' } + +// ---- L7 provider/model 枚举(host 域;与模型切换域一起实现) ---- +// map key(届时):'host.listModels'(独立方法,不并入 describe——describe 是轻快照, +// 枚举可能触发 adapter 查询,成本与缓存策略不同) +listModels(request: RpcRequest<{}>): + Promise> + +// ---- host 实例标识(ui-design 2026-07-19 22:1x 提出;v1 重连=重建故零影响,将来续传/缓存需要) ---- +// describe 返回加可选字段(届时): +// hostInstanceId?: string // host 进程每次启动 mint(uuid),client 据此察觉 host 重启、废弃本地缓存 +// 砍 protocolVersion(20:02)的连带缺口,实装时同批评估是否需要 brand。 +``` diff --git a/missions/tasks/20260719-1902-apiproxy-api-design/opencode-crosscheck.md b/missions/tasks/20260719-1902-apiproxy-api-design/opencode-crosscheck.md new file mode 100644 index 0000000000..48aa5aa25c --- /dev/null +++ b/missions/tasks/20260719-1902-apiproxy-api-design/opencode-crosscheck.md @@ -0,0 +1,17 @@ +# opencode 调研 × 本设计 对照(2026-07-19 19:10) + +> 调研证据:`../20260719-1902-opencode-api-research/findings.md`(19 处 file:line)。 + +| 维度 | opencode 实际做法 | 本设计(design.md) | 判断 | +|---|---|---|---| +| 同构面 | 确认是 fetch:server 导出不监听的 `app`,进程内直接把 `app.fetch` 传给 SDK;TUI(现为 TS+solid,Go 版已删)在 Bun Worker 里跑 server,fetch 序列化走 Worker RPC,worker 端还原 Request 再 `app.fetch`;只有 `--port` 才真监听 | `createApiClient(toFetchHandler(api).fetch)` 进程内同构 | **同向,验证通过**。Worker RPC 案例额外证明:fetch 载体连线程边界都能过,Electron/TUI 形态无忧 | +| 命令流 | `prompt_async` 立即 204,渲染全由事件驱动(CQRS) | `prompt` 返回 `accepted:true`,UI 全靠 mux 事件 | **同向** | +| token 增量 | `message.part.delta` 走事件总线,不走 prompt 响应流 | `assistant/chunk` 透传走 mux | **同向** | +| 事件总线 | 全局单 SSE,事件带归属字段客户端过滤;**断线无 cursor,重连靠 REST 全量 bootstrap 重建 store** | v1 无 `since` 续传(签名留座),重连=重开流+重拉 history | **同向**。下方建议已采纳,见 design.md §0.7 | +| 类型打通 | server-first:Effect httpapi Schema → OpenAPI → codegen SDK | TS interface 权威 + 手写薄 client,zod 双向校验 | **有意不同,维持**:monorepo 内无外部 SDK 消费者,codegen 链是负资产;zod 承担了他们 Schema 的校验职责 | + +## 建议(已采纳 → design.md §0.7) + +**mux 重连可以再砍一刀**:v1 干脆不做 `since` cursor——重连 = 重开流 + 各打开的 session 重拉 `history()` 重建(opencode 全量 bootstrap 同款,且我们 history 本来就是事件重放,重建代价 = 一次分页请求)。`since` 字段在签名里保留为可选,实现留空。这同时消解了 design.md §7 开放问题 3(since 走 query 还是 POST)——v1 根本不传。 + +代价:重连瞬间 UI 重建(闪一下)+ 多拉一次历史窗口。localhost 场景断线本来罕见,可接受。 diff --git a/missions/tasks/20260719-1902-opencode-api-research/findings.md b/missions/tasks/20260719-1902-opencode-api-research/findings.md new file mode 100644 index 0000000000..26ddd130c4 --- /dev/null +++ b/missions/tasks/20260719-1902-opencode-api-research/findings.md @@ -0,0 +1,72 @@ +# opencode「统一 API 层」调研(fetch 同构 / SSE 事件流 / OpenAPI 类型链) + +调研对象:`/weka-hg/prod/deepseek/permanent/ys/private/workspace/github/opencode`(HEAD `f5573281`,2026-07-19)。下文 file:line 均相对该仓库根。 + +**先纠正一个背景认知**:TUI 已不是 Go。Go/Bubbletea TUI 于 2025-11-02 被删(commit `f68374ad2` "DELETE GO BUBBLETEA CRAP HOORAY"),现 TUI 是 TypeScript + solid-js(opentui 渲染,`packages/tui`),与 server 同进程不同线程(Bun Worker)。全仓已无 `.go` 文件。 + +## 1. server 定义 + +**框架不是 Hono,是 Effect 的 `effect/unstable/httpapi`**(声明式 HttpApi DSL)。Hono 只在 `enterprise`、`function`(云端/SST 部分)用到(`packages/enterprise/package.json:25`、`packages/function/package.json:17`),本地 server 完全不用。 + +- 入口:`packages/opencode/src/server/server.ts` + - `import { HttpRouter, HttpServer } from "effect/unstable/http"`、`OpenApi from "effect/unstable/httpapi"`(server.ts:6-7)。 + - 监听:`Server.listen(opts)`(server.ts:73)→ `listenEffect` → `listenerLayer`(server.ts:100-116)用 `HttpRouter.serve(...)` + `NodeHttpServer.layer(() => createServer(), {port, host})`(server.ts:199-214,node:http 的 createServer)。端口回退:显式 0 时先试 4096 再随机(server.ts:118-123)。 + - 谁调 listen:`serve` 命令(`packages/opencode/src/cli/cmd/serve.ts:19`);TUI worker 仅在用户给了 `--port/--hostname/--mdns` 时调(`packages/opencode/src/cli/tui/worker.ts:56`)。 +- 路由声明与实现分离,全部在 `packages/opencode/src/server/routes/instance/httpapi/`: + - `groups/*.ts` = API 形状声明(路径、params、payload/success/error Schema、OpenAPI 注解),如 `groups/session.ts`、`groups/global.ts`、`groups/event.ts`。 + - `handlers/*.ts` = 实现(`HttpApiBuilder.group(Api, name, handlers => ...)`),如 `handlers/session.ts`、`handlers/event.ts`。 + - `api.ts` 组装:`RootHttpApi`(control/control-plane/global)+ `InstanceHttpApi`(config/file/session/provider/... 15 组)→ `OpenCodeHttpApi`(api.ts:56-80)。 +- **分层关系**:handler 是薄壳,业务在 Effect Service 里。如 session handler 注入 `SessionPrompt.Service` 后 `promptSvc.prompt(...)`、`promptSvc.cancel(...)`(handlers/session.ts:52、233、301);server.ts 只负责把几十个 service Layer(Session、Provider、Permission、MCP、LSP……见 routes/instance/httpapi/server.ts:8-57 的 import 清单)组进 HttpApi 运行时。 + +## 2. API 契约与类型:OpenAPI codegen(@hey-api/openapi-ts),非 hono/client + +- SDK 在 `packages/sdk/js`,生成链(`packages/sdk/js/script/build.ts`): + 1. `bun dev generate > openapi.json`(build.ts:15)——即 CLI `generate` 命令调 `Server.openapi()`(`packages/opencode/src/cli/cmd/generate.ts:10`),后者 `OpenApi.fromApi(PublicApi)` 从 Effect HttpApi 声明直接导出 OpenAPI 文档(server/server.ts:67-69)。**单一事实源是 server 端的 Effect Schema 声明**。 + 2. `createClient({input: openapi.json, output: src/v2/gen, plugins: [@hey-api/typescript, @hey-api/sdk(instance: OpencodeClient, paramsStructure: flat), @hey-api/client-fetch]})`(build.ts:19-72)。产物:`types.gen.ts`(全部请求/响应/事件类型)+ `sdk.gen.ts`(OpencodeClient 方法树,如 `sdk.session.prompt(...)`)+ `client/`(fetch 客户端)。 +- **自定义 fetch 注入**:`createOpencodeClient(config)` 的 `config.fetch` 就是 @hey-api client 的标配选项。v1 版 `packages/sdk/js/src/client.ts:33-42`(不传 fetch 则用包一层 `req.timeout=false` 的全局 fetch);v2 版 `packages/sdk/js/src/v2/client.ts:50-61`,另支持 `baseUrl`、`headers`、`directory`(转成 `x-opencode-directory` header,再由 request 拦截器改写成 query 参数,v2/client.ts:18-48、69-76)。 +- SDK 还提供 `createOpencodeServer()`:spawn `opencode serve` 子进程、等 stdout 打出 "opencode server listening" 再解析 URL(`packages/sdk/js/src/v2/server.ts:23-60`);`createOpencode()` = server + client 一把梭(v2/index.ts:10-20)。 + +## 3. fetch 同构:`Server.Default().app.fetch` 直调,零网络 + +server.ts 导出一个**不监听端口的 app 对象**:`Server.Default()`(lazy 单例,server.ts:56-65)——`HttpApiApp.webHandler().handler` 包成 `{fetch(Request): Promise}`,即 WHATWG Request→Response 纯函数。所有同构点都是把它塞进 SDK 的 `fetch` 选项: + +| 场景 | 位置 | 做法 | +|---|---|---| +| `opencode run`(非交互 CLI)| `packages/opencode/src/cli/cmd/run.ts:943-955` | `fetchFn = (input, init) => Server.Default().app.fetch(new Request(...))`,`createOpencodeClient({baseUrl: "http://opencode.internal", fetch: fetchFn})`——baseUrl 是假域名,仅用于构造 URL | +| `run` 交互本地模式 | run.ts:905-917 | 同上,传给 `runInteractiveLocalMode` | +| 插件运行时 | `packages/opencode/src/plugin/index.ts:141-146` | 给插件的 `client`:有真 server 时用 `Server.url`,**没有则 `fetch: (...args) => Server.Default().app.fetch(...args)`**——插件代码不感知区别 | +| TUI(默认模式)| 见下 | 跨 Worker 线程 RPC,仍不走网络 | + +**TUI 连接方式**(`packages/opencode/src/cli/cmd/tui.ts`):主线程起 `new Worker(worker.ts)`(tui.ts:210),server 核心跑在 worker 线程里。 +- 默认(无 `--port/--hostname/--mdns`,tui.ts:234):**不起 HTTP server**。transport = `{url: "http://opencode.internal", fetch: createWorkerFetch(client), events: createEventSource(client)}`(tui.ts:245-249)。`createWorkerFetch` 把 Request 序列化成 `{url, method, headers, body}` 经 Worker RPC 发过去(tui.ts:24-40);worker 端 `rpc.fetch` 还原成 Request 后 `Server.Default().app.fetch(request)`(worker.ts:31-49)。即**同构面是 fetch 签名,传输是 structured-clone RPC,非 socket**。 +- 显式要求网络暴露时:worker 端 `Server.listen` 起真 HTTP(worker.ts:54-57),TUI 改用真 URL + 默认 fetch + SSE(tui.ts:238-244)。 +- `opencode attach ` 连远端:纯 HTTP,`createOpencodeClient({baseUrl: args.attach, headers: auth})`(run.ts:349-355)。 +- desktop(Electron):renderer 通过 IPC 拿 server URL(`packages/desktop/src/main/ipc.ts:53-54`),走真 HTTP,不做 fetch 直调。 + +## 4. 事件流:单一全局 SSE 总线 + 实例级过滤流,重连靠全量 bootstrap + +两个 SSE 端点,都是 GET、`text/event-stream`: + +- **`GET /global/event`**(`groups/global.ts:85-92`)——**全局单总线**,TUI/桌面默认订阅这个。handler(`handlers/global.ts:33-52`)把进程级 `GlobalBus`(Node EventEmitter,`src/bus/global.ts:12-22`)的所有事件 + 10s 心跳推给客户端。事件从核心到总线的路径:Effect 内部 `EventV2.publish` → `EventV2Bridge` 监听后 `GlobalBus.emit("event", {directory, project, workspace, payload:{id,type,properties}})`(`src/event-v2-bridge.ts:36-46`),即事件自带 directory/workspace 归属,**由客户端按需过滤**,不分 session 订阅。 +- **`GET /event`**(instance 级,`groups/event.ts:7-28`)——按当前 instance directory/workspace **服务端过滤**(`handlers/event.ts:34-40`),首包发 `server.connected`,10s 心跳 `server.heartbeat`,实例销毁时发 `server.instance.disposed` 后终止流(handlers/event.ts:60-66、70)。 +- 事件类型全集在 `packages/schema/src/event-manifest.ts`(`Definitions` 聚合 30+ 模块,:64-82)。主要类别: + - v1 UI 面(TUI store 实际消费的,`packages/tui/src/context/sync.tsx` switch,:171-441):`message.updated`、`message.removed`、`message.part.updated`、**`message.part.delta`**、`message.part.removed`、`session.updated`、`session.deleted`、`session.status`、`session.diff`、`permission.asked/replied`、`question.asked/replied/rejected`、`todo.updated`、`lsp.updated`、`vcs.branch.updated`、`server.instance.disposed`。 + - v2 内核事件(`session.next.*`,schema/src/session-event.ts):`session.next.text.delta/started/ended`、`tool.called/success/failed/input.delta`、`reasoning.*`、`step.*`、`compaction.*`、`prompt.admitted` 等 ~40 种。 +- **断线重连 = 全量重取,无 cursor**。TUI SDK 层:SSE 断开后指数退避(1s→30s 封顶)无限重连(`packages/tui/src/context/sdk.tsx:82-117`);状态恢复不靠事件回放,而是收到 `server.instance.disposed` 时整体 `bootstrap()`(sync.tsx:172-173)——并行重拉 providers/agents/config/session.list/messages 等十几个 REST 端点重建 store(sync.tsx:445-541)。事件 payload 里有 `id`(ascending 标识,bus/global.ts:15-17)和 durable 事件的 `seq`(event-v2-bridge.ts:47-60,"sync" 通道),但那是给实验性 workspace 同步用的(`sdk.sync.start()`,sdk.tsx:99),主 UI 路径不做 cursor 续传。 + +## 5. 命令面:session 创建 / prompt / abort + +路径常量集中在 `groups/session.ts:79-104`(`SessionPaths`),全部带 `?directory=`(workspace 路由 query,middleware 解析): + +- **创建**:`POST /session`,body 可空或 `Session.CreateInput`(parentID/title 等),返回 `Session.Info`(groups/session.ts:203-214;handler `handlers/session.ts:155-175`,空 body 走 `create({})`)。 +- **发消息(同步)**:`POST /session/:sessionID/message`,body = `PromptPayload`(`SessionPrompt.PromptInput` 去掉 sessionID:parts、model、agent 等),**响应是阻塞到整轮 agent 循环结束后一次性返回的 message+parts JSON**(groups/session.ts:316-328;handler 里 `promptSvc.prompt(...)` 完成后 `HttpServerResponse.stream(Stream.make(JSON.stringify(message)))`,handlers/session.ts:295-309——用 stream 包装只是让连接保持,不是增量协议)。 +- **发消息(异步,TUI 实际用法)**:`POST /session/:sessionID/prompt_async`,同 payload,**立即 204**,prompt 在服务端 fork 执行,错误也转成 `session.error` 事件发总线(groups/session.ts:329-342;handlers/session.ts:311-329)。 +- **中止**:`POST /session/:sessionID/abort`,无 body,返回 boolean;handler 调 `promptSvc.cancel(sessionID)`(groups/session.ts:253-264;handlers/session.ts:232-234)。 +- 相邻端点:`GET /session`(list,支持 start/search/limit)、`GET /session/:id/message`(历史,limit/before 分页)、`POST .../fork`、`POST .../command`、`POST .../shell`、`POST .../revert`、`permissions/:permissionID` 回复等(groups/session.ts:111-433 逐个声明)。 +- **token 级增量不走 prompt 响应,全走事件总线**:处理器在生成过程中发 `message.part.delta`(字段级 append:`{sessionID, messageID, partID, field, delta}`,schema/src/v1/session.ts:632-641)和 `message.part.updated`(整 part 替换);TUI 收到 delta 后往 store 里对应 part 的 field 追加字符串(sync.tsx:392-441)。即“命令走 REST、数据走 SSE”的 CQRS 形态:prompt_async 只负责触发,渲染完全由事件驱动。 + +## 对 DSH 统一 API 层的可借鉴点(简评) + +1. **同构面选在 WHATWG fetch(Request→Response)**是整个设计的支点:server 框架只要能产出 `fetch(Request): Promise` 纯函数(Effect httpapi 的 webHandler、Hono 的 app.fetch、我们未来的 dsc web server 均可),SDK 就能通过 `fetch` 选项零改动切换 in-process / Worker RPC / 真 HTTP 三种传输。 +2. **类型打通靠 server-first OpenAPI**:路由声明用带 Schema 的 DSL → 导出 openapi.json → @hey-api codegen 出客户端。契约测试只需盯 openapi.json diff。 +3. **事件设计取舍**:全局单 SSE 总线 + 事件自带归属字段 + 客户端过滤,简单但重连无 cursor,恢复靠 REST 全量 bootstrap——请求面与事件面正交,客户端 store 是唯一 join 点。 diff --git a/missions/tasks/20260719-2036-step1-impl/README.md b/missions/tasks/20260719-2036-step1-impl/README.md new file mode 100644 index 0000000000..f10971b6e9 --- /dev/null +++ b/missions/tasks/20260719-2036-step1-impl/README.md @@ -0,0 +1,57 @@ +# step1 骨架实现(GUI) + +任务:照 `../20260719-1843-step1-skeleton-design/design.md`(v2 实现级规格)编码实现五模块骨架,跑通⑥验收清单 12 条。dispatcher:step1-design(升任)。 + +## 分工 + +| worker | 范围(design.md 节) | 依赖 | +|---|---|---| +| W-host | ②根配置四处编辑 + packages/host/apiproxy(③-3/④-3/⑤-1) + apps/dsc(③-1/④-1/⑤-5) | 无 | +| W-web | packages/client/web-runtime(③-4/④-4/⑤-2) + web-ui(③-5/④-5/⑤-3) + apps/web(③-2/④-2/⑤-4 三件套) | pnpm install 需等 W-host 根配置落盘 | +| 验收 | ⑥12 条逐条执行记录 | 两 worker 完成后 | + +纪律:照 v2 精确实现,文档有误/歧义先报 dispatcher 不自行改设计;小步落盘+回执;干完不 kill 保持存活修 bug;不遵循仓库门禁;禁读 worktree-webpreview。 + +## 已知风险 + +- ~~本 worktree 根无 `.env`、环境无 `DEEPSEEK_API_KEY`~~ 已解(20:47):team-lead 拍板先用假 key 走通全部验收。核实成立——llm-deepseek `apply()` 只查 key 非空后纯构造注册 adapter、零网络(src/index.ts:81-96)。根 .env 已放 `DEEPSEEK_API_KEY=dummy-step1-acceptance`(.gitignore 内)。验收按「假 key,真模型对话未验」口径记;真 key 到位后补一条真对话冒烟。 + +## 验收结果(2026-07-19 21:10 假 key 初验 12 条全过;21:2x 真 key 到位后复验+冒烟,见表尾三行) + +| # | 条目 | 结果 | 实际输出 | +|---|---|---|---| +| 1 | pnpm install | ✅ | 36.7s 完成;五包软链就位(分包 node_modules 下,根无顶层链属 pnpm 正常布局) | +| 2 | vite build | ✅ | `dist/index.html` 0.32kB + `dist/assets/index-BxPPnDLQ.js` 143.83kB(修 §③-2 deps 后) | +| 3 | demo:web 起服务 | ✅ | stdout `dsc web: http://127.0.0.1:3080` | +| 4 | GET / | ✅ | index.html 全文含 `

` | +| 5 | GET /assets/*.js | ✅ | 200 + `content-type: text/javascript; charset=utf-8` | +| 6 | 未知路由 | ✅ | 200 + index.html(SPA 回退) | +| 7 | 路径穿越 | ✅* | 编码变体 `/%2e%2e%2fpackage.json` → 403;裸 `/../` 被 server 侧 `new URL()` 先折叠、安全落为 SPA 回退 200 无泄漏(验收命令已 v2.1 修正为编码变体) | +| 8 | 浏览器访问 | ✅(代验) | dispatcher 无浏览器;以容器 IP `http://10.213.93.123:3080/` curl 200 代验网络可达;页面渲染待用户开浏览器抽查 | +| 9 | SIGINT | ✅ | 直接 node 起进程 kill -INT → 退出码 130 | +| 10 | SIGTERM | ✅ | 退出码 0 | +| 11 | 缺 key | ✅ | 退出码 1 + stderr `llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)` | +| 12 | 缺 dist | ✅ | 退出码 1 + stderr `dsc web: 前端 dist 未构建,先跑 pnpm --filter @deepseek-ai/dsc-web build` | + +加验:验收 3–10 全程 `.sessions/` 未出现(bootHost 无 agent 副作用,符合设计)。 + +真 key 复验(21:2x,用户填入 .env:DEEPSEEK_API_KEY + DEEPSEEK_BASE_URL 代理端点): + +| 条目 | 结果 | 实际输出 | +|---|---|---| +| 杀假 key 旧进程、真 key 重起 demo:web | ✅ | 打印行照常、GET / 200、SIGTERM 退出 0、`.sessions/` 未出现 | +| bootHost 真 key boot 链 | ✅ | 无 fail-loud,装载全过 | +| 真流冒烟(临时脚本 bootHost→`ctx.llm.stream` deepseek-v4-flash 最小对话,不动产品代码,用后即删) | ✅ | `finish=stop chunks=51 text="SMOKE-OK"`,key 真实有效 | + +## 进展 + +| 时间 | 事项 | +|---|---| +| 2026-07-19 20:36 | 用户拍板文档定稿开工;step1-design 升任 dispatcher,建本归档目录 | +| 2026-07-19 20:39 | 并发派出 W-host / W-web(平台限制 teammate 不能再生 teammate,两 worker 为后台 subagent,「干完保活修 bug」降级为「完事出报告、返工另派」;install/build/验收改由 dispatcher 在两者完成后亲自跑,天然满足时序依赖)。核实本 worktree 无 .env 且环境无 DEEPSEEK_API_KEY,已报 team-lead 待解(不阻塞编码与 build,阻塞验收 3–11 条) | +| 2026-07-19 20:42 | W-web 完成:11 文件落盘(web-runtime 3 / web-ui 3 / apps/web 5,index.html 在包根),零 BLOCKER,未越界。等 W-host | +| 2026-07-19 20:47 | team-lead 拍板假 key 方案;dispatcher 核实 llm-deepseek load 期零网络成立,根 .env 放入 dummy key | +| 2026-07-19 20:52 | W-host 完成:根配置四处 + apiproxy 3 文件 + apps/dsc 3 文件(bin.ts 3303B 边界结论逐条落实),照抄前核实 13 个 references/loadEnv 签名/插件导出形状均与文档一致,零 BLOCKER。里程碑②达成,dispatcher 开跑 install→build→验收 | +| 2026-07-19 21:02 | install 过(36.7s);vite build 两连挂,均为 apps/web deps 缺项(设计缺陷非 worker 错):①缺 dsh-web-runtime(main.ts 直接 import,严格 node_modules 不可解析)②缺 react/react-dom(plugin-react 强制 dedupe:['react','react-dom'],从项目根解析而非 importer,probe 插件实测 resolve NULL)。修 apps/web/package.json 补三依赖 + design.md §③-2 v2.1 修正。重跑 build 过:dist/index.html 0.32kB + assets/index-BxPPnDLQ.js 143.83kB。里程碑③达成 | +| 2026-07-19 21:10 | dispatcher 亲跑⑥验收 12 条全过(结果表见上)。发现并修正验收 #7 命令缺陷:裸 `/../` 会被 server 侧 URL 解析先折叠、测不到 403,改用编码变体 `%2e%2e%2f`(实测 403);裸变体安全落为 SPA 回退无泄漏。里程碑④达成,报 team-lead | +| 2026-07-19 21:26 | 真 key 到位(.env 含 DEEPSEEK_API_KEY+DEEPSEEK_BASE_URL)。杀假 key 旧进程→真 key 重起复验(打印行/静态页/SIGTERM 0)→临时脚本冒烟 `ctx.llm.stream` 拉真流:`SMOKE-OK` 51 chunks finish=stop。**step1 验收全量收口,关账** | diff --git a/missions/tasks/20260719-2039-rpc-vs-jsonrpc/README.md b/missions/tasks/20260719-2039-rpc-vs-jsonrpc/README.md new file mode 100644 index 0000000000..0e381c88ea --- /dev/null +++ b/missions/tasks/20260719-2039-rpc-vs-jsonrpc/README.md @@ -0,0 +1,18 @@ +# apiproxy 协议 vs 仓内 dsh-jsonrpc 平视对比调研 + +任务:把 apiproxy 新设计的 RPC 协议与 `packages/ui/jsonrpc/`(JSON-RPC 2.0 over stdio,真实消费方为 python SDK)做六维平视对比,找双向学习点,不做契约修改。 + +## 文件索引 + +| 文件 | 内容 | +|---|---| +| `findings.md` | 六维对比表(信封/类型安全/错误/流/传输/生命周期,jsonrpc 侧全带 file:line)+ 建议采纳清单(1 条契约改动建议 + 3 条实现注记 + 1 条反面自查) | + +## 进展 + +| 时间 | 事项 | +|---|---| +| 2026-07-19 20:39 | 读毕我方 design.md v1.2 + README 拍板表;细读 jsonrpc 三源文件 + README + transport 测试 + jsonrpc-demo bin + python SDK client.py(真实协议客户端) | +| 2026-07-19 20:46 | 第一批落盘:维度 1(帧/信封)+ 维度 2(类型安全) | +| 2026-07-19 20:52 | 第二批落盘:维度 3(错误模型)+ 维度 4(流/事件推送),发现子代理谱系学习点 | +| 2026-07-19 20:58 | 第三批落盘:维度 5(传输抽象)+ 维度 6(生命周期/取消)+ 建议采纳清单,调研完成 | diff --git a/missions/tasks/20260719-2039-rpc-vs-jsonrpc/findings.md b/missions/tasks/20260719-2039-rpc-vs-jsonrpc/findings.md new file mode 100644 index 0000000000..a1ebd0d1a9 --- /dev/null +++ b/missions/tasks/20260719-2039-rpc-vs-jsonrpc/findings.md @@ -0,0 +1,87 @@ +# apiproxy 协议 vs 仓内 dsh-jsonrpc 平视对比 + +> 2026-07-19。对象:`missions/tasks/20260719-1902-apiproxy-api-design/design.md`(v1.2,RPC map 按形态 B 理解)vs `packages/ui/jsonrpc/`(src/index.ts、server.ts、transport.ts + README + tests)及其真实消费方 `python/sdk/src/deepseek_harness/client.py`(jsonrpc-demo 只是 bin 壳,协议客户端在 python SDK)。 +> 场景声明:jsonrpc 是**进程间 stdio、单流全双工、SDK 驱动**场景;apiproxy 是**浏览器 HTTP/SSE、每请求独立信道**场景。纯场景差异导致的不同不计为学习点。 + +## 维度 1:帧 / 信封形状 + +| 子项 | 我方(apiproxy) | jsonrpc | 评价 | +|---|---|---|---| +| 信封 | `RpcResponse = {ok:true;value} \| {ok:false;error}`,HTTP 200 恒定,载体状态码只表载体故障(design §2、§4) | JSON-RPC 2.0 帧:`{jsonrpc,id,method,params}` 请求 / `{id,result\|error}` 应答 / 无 id 为 notification,按「id+method / 仅 id / 仅 method」三分(transport.ts:1-7, 162-177) | 各得其所:单条共享流必须靠 `id` 关联请求应答;HTTP 每请求自带信道,我方省掉 id 关联层是合理简化 | +| id 生成 | 无(HTTP 关联) | client 侧 `req_`+uuid 字符串(transport.ts:98);python 客户端同样 uuid(client.py:230) | 场景差异,无学习点 | +| 批量(batch) | 无此概念 | **未实现**:JSON-RPC 2.0 批量数组帧会静默落空(`handleLine` 三分支全不匹配即丢弃,transport.ts:162-177) | 双方一致不做批量;对方的「静默丢弃」还不如显式拒绝,见维度 3 容错评价 | +| notification(无 id 帧) | 无对应物;server→client 推送走独立 SSE 流的 Frame 族 | **重度使用**:server→client 事件全部走 notification(`session.event` server.ts:81、`session.finished` server.ts:139、`subagent.started` server.ts:86、`subagent.finished` server.ts:97);client→server 方向也支持(client.py:173-177,实际未用) | 等价物:他们的 notification ≈ 我们的 Frame 族。双方都把「推送不是应答」这件事在帧形状上区分开了——他们靠去掉 id,我们靠独立的流 + Frame 类型族 | +| 帧命名纪律 | 已拍板机械推导 convention:方法 key 点号(`session.list`),Frame type 斜杠(`session/event`)(design §1 命名 convention) | **无 convention,自然漂移**:方法名 `session/prompt` 用斜杠(server.ts:199),notification 名 `session.event`/`session.finished` 用点号(server.ts:81,139)——与我方约定恰好完全相反且包内自身不一致 | 我方优。对方是「没有显式约定就会漂移」的实证,反向验证了我们把命名 convention 写进拍板的价值 | + +维度小结:信封差异基本由载体决定(共享流 vs 每请求信道),无需互搬。对方 wire 命名漂移是反面教材,不构成改动项。 + +## 维度 2:方法注册与类型安全 + +| 子项 | 我方(apiproxy) | jsonrpc | 评价 | +|---|---|---|---| +| 方法注册 | `RpcMethodMap`(形态 B:登记方法签名本身,`'session.list': SessionsApi['list']`),handler/client 按 map key 机械遍历(design §1 RPC map) | 单个 `onRequest(handler)` 槽位(transport.ts:84-86)+ server 内裸字符串 `switch(method)` 派发三个方法(server.ts:195-206),无 map、无遍历机制 | 我方结构化程度高一档;对方仅 3 个方法,裸 switch 成本尚可接受,但每加一方法要同时改 switch + 类型 + python 双份 | +| 参数类型约束 | `ClientRequest` = `Parameters[0]` 反推,签名即事实源;wire 入口 zod parse 拒收 → `bad-request`(design §1、§4) | 接口类型仅作文档(`InitializeParams`/`SessionPromptParams`,server.ts:20-47),派发处 **`params as unknown as InitializeParams` 双重 cast,零运行时校验**(server.ts:198,200);transport 层只把非对象 params 归一成 `{}`(objectParams,transport.ts:220-223) | 我方显著更严:对方 wire 与类型之间没有任何强制关联,恶意/漂移 payload 直接以 any 语义进 handler | +| 契约的跨语言事实源 | TS interface 单一权威,client 经 `import type` 直达(design §0.1) | **契约双份**:TS interface(server.ts:20-47)+ python pydantic 模型(models.py:26-31)各写一遍,靠人肉对齐,无生成或校验链 | 我方优(v1 单 TS 生态占了便宜);若将来 apiproxy 出现非 TS 消费方,对方这个「双份漂移风险」就是前车之鉴——到时需要 schema 导出(zod → JSON Schema)而非手抄 | +| 返回值类型 | `ServerValue` infer 去信封;zod `satisfies z.ZodType` 双向锚定 | 返回 `Promise`(transport.ts:14, server.ts:195),python 侧 pydantic `model_validate` 兜底(client.py:171) | 我方优;对方把校验责任推给了客户端 | + +维度小结:类型安全是两者差距最大的维度。对方是「接口类型只当注释用」的典型形态,任何一处 wire 漂移要靠 python 侧 pydantic 报错才能发现;反向验证我方 RpcMethodMap + zod 双向校验的投入是值得的。此维度无可学习点,只有可引以为戒点。 + +## 维度 3:错误模型 + +| 子项 | 我方(apiproxy) | jsonrpc | 评价 | +|---|---|---|---| +| 错误码体系 | `RpcErrorCode` 闭合字符串 union,起步四码 `bad-request/session-not-found/agent-busy/internal`,按域扩展(design §2) | 标准 JSON-RPC 数字码但**只用两个**:`-32601` method-not-found(transport.ts:182)、`-32603` 兜底(transport.ts:189);server 层所有业务异常(session 忙 server.ts:132、provider 无 adapter server.ts:119、shutting down server.ts:209)全部 throw Error → 统一压扁成 `-32603 + message 文本` | 我方显著优:对方客户端只能靠 message 字符串匹配区分「忙」和「炸」(python 侧 JsonRpcError.code 拿到的恒是 -32603)。我方 `agent-busy` 这类可编程区分的域码正是对方缺的 | +| error.data / details | `details?: unknown`(design §2) | 帧格式支持 `error.data`(python 读侧 client.py:345-347、写侧 respond_error client.py:207-218),但 **server 从不填**——data 通道形同虚设 | 双方形状同构(details≈data);对方证明「留了通道不填」没有价值,我方 details 的价值取决于 impl 真的放结构化内容(如 zod issue 列表进 bad-request.details) | +| 业务错误 vs 传输错误分层 | 两层显式分离:业务错误 = 200 + RpcResponse.error;载体故障 = fetch throw / 4xx/5xx(design §2、§4 HTTP status 只表载体) | **无分层**:业务失败与 handler bug 同为 -32603 error 帧;传输死亡 = pending 全部 reject(transport.ts:213-217, python client.py:373-384) | 我方优。对方 -32603 语义上本应是「internal error」,被迫兼职业务错误码,是标准 JSON-RPC 码表太窄的直接后果 | +| 非法帧容错 | 404 未知路径 / 400 body 非 JSON —— 显式拒绝(design §4) | **静默丢弃**:JSON 解析失败 ignore(transport.ts:154-160)、非对象帧 ignore(transport.ts:162)、批量数组帧三分支全不匹配也静默落空(transport.ts:162-177)、孤儿 response 静默忽略(transport.ts:194-195) | 各有道理:共享长流上一条坏帧不该毒死整个连接(丢弃保流活);HTTP 每请求独立,显式 4xx 无连坐风险。场景差异,但「孤儿 response 忽略」对应我方 SSE 重连后旧流帧要能安全丢弃——我方 v1「重连=重建」天然规避 | + +维度小结:错误模型我方全面占优,且对方恰好演示了我方设计规避的两个坑(码表退化成单码、data 通道空转)。唯一带回家的是执行纪律而非契约改动:`details` 要在 impl 里真的填结构化内容。 + +## 维度 4:流式 / 事件推送 + +| 子项 | 我方(apiproxy) | jsonrpc | 评价 | +|---|---|---|---| +| 推送机制 | 两条 SSE(events.mux 全 session 聚合 + events.host),AsyncIterable(design §3.3) | 无流概念;server→client 推送全走共享 stdio 流上的 notification(session.event server.ts:81、session.finished server.ts:139、subagent.started/finished server.ts:86,97) | 同构异形:单双工流 vs 独立 SSE 是载体差异。值得注意的相同点:**双方都选了「全量广播、client 侧过滤」**——对方 python 端 predicate 订阅过滤(client.py:185-196, 496-505),我方 mux 聚合流 client fold;无 server 侧按 session 订阅,路线互相印证 | +| 事件 payload | `SessionEvent` 纯透传(design §3.3 透传纪律) | **同样纯透传**:`notify('session.event', { sessionId, event })`,event 原样(server.ts:76-82) | 双方一致。透传纪律在对方已有实践先例,佐证我方拍板 | +| prompt 与事件的关系 | prompt 立即回 `accepted`,token/工具进度走 mux 流,turn 结束由 client fold `turn/end` 事件得出 | **prompt 请求悬到整轮结束**:`await whenIdle()` 后才回 accepted(server.ts:130-148),轮次结局另发 `session.finished` notification(status 由 turn/end reason 折算,server.ts:234-237);README:23 一 session 单飞行 prompt,重叠立即失败 | 我方优(对我们的场景):长轮次挂住一个 HTTP 请求分钟级不可接受,浏览器超时/代理都会杀它。对方 SDK 场景「阻塞到定稿」反而是易用性(同步语义)。`session.finished` 我方不需要——透传的 turn/end 事件已含此信息,对方是因为不透传完整事件序给 request 侧才需要补这个信号 | +| 缝隙检测 | `subscribed.lastSeq` + history 尾 seq 对比补缝(design §3.3 已拍板) | 无对应物:单条有序流从 session 创建起连续推送,无「开流 vs 拉历史」竞态窗口,也无 seq 概念上 wire | 场景差异(对方无历史拉取、无重连)。我方多出的复杂度是 HTTP 双通道(unary+SSE)的固有代价,lastSeq 是对的 | +| 子代理谱系 | HostFrame 只有 `session-added/removed/status`,**无父子关系**;SessionSummary 三字段无 parentSession(design §3.1、§3.3) | 一等公民:`session/created` 时若有 `parentSession` 即发 `subagent.started {parentSessionId, childSessionId}`(server.ts:83-90);`subagent/end` 发 `subagent.finished` 带 provider/status/stopReason/lastAssistantMessage(server.ts:91-106),且只报 `local` 子 session(server.ts:97 快照纪律) | **对方有我方没想到的点**:SDK 消费者第一时间要子代理谱系,web UI 迟早同样要(子 session 归组显示在父会话下)。我方 host/session-added 帧撞上子 session 时 client 无从知道它是谁的孩子。学习点 → 建议清单 #1 | + +维度小结:推送形态互相印证(广播+client 过滤、事件透传都撞车,是好信号)。真学习点一个:子代理谱系在 session 出生帧上的缺位。 + +## 维度 5:传输层抽象 + +| 子项 | 我方(apiproxy) | jsonrpc | 评价 | +|---|---|---|---| +| seam 形状 | `fetchLike` 函数(`createApiClient(fetchLike)`),同进程注入 `toFetchHandler(api).fetch` 即免网络(design §1、§4 同构点) | 双 seam:① 流级 —— `JsonRpcConfig.input/output` 收 `Readable/Writable`(index.ts:26-33),生产 stdin/stdout,测试注 PassThrough(transport.spec.ts:6-12 用两对 PassThrough 组 transportPair 全双工对测);② 帧级 —— server 只依赖 `JsonRpcTransportPeer` 接口(request/notify 两方法,transport.ts:20-34,server.ts:74) | 同一思想不同层:都把「换传输」做成注入点,都能做到测试零真 IO。对方帧级 `TransportPeer` 接口值得注意——server 完全不知道底下是 stdio 还是别的;我方等价物是 `ApiProxy` 接口本身(handler 不知道 fetch 是真是假),层次同构 | +| 帧编码 | JSON body / SSE `data:` 行 | newline-delimited JSON + StringDecoder 处理跨 chunk 多字节 UTF-8(transport.ts:49,129;专项测试 transport.spec.ts:125-143) | 场景差异。但对方 UTF-8 splitting 专项测试提醒了一件事:我方 SSE 用 streaming fetch 手工切帧(design §4「非 EventSource」),**同样会遇到多字节字符跨 chunk 与跨 `data:` 行边界问题**——这是 client 实现的必测项,进清单(测试项,非契约改动) | +| exit/进程权 | 不适用(HTTP server 常驻) | `exit` 也是注入 seam(index.ts:31-33),协议 shutdown 先 flush 响应再 dispose 再 exit(0)(index.ts:57-74) | 场景差异,无学习点 | +| 背压/flush | 未提及;HTTP 响应体天然有背压 | `flush()` 用空写屏障等待所有先前帧落盘(transport.ts:115-126),shutdown 前显式 flush 保「响应先于退出」 | 对方解决的是「进程要死前别丢帧」,我方 host 常驻无此问题;SSE 断流时帧丢失由重连重建兜底。无需搬 | + +维度小结:可替换性双方都做到了,思想同构(接口注入、测试零 IO)。带走一个实现期测试项:SSE 手工解帧的多字节/跨 chunk 边界测试。 + +## 维度 6:生命周期 / 取消 / 超时 + +| 子项 | 我方(apiproxy) | jsonrpc | 评价 | +|---|---|---|---| +| 请求取消 | unary:无(HTTP fetch 本身可 abort,但 server 侧不感知语义取消);**业务级取消是显式 RPC**:`session.cancel` 清 FIFO + abort step(design §3.1) | **完全没有**:wire 无 prompt-cancel 方法,README:35 明列已知缺陷「一个 accepted prompt 跑到 idle 前该 session 无法再接受任何输入」;python 客户端超时(client.py:264)也只是放弃等待,server 侧照跑 | 我方优,且对方把这个坑写成了官方遗留。反向确认 `session.cancel` 进 v1 是对的 | +| 请求超时 | 未提及;fetch 载体可加 AbortSignal,但契约层无 timeout 语义 | client 侧 `request_timeout_seconds`(HarnessConfig,client.py:33; 超时逻辑 client.py:250-264),默认 None=无限等 | **对方有我方没写的点**:超时是纯 client 策略这个定位是对的(server 不该管),但我方 design §5 ConnectionController 未提 unary 超时——浏览器 fetch 默认无超时,host 若 hang,UI 会永久 pending。学习点(client 实现注记,非契约改动)→ 清单 #3 | +| 连接断开(client 死) | SSE 断流 server 侧收 abort;unary 无状态 | stdin EOF → dispose root → 进程退(bin.ts:49; demo README:27 「EOF 切断在飞轮次」);pending 请求 reject(transport.ts:148-152) | 场景差异(对方 client 死=服务无意义;我方多 client 且 host 常驻)。无学习点 | +| 服务端主动关闭 | 无 shutdown 概念(host 生命周期独立于 client) | 协议级 `shutdown` 方法:响应先落盘再 flush → dispose 到静止 → exit 0(index.ts:67-74, server.ts:155-186);幂等(shutdownTask 缓存 server.ts:156) | 场景差异。但其中「shutdown 期间新建 session 拒绝」(shuttingDown 闸门 server.ts:209)+「等 pending 创建落定再拆」(server.ts:162-164)是通用的**拆机纪律**,我方 host 将来做优雅退出(Electron 关窗)时同样要处理 in-flight prompt vs 拆机竞态——记为远期提示,不动 v1 契约 | +| 并发互斥 | prompt 无互斥需求(agent FIFO 队列天然吸收,mode:queue/steer 语义已覆盖);单客户端互斥 ClientSlot v1 不做(design §6) | session 级单飞行 prompt,重叠**立即报错**(activePrompt 标志 server.ts:132)而非排队 | 有意思的分叉:对方「拒绝重叠」因为其 prompt 语义是同步等结局;我方 prompt=入队立返,天然无重叠问题。各自内洽,无学习点 | +| 惰性资源创建 | `session.create` 显式;history/prompt 对冷 session 隐式 resume(design §3.1) | prompt 未知 sessionId 直接惰性创建 agent+session(server.ts:38, 208-232),并发创建去重(sessionCreations map,server.ts:212-221) | 同路线(隐式创建/附着)。对方 `sessionCreations` 并发去重值得记一笔:我方两个并发请求同时命中同一冷 session 时 impl 也要做 resume 去重——实现注记 → 清单 #4 | + +维度小结:取消上我方领先(对方官方承认缺失);对方贡献三个实现期提醒:client 超时策略、并发 resume 去重、优雅拆机闸门。全部是 impl/client 层面,零契约改动。 + +## 建议采纳清单 + +平视结论先行:六个维度里,**契约形状层面没有一处需要向 JSON-RPC 2.0 靠拢**——信封、错误码、流形态的差异全部由场景(HTTP 多信道 vs stdio 单流)正当化,且对方在类型安全、错误码分辨力、取消能力三处反向验证了我方拍板。真正值得搬的是对方作为「已运行协议」暴露出的需求点和实现纪律,共 4 条 + 1 条反面自查: + +1. **【唯一契约改动建议】host/session-added 帧补子代理谱系**:`HostFrame` 的 `session-added` 增可选字段 `parentSession?: SessionId`(core session header 已有此数据,server.ts:83-90 证明取用零成本)。jsonrpc 把 subagent.started/finished 做成一等公民,说明消费方第一时间就要谱系;我方 web UI 做子 session 归组时若无此字段,只能开一条 history 才能知道父子关系,代价不成比例。**成本:极低**——additive 可选字段,一行类型 + schema 一行 + impl 取 header 现成值;现在加避免将来 fold/store 按平铺 session 建模后返工。若用户认为 v1 UI 明确不显示子 session,可降级为「留座注记」写进 design §6 不做清单。 +2. **【执行纪律,非改动】`RpcError.details` 必须真的填**:jsonrpc 的 error.data 通道从未被 server 填过(形同虚设)。落实到 impl 验收:`bad-request` 的 details 放 zod issues、`session-not-found` 放 sessionId。成本:impl 编码习惯,零契约变更。 +3. **【client 实现注记】unary 请求超时**:浏览器 fetch 默认无超时,host hang 时 UI 永久 pending。python SDK 的做法(纯 client 侧 timeout 配置,client.py:250-264)定位正确。落到 design §5 ConnectionController 一句话注记即可。成本:低,一句设计注记 + client 实现一个 AbortSignal.timeout。 +4. **【impl 实现注记】冷 session 并发 resume 去重**:两个请求并发命中同一冷 session 时的 resume 单飞(对照 sessionCreations map,server.ts:212-221)。成本:低,impl 内一个 Map,可写进 design §3.1 分页注记旁一句话。 +5. **【反面自查,已通过】wire 命名一致性**:对方无 convention 导致 `session/prompt`(斜杠方法名)与 `session.event`(点号通知名)在同一包内互相打架。我方已拍板机械推导 convention(方法点号、Frame 斜杠),此坑已提前规避——无动作,仅记录佐证。 + +SSE 手工解帧的多字节 UTF-8 跨 chunk 测试(维度 5)并入 client 实现测试计划,不单列为契约建议。 + diff --git a/missions/tasks/20260719-2119-step2-impl/README.md b/missions/tasks/20260719-2119-step2-impl/README.md new file mode 100644 index 0000000000..9c3e6e2455 --- /dev/null +++ b/missions/tasks/20260719-2119-step2-impl/README.md @@ -0,0 +1,47 @@ +# step2 协议实现(dispatcher: apiproxy-design) + +契约基线:`../20260719-1902-apiproxy-api-design/design.md` **v1.5(冻结)**。任何契约疑问回 dispatcher,不得自行改契约。 +纪律:GUI 期间跳过仓库门禁(不写测试/不跑 coverage 门),只求 typecheck 过 + 能跑通。 + +**UI 首里程碑(2026-07-19 21:3x 用户拍板,对话流后置)**:布局分区(左导航 Sessions/Settings、右主区留白)+ **右下角 RPC 调试面板**(所有 unary 往返 + SSE 帧台账,rpcId 信封第一个消费者)。验收:浏览器打开 → 左栏真实 session 列表(session.list 真 RPC)→ 调试面板见 bootstrap unary 往返 + mux/host 帧滚动。 + +**已定 React 架构(写进 W4/W5 任务书)**:React 不碰流/不发请求——runtime 层 ConnectionController 消费 AsyncIterable → fold → 写 zustand store;组件 `useStore(selector)` 直连 zustand(无 bridge 层);写路径 = runtime 导出 intent 普通函数集(内调 ApiClient + 写 store)。rpcLog 采集点在 createApiClient 包/解包咽喉(载体层选项 `{onEnvelope}`,不污染契约签名),有界环形 buffer ~500 条。 + +## worker 分工 + +| W | 范围 | 状态 | +|---|---|---| +| W1 | `packages/host/apiproxy/src/api/`(契约包:五域接口、rpc.ts 三信封、rpc-map.ts、zod schemas)+ typecheck | **完成 21:55**(worker 连折三茬后 dispatcher 依批准下场直写;14 文件,typecheck 绿) | +| W2 | `impl/`(boot core 上实现 ApiProxy)。**最小先行**:session.list + events 两流(点亮调试面板);history 分页/prompt+rpcId spike/审批问答 registry 并行晚到 | 待 W1 冻结 | +| W3 | `fetch/`(toFetchHandler 两级 parse / createApiClient mint+拆封+`onEnvelope` tap)+ apps/dsc `/api/*` 接线 | 待 W1 冻结 | +| ~~W-design~~ | **已移交独立 teammate ui-design**(21:4x 用户调整分工,直接向主会话汇报不经本 dispatcher);本处 worker 已停、任务书作废、无部分产出 | 移交 21:4x | +| W4 | web-runtime 编码(照 ui-design 的设计文档) | 待设计过用户 review;届时是否回本 dispatcher 调度另定 | +| W5 | web-ui 编码(同上) | 同上 | + +## UI 六问拍板(2026-07-19 21:5x,已注入 W-design) + +| 问 | 答 | +|---|---| +| Q1 主题 | 架构双主题、先只做亮色;`:root` 亮色实值 + `[data-theme='dark']` 占位;**切换按钮保留可点**(暗色不完善也不藏不禁用,用户明示) | +| Q2 面板形态 | 强浮动:右下角浮层(折叠徽标/展开浮层覆盖内容之上),不占布局流 | +| Q3 导航 | 左栏上段 Sessions 列表占大头 + 下段固定 Settings 入口 | +| Q4 列表交互 | 选中态 + **新建 session 按钮**(session.create 真 RPC;intent 加 createSession);重命名等不做 | +| Q5 面板权限 | readonly 纯观察 | +| Q6 CSS | CSS Modules + PostCSS + clsx,不引组件库(deepseekchat 同模式) | + +## 进展 + +| 时间 | 事项 | +|---|---| +| 2026-07-19 21:22 | 归档建立;W1 派发(后台) | +| 2026-07-19 21:3x | 用户拍板 UI 首里程碑重塑(布局+调试面板,对话流后置);W2 拆最小先行、W4 提优先级、W4/W5 任务书按 React 架构决策重写 | +| 2026-07-19 21:4x | 用户修正流程:W4/W5 设计先行;W-design 派发(单文档 ui-milestone1-design.md),视觉小节留占位 | +| 2026-07-19 21:5x | 六问答案到齐注入 W-design 收口;评审链=dispatcher 契约 review → team-lead → 用户通过才开 W4/W5 编码 | +| 2026-07-19 21:4x | W1/W-design 双双 API 超时零落盘,杀掉按「分批落盘铁律」重派(W1r 七批 / W-design-r 三批);5 分钟存活检查点纪律启用 | +| 2026-07-19 21:4x | 用户调整分工:UI 设计移交独立 teammate ui-design;W-design-r 停止(无部分产出);本 dispatcher 收窄为纯协议实现(W1→W2/W3) | +| 2026-07-19 21:55 | **W1 契约包完成**(21:43 检查点 W1r 仍零落盘 → 杀掉,dispatcher 依 team-lead 批准下场直写):api/ 14 文件(5 域 ts+schema 对、rpc/rpc-map/index),typecheck 绿。**实现注记**:仓库 exactOptionalPropertyTypes 与 zod .optional() 输出不兼容,schema 锚定统一为 `satisfies z.ZodType>`(Wire=深度 undefined 宽化,rpc.schema.ts 有文档),透传宽分支(SessionEvent/ContentBlock/RpcError/帧 union)与 brand id 保持显式 cast+注释——设计层「satisfies 锚定」精神不变,形式微调,回头补进 design.md §0.5 | +| 2026-07-19 22:42 | **api/ 14 文件按 v2.0 四象限改写完毕,typecheck 绿**:rpc.ts(四具名 union+窄形+RpcReceipt+错误码删两个)、rpc-map(6 key+RequestPayload/ResponseValue)、sessions/host 签名 RpcRequest

、events 流 yield RpcRequest<帧>+帧字段改名(approvalId/questionRpcId)、approvals/questions 改 payload 形状(域接口取消)、barrel 按消息层分组、schema 全层跟改(四具名全形 schema+respond payload schema)。W2 旧模型废码 impl/api-proxy.ts 已删。期间 W2/W3 已死(超时/手停),待重派 | +| 2026-07-20 01:1x | **备案(session-design 联调改动,契约 owner review 通过)**:client.ts URL base 改 resolveBase()——浏览器=location.origin(真网络下假域名 DNS 失败)、无 location 或 origin==='null'(file://、沙箱 iframe)=dsh.internal 注入基;Node 同构管道零影响。apiproxy exports 补 `./api`、`./client` 浏览器安全出口(指 src .ts,GUI 期可;发布前需转 lib 产物——记 hygiene 欠账) | +| 2026-07-19 23:51 | **provider/model 缺省 bug 修复**(team-lead 真 HTTP 探针发现 turn 即错):bootHost 加 `provider?/model?` 配置与 `HostDefaults`(缺省 deepseek / deepseek-v4-flash 同 demos);createApiProxy 收 defaults 参数——create/resume 注入 agentOptions、describe 回报同一来源(契约 §3.2「host 级默认现值」语义闭环);契约 create payload 加 provider?/model? 留 additive 后补。**全链自证通过:prompt 后真模型 assistant/chunk 流出**(进程内同构,60s 探针 exit 0)。双 typecheck 绿 | +| 2026-07-19 23:45 | **W2 第二批完成**(session-design 验收开闸触发):history 消息边界分页(尾向前扫 surface 消息计数、sourceEventSeqs 归组切 seq、尾页含 partial、DEFAULT_MAX_MESSAGES=50);prompt 真分发(queue→send/steer→steer,**rpcId 经 MessageSource 透传 spike 落地**——api/sessions.ts merge 声明 `{kind:'user'; rpcId}`,模型面零传输词汇);cancel 真分发;冷 session 隐式 resume + Map 并发去重。SSE 探针修复顺带:handler.fetch 签名对齐全局 fetch(进程内 (url,init) 调用形归一化)。**进程内同构探针三连过**:create ok / history 空页 ok / not-found 错误码 ok。双 typecheck 绿。respond/审批 registry 仍后置(PendingCard v1 只展示) | +| 2026-07-19 23:04 | **W2/W3 范围由 dispatcher 下场完成**(W2v2/W3v2 重派后又双双超时零落盘/零进展,杀掉直写):impl/api-proxy.ts(describe/list/create/mux/host 两流真实现+FrameQueue;history/prompt/cancel/respond stub 带 TODO)、fetch/handler.ts(UNARY_ROUTES 6 路由两级 parse+path==method、/api/respond、SSE ServerRequest 全形)、fetch/client.ts(窄形↔全形、onEnvelope 四象限 tap、unary 超时、streaming SSE 解析、respond 入口)、apps/dsc bin.ts /api/* 桥接(node:http↔WHATWG+SSE 流式写出)、apiproxy index.ts 导出四件套。apiproxy+dsc typecheck 双绿。**调试面板地基(W1+W2最小+W3)全就位**,待 ui-design 稿过审后 W4/W5 编码接 onEnvelope | diff --git a/missions/tasks/20260719-2140-ui-milestone1-design/README.md b/missions/tasks/20260719-2140-ui-milestone1-design/README.md new file mode 100644 index 0000000000..f9317cc022 --- /dev/null +++ b/missions/tasks/20260719-2140-ui-milestone1-design/README.md @@ -0,0 +1,30 @@ +# UI 首里程碑设计(布局分区 + RPC 调试面板)——实现级设计文档 + +任务:写 web-runtime 数据层(§A)+ web-ui 组件层(§B)+ 对齐纪律(§C)的实现级设计,读者=无上下文编码 teammate;只写文档不写代码。契约基线 = apiproxy design.md v1.5(冻结,只消费不改)。 + +## 进展 + +| 时间 | 事项 | +|---|---| +| 2026-07-19 21:46 | 建档(上一轮 API 超时,内容重来;四份必读上下文已读完) | +| 2026-07-19 21:52 | design.md 首批落盘:§A.0 模块布局/导入纪律、§A.1 store 全切片 TS 类型、§A.2 rpcLog tap+环形 buffer(含 onEnvelope 形状=W3 接缝) | +| 2026-07-19 21:58 | 第二批落盘:§A.3 ConnectionController(退避参数写死/先流后 unary/generation fencing/重连=重建)、§A.4 intents 11 函数全表、§A.5 fixture 两件套、§A.6 boot+apps/web 接线(?fixture 开关)。§A 完 | +| 2026-07-19 22:04 | 第三批落盘:§B.0 文件全清单+包配置增量、§B.1 useWeb 订阅 hook+纪律、§B.2 布局骨架(App 网格/Sidebar 三段/MainArea 三分支占位) | +| 2026-07-19 22:11 | 第四批落盘:§B.3 调试面板完整交互规格、§B.4 selector 订阅表(逐组件 re-render 边界)、§B.5 CSS 变量表(亮色实值+暗色占位)、§C 对齐纪律六条、§D 验收清单九条、拍板对照索引。v1 完稿(与收窄拍板交叉,随即回改) | +| 2026-07-19 22:0x | 收到三条变更(team-lead 转达用户拍板):①契约严格双向 RPC(签名收 RpcRequest 封装/帧=server request/respond 回填 rpcId),契约名以 apiproxy-design 修订稿为准;②store 瘦身——不存业务对象,只剩 rpcLog+面板 view 态,sessions/connection 走 OOP 演进;③范围收窄——本里程碑只做 RPC 面板,左导航/Settings/主题全部降级下一里程碑 | +| 2026-07-19 22:2x | v2 回改完稿:§A.1 两切片+OOP 演进节、§A.3 状态不进 store、§A.4 收缩至 rpcLog 三件+pingHost(boot 自动一次+面板 dev 按钮)、§A.2 契约类型弱引用化、§B 砍到 App 壳+面板(新增同 rpcId 配对高亮)、§B.5 只落 :root 亮色、§C 七条(新增 store 无业务对象红线)、§D 面板口径八条、新增 §E 降级素材四节。v2 完稿 | +| 2026-07-19 22:3x | 用户点名修订(v2.1):rpcLog 对齐契约四具名 union——ApiEnvelopeTapEvent/RpcLogEntry 三支改四支(client-request/server-response/server-request/client-response,frame 词从分类退役、stream 挪入 server-request 支);行方向符四种(→/←/⇐/⇒);配对高亮升级为按族两组(client-request↔server-response、server-request↔client-response);tap 时机表补 client-response;注记 v1 仅 respond 产生 client-response、实现留支不填 | +| 2026-07-19 22:4x | 目录组织拍板(随 v2.1 一批):①面板归位 panel 概念——`components/panels/RpcLog/`,命名全链统一(DebugPanel→RpcLog、PanelBadge/Body→RpcLogBadge/Body、debugPanelOpen→rpcLogOpen、intents open/close/toggleRpcLog),panels/ 为可扩展位(将来 Settings/诊断各占子目录);②纯函数工具归 utils/——`web-ui/src/utils/formatRelative.ts`,归属 web-ui 理由=唯一消费方是渲染层(消费方就近,两包各自建 utils/ 不共享工具包)。**v2.1 完稿** | +| 2026-07-19 22:5x | 用户批准设计稿,转入编码(任务 #6,fixture 驱动)。web-runtime 7 文件(api-types 契约临时副本/store/rpc-log/intents/connection/fixture/boot+index)tsc 绿;web-ui 12 文件(use-web/utils/global.css/App 壳/panels/RpcLog 五件套+css)tsc 绿(tsconfig 去 composite 走 paths 源码解析);apps/web main.ts 接线 ?fixture。vite build 绿;dsc web 3080 起服 curl 200;node 冒烟全链路过(boot 3 条台账三象限 kind/开面板清 unread/ping 造一对 describe/清空/暂停全对) | +| 2026-07-19 23:0x | 用户指令改真浏览器验收:装 playwright(chromium 走代理下载 114MB)+ 写 scripts/verify-rpclog-panel.mjs;重启 3080(EADDRINUSE 是旧进程活着、setsid 脱管重起);首跑 §D-5 暴露脚本前置缺陷(行数不溢出滚不动,补连点 ping 造行)后 **ALL PASS 10/10**(表见上节) | + +## playwright 自动验收(scripts/verify-rpclog-panel.mjs) + +跑法:dsc web 起在 3080 + dist 最新 build 后 `node scripts/verify-rpclog-panel.mjs`(chromium headless 走 ~/.cache/ms-playwright;不进门禁体系)。改面板代码后重跑此脚本代替人工点验。 + +2026-07-19 23:0x 首跑结果:**ALL PASS(10/10)**——§D-1 角标+未读、§D-2 三象限方向符+未读清零、§D-3 ping 造对+§D-3b 两族配对高亮、§D-4 JSON 展开收起、§D-5 上滚暂停+继续贴底(脚本先连点 ping 造 ≥30 行溢出,否则列表不滚)、§D-6 清空+周期帧续入。顺带覆盖 team-lead 的 bin.ts mime 修复(页面能载入即 content-type 正确)。 + +## 接缝问题(契约 v1.5 缺口,只报告不擅改) + +1. **`host.describe` 无 host 实例标识**(bootId/instanceId 类字段缺失):client 无法区分「网络闪断(host 未换)」与「host 重启过」。v1 影响为零——「重连=重建」一刀切让两种情况行为一致;但将来做客户端缓存、mux `since` 续传或乐观 UI 时必须能辨识实例,届时 describe 需 additive 加一个实例标识字段。 +2. **`onEnvelope` tap 属载体层选项,契约未列**:设计把它定在 `fetch/client.ts` 的 `CreateApiClientOptions`(见 design.md §A.2,含 tap 事件三形状)。这符合 v1.5「开关是实现细节不进签名」的既有口径,不算契约变更,但 W3 实装需按 §A.2 形状对齐——请 dispatcher 把该小节转发给 W3 作接缝规格。 diff --git a/missions/tasks/20260719-2140-ui-milestone1-design/design.md b/missions/tasks/20260719-2140-ui-milestone1-design/design.md new file mode 100644 index 0000000000..7abf359744 --- /dev/null +++ b/missions/tasks/20260719-2140-ui-milestone1-design/design.md @@ -0,0 +1,539 @@ +# UI 首里程碑(RPC 调试面板)· 实现级设计(v2.1 完稿,待 review) + +> 2026-07-19 起草。读者=无上下文编码 teammate,照抄即可建文件写代码。 +> 契约基线:`../20260719-1902-apiproxy-api-design/design.md`——本文只消费不改。**契约变更提示(2026-07-19 22:0x)**:用户推翻「签名不感知信封」,定型严格双向 RPC——①ApiProxy 方法签名收 `RpcRequest

= { rpcId, payload }` 封装(server 感知 rpcId);②SSE 帧本身 = server 发起的 request(帧 rpcId 由 server mint);③审批/问答 respond 的 payload 回填 requested 帧的 rpcId 作 wire 关联(respond 调用自身另有新 rpcId)。修订稿由 apiproxy-design 维护中,**本文引用契约类型的具体泛型/信封名以修订稿为准**,涉及处以「契约修订稿」字样弱引用、不写死。 +> 骨架现状:step1 五包已 commit(`../20260719-1843-step1-skeleton-design/design.md` v2.1);本里程碑会**改写** web-runtime / web-ui / apps-web 三包的入口文件(§A.0 / §B)。 +> 范围(2026-07-19 22:0x 两条收窄拍板后):**最简 App 壳 + 右下角强浮动 RPC 调试面板,仅此**。左侧导航 / Sessions 列表 / Settings 入口 / 新建按钮 / 主题切换全部降级下一里程碑(已写素材保留在 §E,不作本里程碑交付物);store 只存 rpcLog + 面板轻量 view 态,sessions/connection 业务数据走 OOP 演进方向(§A.1 注记)。对话流不做。 +> 纪律:GUI 期间跳过仓库门禁(无测试/coverage/JSDoc 门),只求 typecheck 过 + 能跑。 + +--- + +## §A web-runtime 数据层 + +### §A.0 模块布局与导入纪律 + +``` +packages/client/web-runtime/src/ + index.ts ← 唯一出口:bootWebRuntime + store + intents + 本层类型 re-export + store.ts ← WebStore(rpcLog + 轻量 ui 态)+ zustand vanilla 模块单例(§A.1) + rpc-log.ts ← RpcLogEntry 类型 + tap→store 微任务批量泵 + 环形截断(§A.2) + connection.ts ← ConnectionController:boot 序列 + 重连退避循环(§A.3) + intents.ts ← intent 普通函数集(rpcLog 三件 + 面板演示触发,§A.4) + fixture.ts ← FixtureApi:无 server 时的假 ApiProxy(§A.5) + boot.ts ← bootWebRuntime(options):选 real/fixture、装 tap、起 controller(§A.6) +``` + +`packages/client/web-runtime/package.json` 的 `dependencies` 新增两项(step1 时该包零依赖): + +```json + "dependencies": { + "@deepseek-ai/dsh-apiproxy": "workspace:^", + "zustand": "~4.4.7" + } +``` + +**导入纪律(§C 会重申,两处冲突以本节为准)**: + +- 契约类型一律 type-only import 自 apiproxy 的 api 层:`import type { ... } from '@deepseek-ai/dsh-apiproxy/src/api/index.ts'`(借 step1 已有的 `"./src/*"` exports 通道;vite/tsx 均吃 src)。 +- 运行时值只允许两个来源:`createApiClient` 自 `@deepseek-ai/dsh-apiproxy/src/fetch/client.ts`;`RpcId()` 构造函数自 api 层(fixture 造假信封用;api/ 零 Node 依赖,浏览器可 import)。 +- **禁止 import `@deepseek-ai/dsh-apiproxy` 包根**——根出口 re-export `bootHost`,会把 cordis/Node 依赖拖进浏览器 bundle。 +- `SessionId` type-only import 自 `@deepseek-ai/dsh-session`(契约 id 纪律同款;类型擦除后 vite 不见此包,package.json 不加该依赖)。 +- zustand 只用 `zustand/vanilla` 的 `createStore`(本包无 React);`useStore` hook 属于 web-ui(§B)。 +- 不引 immer(deepseekchat 基线有、此处偏离):切片浅、手写 spread 足够,少一个依赖。 + +### §A.1 store:切片 TS 类型(store.ts 全文形状;2026-07-19 22:0x 拍板瘦身后) + +**拍板**:store 里不存复杂业务数据对象——sessions 列表/摘要、connection 状态机全都**不进 store**。本里程碑 store 只有两块:rpcLog(核心)+ 面板自身的轻量 view 态。 + +```ts +import { createStore } from 'zustand/vanilla' +import type { RpcLogEntry } from './rpc-log.ts' + +// ---- rpcLog 切片 ---- + +export interface RpcLogSlice { + /** 追加序(旧→新),长度 ≤ RPC_LOG_CAP(500),溢出丢最旧(§A.2)。 */ + entries: RpcLogEntry[] + /** 因溢出丢弃的累计条数(面板顶部「已丢弃 N 条」提示)。 */ + droppedCount: number + /** 面板折叠期间新到条数(角标徽标);面板展开瞬间清零,展开期间恒 0。 */ + unread: number + /** 只冻结面板自动跟随(§B 滚动行为),采集永不停。 */ + paused: boolean +} + +// ---- ui 切片(纯面板 view 态,无业务对象)---- + +export interface UiSlice { + rpcLogOpen: boolean +} + +// ---- 根 ---- + +export interface WebStore { + rpcLog: RpcLogSlice + ui: UiSlice +} + +/** 模块单例(不造 bridge:web-ui 直接 import 此 store 包 useStore)。 */ +export const store = createStore()(() => ({ + rpcLog: { entries: [], droppedCount: 0, unread: 0, paused: false }, + ui: { rpcLogOpen: false }, +})) +``` + +- 主题切换随左导航一起移出本里程碑(§E);`data-theme` 架构届时按 §E.4 变量表接入,本里程碑 global.css 只写 `:root` 亮色变量(面板要用色值)。 +- **变更纪律**:一切写入走 `store.setState()`(顶层浅合并);每次只重建被改的切片对象(spread),未动切片保持引用不变——§B selector 稳定性的前提。写入方只有 rpc-log.ts 泵与 intents;React 组件零写入。 + +**数据对象演进方向(拍板注记,本里程碑不定型)**:connection / session 这类「数据 + 操作」将走 OOP 化——runtime 层持有 `Connection` / `Session` 类实例(方法即操作,如 `session.prompt()`、`connection.reconnect()`),React 界面操作调用对象方法;React 需要的展示态届时经**窄投影**进 store(对象在状态迁移点写入最小标量,如 `connected: boolean`)或经 `useSyncExternalStore` 直接订阅对象自身的变更通知——两条路线届时定型,不在本文预设。因此 v1 版本文里的 ConnectionSlice / SessionsSlice / DraftSlice 已删除;ConnectionController(§A.3)保留但其状态不进 store。 + +### §A.2 rpcLog:tap 形状 + 环形 buffer(rpc-log.ts) + +**采集点唯一**:fetch 载体层咽喉——`createApiClient(fetchLike, options)` 的 `onEnvelope` 选项,**四象限 wire 单元**全过此口。契约 wire 模型已定型为四具名判别 union(用户拍板):**ClientRequest**(client 发起的 unary)/ **ServerResponse**(对它的应答)/ **ServerRequest**(SSE 帧,server 发起,含无需应答的 notify 子集;「帧」只是它的承载俗称)/ **ClientResponse**(client 对 ServerRequest 的应答——物理走 HTTP、payload 回填帧 rpcId、**不 mint 新 id**)。契约签名(ApiProxy 各域方法)零污染。 + +**onEnvelope 形状(W3 实装在 fetch/client.ts 并导出,本节是消费方规格;各支 envelope 的具体类型名以契约修订稿为准,本节锁定「四支分类 + 各带完整 wire 单元」的形状约定,分类字面量直接用四象限词汇)**: + +```ts +// 住 apiproxy fetch/client.ts(载体层类型,非契约 api/);web-runtime type-only import。 +export type ApiEnvelopeTapEvent = + | { kind: 'client-request'; envelope: /* 契约修订稿·ClientRequest wire 单元 */; method: string } + | { kind: 'server-response'; envelope: /* 契约修订稿·ServerResponse wire 单元 */; method: string } + | { kind: 'server-request'; envelope: /* 契约修订稿·ServerRequest wire 单元 */; stream: 'mux' | 'host' } + | { kind: 'client-response'; envelope: /* 契约修订稿·ClientResponse wire 单元 */; method: string } +export type ApiEnvelopeTap = (e: ApiEnvelopeTapEvent) => void + +export interface CreateApiClientOptions { onEnvelope?: ApiEnvelopeTap } +// createApiClient(fetchLike, options?: CreateApiClientOptions): ApiProxy +``` + +- 四支都要能取到 `rpcId` 与 payload(wire 单元自含);`method` 在 client 调用点天然可知,tap 带上省得面板查 pending 表。`stream` 字段只住 server-request 支(SSE 承载信息)。 +- **rpcId 归属**:client-request 的 rpcId 由 client mint,server-response 回显同 id;server-request 的 rpcId 由 server mint,client-response **回填同一 id、不 mint 新 id**——两族各自闭环,四象限在台账上完整可对账(§B.3 配对高亮)。 +- **tap 时机**:client-request=wire 单元构造后 fetch 前;server-response=parse 成功后、业务结果返回调用方前;server-request=parse 后、业务帧 yield 前;client-response=respond 调用的 wire 单元构造后发出前。transport 异常(fetch throw、流断)**不经 tap**——那是 ConnectionController 的事(§A.3)。 +- **v1 范围注记**:实际会产生 client-response 的只有审批/问答 respond(本里程碑 UI 不调用),实现可先留支不填——类型四支齐全,W3 接线时 respond 路径补 tap 即可。 +- **tap 不得反噬业务**:client 侧对每次 `onEnvelope` 调用包 try/catch 吞异常。 + +**日志条目(web-runtime 自己的展示模型,不是契约类型)**: + +```ts +export type RpcLogEntry = + | { id: number; at: number; kind: 'client-request'; rpcId: string; method: string; payload: unknown } + | { id: number; at: number; kind: 'server-response'; rpcId: string; method: string; ok: boolean; errorCode: string | null; payload: unknown } + | { id: number; at: number; kind: 'server-request'; rpcId: string; stream: 'mux' | 'host'; frameType: string; payload: unknown } + | { id: number; at: number; kind: 'client-response'; rpcId: string; method: string; payload: unknown } +``` + +- `id`:模块级单调计数器(React key + unread 计数依据);`at` = `Date.now()`(相对时间渲染在 §B 算)。 +- `rpcId`:`String(wire 单元的 rpcId)`(brand 只在类型层,运行时就是 string;展示截断在 §B)。client-response 支存的是**回填的帧 rpcId**(与其应答的 server-request 同值——配对高亮的关联键)。 +- `payload` 存**引用**不深拷贝不序列化:client-request→业务 payload、server-response→整个业务结果(RpcResponse 含 ok/error)、server-request→业务帧对象、client-response→respond 业务 payload(字段名按契约修订稿,映射在 toEntry 一处收口);JSON.stringify 只在面板行展开时做(§B)。 +- `ok`/`errorCode`/`frameType`:入表时反正规化(`result.ok`、`result.error.code`、`frame.type`),让行渲染不必探 payload。client-response 的 `method` = respond 方法名(`approval.respond`/`question.respond`),标注它属于哪个域。 +- v1 范围:client-response 支入表路径随 tap 同步留空(§A.2 注记),类型先齐。 + +**微任务批量泵 + 环形截断(防 SSE 帧风暴逐帧 setState)**: + +```ts +export const RPC_LOG_CAP = 500 +let nextId = 1 +let pendingBatch: RpcLogEntry[] = [] +let flushScheduled = false + +/** boot.ts 把它接到 onEnvelope(real 与 fixture 同一入口)。 */ +export function tapToStore(e: ApiEnvelopeTapEvent): void { + pendingBatch.push(toEntry(e)) // toEntry: 四支 tap 事件→四支条目的机械映射 + if (flushScheduled) return + flushScheduled = true + queueMicrotask(() => { + flushScheduled = false + const batch = pendingBatch + pendingBatch = [] + store.setState((s) => { + const merged = [...s.rpcLog.entries, ...batch] + const dropped = Math.max(0, merged.length - RPC_LOG_CAP) + return { rpcLog: { + ...s.rpcLog, + entries: dropped > 0 ? merged.slice(dropped) : merged, + droppedCount: s.rpcLog.droppedCount + dropped, + unread: s.ui.rpcLogOpen ? 0 : s.rpcLog.unread + batch.length, + } } + }) + }) +} +``` + +- `paused` 不影响采集(只管 §B 自动跟随),所以泵里不看它。 +- `clearRpcLog` intent(§A.4)要同时清 `pendingBatch`(防已排队批次在 clear 后复活)。 + +### §A.3 ConnectionController:生命周期(connection.ts;瘦身拍板后) + +保留理由:两条流必须有人打开并 `for await` 迭代(AsyncIterable 拉模式,没人拉就没人读 socket、tap 也不触发),rpcLog 才有帧可看。但**其状态不进 store**(拍板):phase/attempt/lastError 全部是类实例私有字段,将来 OOP 化(§A.1 注记)时它就是 `Connection` 对象的雏形。 + +```ts +export class ConnectionController { + constructor(private api: ApiProxy) {} + start(): void // 幂等;进入连接循环 + stop(): void // abort 当前代;循环退出 +} +``` + +**退避参数(写死为模块常量,不做配置)**: + +```ts +const BACKOFF_BASE_MS = 500 +const BACKOFF_FACTOR = 2 +const BACKOFF_MAX_MS = 10_000 +// 第 n 次重试延迟:cap = min(BACKOFF_MAX_MS, BASE * FACTOR^(n-1)); +// 实际取 [cap/2, cap] 均匀随机(半区间 jitter,防多标签页齐步重连)。 +// 无重试上限,断线永远在重试。 +``` + +**一次连接尝试(attempt)的序列**: + +1. 新建本代 `AbortController`(代号 = 模块级 generation 计数器自增;旧代残余任务的报错以代号比对丢弃——fencing)。 +2. **先开两条流**:`api.events.mux({}, signal)` 与 `api.events.host({}, signal)`,各起一个 `for await` 消费任务(不 await 完成)。**两条流的循环体都为空**——帧在载体层已被 tap 进 rpcLog,本里程碑无任何 UI 消费业务帧;仅 `stream/error` 帧视同流故障(进入重连),其余含未知类型一律忽略(documented-default)。 +3. **再发一条 unary**:`api.host.describe({})`(面板演示数据源,§A.4「pingHost」同一函数)。成败只影响日志台账与重连判定:`ok:false` 不判败(host 活着能回错误也是「通」);fetch throw 判败 → 进入重连。 +4. 常态驻留:流泵到 throw(断线)或 `stop()`。任一流 throw / 提前正常结束 → abort 本代(另一条流一并终止)、`console.warn` 一行、退避 sleep、回到 1。两条流同时炸只触发一次(generation fencing 天然去重)。 + +**连接状态投影(最窄)**:本里程碑砍掉左栏后没有任何组件展示连接态,**不设投影**。若 review 期要求面板标题栏带在线点,就地给一个 `connected: boolean` 进 UiSlice(`describe` 成功置 true、进入重连置 false),一行写入两行订阅——预留决策,不默认做。 + +**重连后的重建**:无跨代派生态可保留(store 无业务数据),重连 = 重跑序列 2–3,rpcLog 台账连续累积(不清空——断线前后的台账对照正是调试面板的价值)。契约 `host.describe` 无 host 实例标识的缺口(README 接缝 #1)在本收窄范围下影响进一步归零,仅留将来参考。 + +### §A.4 intent 函数集(intents.ts,完整清单;收窄后) + +intent = runtime 导出的普通函数(非 hook 非类),内部调 ApiClient + 写 store;React 组件只准调这些函数,自己不碰 api/store 写入。模块顶部 `let api: ApiProxy`,由 `bindIntents(api)`(boot 专用,导出但仅 boot.ts 调)注入。intent 内只构造业务 payload 并调 ApiProxy 方法;rpcId 的 mint 与 wire 单元包装(契约修订稿的 RpcRequest 封装)由 client 封装层收口,intent 不感知。 + +| 函数 | 签名 | 行为 | +|---|---|---| +| `bindIntents` | `(api: ApiProxy) => void` | boot 注入 api 引用;重复调用覆盖(fixture/real 切换仅发生在 boot,运行中不换) | +| `openRpcLog` | `() => void` | `rpcLogOpen=true` 且 `rpcLog.unread=0`(展开即已读) | +| `closeRpcLog` | `() => void` | `rpcLogOpen=false` | +| `toggleRpcLog` | `() => void` | 按当前值分派上两者 | +| `setRpcLogPaused` | `(paused: boolean) => void` | 只写 `rpcLog.paused`(冻结面板自动跟随;采集不停,§A.2) | +| `clearRpcLog` | `() => void` | `entries=[]`、`droppedCount=0`、`unread=0`,并调 rpc-log.ts 导出的 `clearPending()`(清微任务批次,防清空后复活) | +| `pingHost` | `() => Promise` | **面板演示数据源**:调 `api.host.describe({})`,结果不落任何 store——它的产出就是 rpcLog 里的一对 client-request/server-response。boot 序列自动调一次(§A.3 序列 3),面板工具行的「ping」dev 按钮手动再触发(§B.3) | + +演示数据源拍板落地:boot 自动 describe 一次 **且** 面板留 ping 按钮——自动那次保证「打开页面即有台账」,按钮让演示者随时再造一对往返(同一个 `pingHost`,无第二实现)。左导航相关 intent(refreshSessions/createSession/selectSession/openSettings/toggleTheme)移至 §E(下一里程碑素材);将来加 = 此表加行,模式不变。 + +### §A.5 fixture 模式(fixture.ts):无 server 时 UI 可独立开发 + +两件套:**假 ApiProxy** + **假信封包装器**(让调试面板在 fixture 下也有台账可看)。 + +```ts +/** 内存假 host:3 个预置 session(只为让 host 流有 status 帧可翻转)。 */ +export function createFixtureApi(): ApiProxy +``` + +- 预置数据:3 条内存 `SessionSummary`(id 为 `'fx-alpha' | 'fx-beta' | 'fx-gamma'` cast `SessionId`,`running` 分别 true/false/false)。收窄后没有列表 UI 消费它们,其唯一作用是给 host 流当翻转素材、给 `session.list`(若被调)当返回值。 +- `host.describe` → `{ version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }`(pingHost 的应答体,面板台账主角)。 +- `sessions.list` → 内存表倒序;`create` → 新增并从 host 流吐 `session/added`;`history` → `{ events: [], hasMore: false }`;`prompt`/`cancel`/`approvals.respond`/`questions.respond` → `{ ok: true, value: { accepted: true } }` 空实现——本里程碑 UI 都不调,实现只为满足 ApiProxy 接口类型。 +- `events.host` 流:打开后每 5s 随机挑一个 session 翻转 `running` 并吐 `host/session-status`(**面板滚动的周期素材**);`signal` abort 时结束。 +- `events.mux` 流:打开时对每个 running session 吐一条 `session/subscribed`(`lastSeq: 0`)然后静默挂起到 abort(形状最简、不伪造 SessionEvent)。 + +```ts +/** fixture 专用:real 模式的 wire 单元在载体层天然存在,fixture 直连 ApiProxy 没有, + * 此包装器在 api 面上补造三种 tap 事件,喂同一个 tapToStore。 */ +export function wrapApiWithFakeEnvelopes(api: ApiProxy, tap: ApiEnvelopeTap): ApiProxy +``` + +- unary 各方法包一层:mint 假 rpcId(`fx-rpc-<计数>` cast;契约修订稿的 `RpcId()` 构造函数可用就用真的)→ `tap({kind:'client-request', ...})` → await 原方法 → `tap({kind:'server-response', ..., method})` → 透传返回值。 +- 两条流包一层:逐帧 mint 帧 rpcId、按 §A.2 形状补造 server-request 事件后 yield(帧 rpcId 本应由 server mint,fixture 里包装器就是「假 server」,语义自洽)。client-response 支 v1 无产生路径(§A.2 注记),fixture 不造。 +- **升级路径**:W1(api 代码)+ W3(fetch 载体)落地后,fixture 可改走同构管道 `createApiClient(toFetchHandler(fixtureImpl).fetch, { onEnvelope })`——wire 单元由真载体层生成,本包装器整体删除。设计上 fixture.ts 与 boot.ts 之外无人知道包装器存在,删除零波及。 + +### §A.6 boot(boot.ts + apps/web 接线) + +```ts +export interface BootWebRuntimeOptions { + mode: 'real' | 'fixture' +} +export interface WebRuntimeHandle { stop(): void } + +export function bootWebRuntime(options: BootWebRuntimeOptions): WebRuntimeHandle { + const api = options.mode === 'fixture' + ? wrapApiWithFakeEnvelopes(createFixtureApi(), tapToStore) + : createApiClient((input, init) => globalThis.fetch(input, init), { onEnvelope: tapToStore }) + bindIntents(api) + const controller = new ConnectionController(api) + controller.start() // 内部序列含首次 pingHost(§A.3/§A.4:面板演示数据源) + return { stop: () => controller.stop() } +} +``` + +- real 模式 fetchLike 用箭头包装 `globalThis.fetch`(防 Illegal invocation);wire 路径 `/api/*` 是相对路径,同源部署(dsc 同时服务静态与 API)下无需 baseUrl——step1 `Runtime.baseUrl` 概念删除。 +- unary 超时(`AbortSignal.timeout`)是 W3 在 createApiClient 内部的职责(契约 §5 已定),本层不重复包。 +- `index.ts` 出口:`bootWebRuntime`、`store`、`WebStore`/`RpcLogSlice`/`UiSlice`、`RpcLogEntry`、§A.4 表中全部 intent 函数。**不导出** ConnectionController / fixture 内部件(boot 是唯一组装点)。 +- `apps/web/src/main.ts` 改写(step1 的 createRuntime/mount(el, runtime) 签名废弃): + +```ts +import { bootWebRuntime } from '@deepseek-ai/dsh-web-runtime' +import { mount } from '@deepseek-ai/dsh-web-ui' + +const el = document.getElementById('root') +if (el === null) throw new Error('missing #root') +bootWebRuntime({ mode: new URLSearchParams(location.search).has('fixture') ? 'fixture' : 'real' }) +mount(el) // mount 不再收 runtime:组件直连 store 单例(§B) +``` + +fixture 开关 = URL query `?fixture`(无构建期开关,`pnpm --filter @deepseek-ai/dsc-web build` 一份产物两用;W5 开发期直接 vite 起 apps/web 加 `?fixture` 即可,不需要 host)。 + +--- + +## §B web-ui 组件层(收窄后:最简 App 壳 + RPC 调试面板,仅此) + +### §B.0 文件全清单与包配置 + +``` +packages/client/web-ui/src/ + index.tsx ← mount(el):createRoot(el).render()(不再收 runtime 参数) + use-web.ts ← 订阅 hook(§B.1) + utils/formatRelative.ts ← formatRelative(now, at): string(§B.3 时间列;纯函数工具一律住 utils/,不散落组件文件内) + css-modules.d.ts ← declare module '*.module.css' { const c: Record; export default c } + style/global.css ← reset + :root 亮色 CSS 变量(§B.5)+ body 字体;index.tsx 顶部 import + App.tsx / App.module.css ← 最简壳(§B.2) + components/ + panels/ ← panel 概念的可扩展位:将来 Settings、诊断等各占一个子目录 + RpcLog/RpcLog.tsx + RpcLog.module.css ← 浮动壳:open ? : + RpcLog/RpcLogBadge.tsx ← 折叠角标(未读徽标) + RpcLog/RpcLogBody.tsx ← 展开浮层(工具行+滚动列表+自动跟随) + RpcLog/LogRow.tsx ← 单条台账行;props { entry: RpcLogEntry; now: number; paired: boolean; onHover(rpcId: string | null): void };React.memo + RpcLog/PayloadJson.tsx ← 展开的完整 JSON;props { payload: unknown } +``` + +- **utils/ 归属定在 web-ui 而非 web-runtime**:`formatRelative` 唯一消费方是渲染层(LogRow 时间列),runtime 不做任何展示格式化——按消费方就近。将来 runtime 层出现自己的纯工具(如退避计算抽函数)同理在 web-runtime 建 utils/,两包各自就近、不共享工具包。 +- (Sidebar/ 与 Main/ 组件族已随范围收窄移出本里程碑,设计素材保留在 §E。) + +deepseekchat 习惯对齐:每组件一目录、`Foo.tsx + Foo.module.css` 同名同目录、clsx 拼类名;偏离项:不用 typed-css-modules 生成 `.css.d.ts`(门禁跳过期用 `css-modules.d.ts` 一张全局声明顶替,类名无编译期校验,接受)。 + +`packages/client/web-ui/package.json` dependencies 增量(react/react-dom/@types 已有): + +```json + "@deepseek-ai/dsh-web-runtime": "workspace:^", // 已有(step1) + "clsx": "^2.0.0", + "zustand": "~4.4.7" +``` + +apps/web 增量:`devDependencies` 加 `"postcss-nested": "^6.0.0"`,包根新建 `postcss.config.cjs`: + +```js +module.exports = { plugins: { 'postcss-nested': {} } } +``` + +(vite 内建 CSS Modules + postcss 装载,无需改 vite.config.ts;PostCSS 面上只要 nested——custom-media/autoprefixer 等 deepseekchat 全家桶暂不引,本里程碑用不上。) + +### §B.1 订阅 hook 与订阅纪律(use-web.ts) + +```ts +import { useStore } from 'zustand' +import { shallow } from 'zustand/shallow' +import { store, type WebStore } from '@deepseek-ai/dsh-web-runtime' + +/** 唯一订阅入口:绑定 runtime 的 store 单例。zustand ~4.4 的三参 useStore 支持 equalityFn。 */ +export function useWeb(selector: (s: WebStore) => T, equalityFn?: (a: T, b: T) => boolean): T { + return useStore(store, selector, equalityFn) +} +export { shallow } +``` + +纪律:组件**只准**经 `useWeb` 读 store、经 web-runtime 导出的 intent 函数写(事件处理器里直接调用);不准 `store.setState` / `store.getState`(例外:无。渲染取值一律走 hook 保证订阅)。selector 返回派生对象/数组时必须配 `shallow`;返回原始值或 store 内既有引用则不用。 + +### §B.2 App 最简壳 + +**App.tsx**:无订阅,纯结构——能挂面板即可。 + +```tsx +

+
+

dsc web

+

RPC debug milestone

+
+ +
+``` + +**App.module.css 要点**: + +```css +.app { + height: 100vh; + background: var(--color-bg); + color: var(--color-text); +} +.blank { + height: 100%; + display: grid; + place-items: center; + text-align: center; + color: var(--color-text-secondary); +} +``` + +(左栏网格、Sidebar 三段式、MainArea 三分支的设计素材见 §E.2——本里程碑不建这些文件。) + +### §B.3 RPC 调试面板:完整交互规格(RpcLog/) + +**形态(已拍板:强浮动,readonly 纯观察)**:不占布局流。RpcLog.tsx 只做分支: + +```tsx +export function RpcLog() { + const open = useWeb((s) => s.ui.rpcLogOpen) + return open ? : +} +``` + +**定位与 z-index(RpcLog.module.css)**: + +```css +.badge { /* 折叠角标 */ + position: fixed; right: 16px; bottom: 16px; z-index: 100; + /* 圆角胶囊按钮:「RPC」字样 + 未读徽标 */ +} +.panel { /* 展开浮层,盖在内容之上 */ + position: fixed; right: 16px; bottom: 16px; z-index: 100; + width: min(560px, calc(100vw - 32px)); + height: min(420px, calc(100vh - 32px)); + display: flex; flex-direction: column; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: var(--shadow-panel); +} +``` + +z-index 全应用只此一层浮动物,100 即可;无 backdrop、无 focus trap(不是 modal,点外部不关闭)。 + +**RpcLogBadge(折叠态)**:胶囊按钮,`onClick={openRpcLog}`。内容 = `RPC` 字样 + 未读徽标(`unread > 0` 时显示红底白字小圆,`unread > 99` 显示 `99+`)。订阅只有 `s.rpcLog.unread`。 + +**RpcLogBody(展开态)**:纵向三段。 + +1. **工具行**(固定高):左 = 标题「RPC」+ 灰字统计 `{entries.length} 条`(`droppedCount > 0` 时追加 `· 已丢弃 {droppedCount}`);右 = 四按钮: + - ping:`onClick={() => void pingHost()}`(dev 演示按钮:手动造一对 describe 往返进台账,§A.4;面板唯一会发 RPC 的控件,发的是只读快照查询,不破 readonly 语义)。 + - 暂停/继续:`onClick={() => setRpcLogPaused(!paused)}`;paused 时按钮高亮且列表顶部出现细条提示「已暂停跟随」。 + - 清空:`onClick={clearRpcLog}`(只写本地日志态,不发任何 RPC)。 + - 关闭 ×:`onClick={closeRpcLog}`。 +2. **滚动列表**(`flex: 1; overflow-y: auto; font-family: var(--font-mono); font-size: 12px`):`entries.map(e => )`。 +3. 无第三段(无输入区——readonly)。 + +**LogRow 行结构(单行网格:方向 | 标签 | rpcId | 相对时间)**——方向列直接可视化四象限模型: + +| 列 | 内容 | +|---|---| +| 方向 | `client-request`→`→`(accent 色);`server-response`→`←`(ok 绿 / !ok 红);`server-request`→`⇐`(mux 紫 / host 蓝——server 发起进站);`client-response`→`⇒`(accent 色空心/淡化变体——client 应答出站,与 `→` 同向不同族) | +| 标签 | client-request / server-response / client-response→`method`(server-response 且 !ok 时追加红字 `errorCode`);server-request→`frameType` | +| rpcId | `slice(0, 8)`,`title` 属性挂全值,灰色 | +| 时间 | `formatRelative(now, entry.at)`:`<10s`→「刚刚」、`<60s`→「Ns 前」、`<60min`→「Nmin 前」、否则本地 `HH:MM:SS`。`now` 来自 RpcLogBody 一个 30s `setInterval` 的 state tick(只在面板展开时运行,卸载即清) | + +- **同 rpcId 配对高亮(按族)**——四象限有两个配对族:**client-request ↔ server-response**(client mint 的 id 闭环)与 **server-request ↔ client-response**(server mint、respond 回填的 id 闭环)。RpcLogBody 持 `hoveredRpcId` state(`useState`),LogRow 鼠标进出上报;高亮条件 = `entry.rpcId === hoveredRpcId` **且与 hover 行同族**(族判定:kind ∈ {client-request, server-response} 为一族,kind ∈ {server-request, client-response} 为另一族——两族 id 由不同方 mint、空间独立,理论无碰撞,同族约束让语义显式)。命中行加浅 accent 底色类。一个 state 一个类名,不做连线等重装饰;hover 一条 requested 帧看到它的 respond 应答同亮,正是四象限模型的核心演示价值。 +- 行 `onClick` 翻转本行展开态(`useState` 在 LogRow 内部——展开态是纯视图态,不进 store;折叠面板/清空自然重置)。 +- **展开区(PayloadJson)**:`JSON.stringify(payload, null, 2)` 在 `useMemo` 内计算,`
` 渲染,`max-height: 200px; overflow: auto`。**大 payload 截断**:字符串化结果 `> 20_000` 字符时只渲前 20_000 + 尾行「… 已截断,共 {N} 字符」(不做「点开完整」二级展开——行内已给全量滚动区,截断只防单帧几 MB 卡死渲染;stringify 对 BigInt/循环引用 throw 时 catch 后渲 `String(payload)`)。
+- `LogRow` 用 `React.memo` 包裹:entries 数组每批追加都换引用(list re-render),但旧行的 `entry` 对象引用不变,memo 让旧行跳过重渲(时间列相对性由 tick 驱动 RpcLogBody 重渲、传 `now` prop 给行——`now` 变化时行会重渲,接受:30s 一次、可视行数有限)。
+
+**自动跟随(跟随最新,可暂停)**:
+
+- RpcLogBody 持 `listRef`;`useEffect` 依赖 `[entries, paused]`:`!paused` 时 `listRef.current.scrollTop = scrollHeight`(无平滑动画,日志面板要快)。
+- **手动上滚即暂停**:列表 `onScroll` 中,若 `!paused` 且滚离底部超过 24px(`scrollHeight - scrollTop - clientHeight > 24`)→ `setRpcLogPaused(true)`。程序化滚动本身触发的 onScroll 事件因距底 0px 不满足阈值,天然不误触发。「继续」按钮解除并立即滚底。
+- 面板展开瞬间(RpcLogBody mount 的 `useEffect([])`)滚到底一次。
+
+**性能边界(写给实现者的红线)**:500 条 cap(§A.2)+ 行内 memo + JSON 只在点开时 stringify + 微任务批量泵——四道闸后,帧风暴下面板的每秒 setState 次数 ≈ 事件循环微任务批次数,列表 DOM ≤ 500 行,无虚拟滚动的必要;**不要**引 react-window 等虚拟列表依赖。
+
+### §B.4 selector 订阅表(re-render 边界,逐组件;收窄后)
+
+| 组件 | 订阅 | equalityFn | 重渲当且仅当 |
+|---|---|---|---|
+| App | 无 | — | 从不 |
+| RpcLog | `s.ui.rpcLogOpen` | 默认 | 开合 |
+| RpcLogBadge | `s.rpcLog.unread` | 默认 | 未读数变 |
+| RpcLogBody | `s.rpcLog`(整切片:entries/paused/droppedCount 全用) | 默认 | 日志批次到达 / 暂停翻转 / 清空 |
+| LogRow | 无订阅(props: entry/now/paired/onHover;React.memo) | — | 自身展开态、`now` tick、`paired` 翻转、entry 引用变(不会发生——条目不可变)。`onHover` 用 `useCallback` 稳定引用,否则 memo 失效 |
+
+设计原则(供 review 对照):**列表壳订数组、行吃引用稳定性**——entries 每批追加换数组引用(壳重渲做 map),旧行 entry 引用不变靠 memo 跳过;hover 配对高亮只翻转受影响行的 `paired` prop(其余行 memo 命中)。(左导航组件族的订阅表随素材移至 §E.2。)
+
+### §B.5 CSS 变量表(style/global.css;收窄后只落 `:root` 亮色)
+
+主题切换随左导航移出本里程碑(§A.1 拍板注记):本里程碑 global.css **只写 `:root` 亮色实值**,不写 `[data-theme='dark']` 块、不设切换按钮;双主题架构(dark 占位块 + 名集一致纪律 + toggleTheme 接线)整套素材在 §E.4,下一里程碑原样接入,变量名从现在起就按双主题口径起名(无「light」前缀之类的单主题假设)。
+
+```css
+:root {
+  /* 表面 */
+  --color-bg: #ffffff;             /* 页面底 */
+  --color-bg-elevated: #ffffff;    /* 浮层底(调试面板) */
+  --color-hover: #ececee;          /* hover 面 */
+  --color-border: #e2e2e6;
+  /* 文字 */
+  --color-text: #1a1a1e;
+  --color-text-secondary: #8a8a93;
+  /* 强调(deepseek 蓝系近似值,无品牌规范包袱) */
+  --color-accent: #4d6bfe;
+  --color-accent-soft: #e8edff;    /* 配对高亮底(§B.3) */
+  /* 语义 */
+  --color-ok: #22a06b;             /* response ok 方向符 */
+  --color-error: #d63841;          /* response !ok / 未读徽标底 */
+  /* 调试面板方向色 */
+  --color-frame-mux: #8250df;
+  --color-frame-host: #0969da;
+  /* 杂项 */
+  --shadow-panel: 0 8px 24px rgba(0, 0, 0, 0.12);
+  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+```
+
+global.css 另含:`* { box-sizing: border-box }`、`body { margin: 0; font-family: system-ui 栈; background: var(--color-bg); color: var(--color-text) }`、`button` reset(继承字体、无默认边框底色)。
+
+---
+
+## §C 对齐纪律(web-ui ↔ web-runtime ↔ 契约)
+
+1. **类型引用方向单向**:web-ui 只准 import `@deepseek-ai/dsh-web-runtime`(`WebStore`/`RpcLogSlice`/`UiSlice`、`RpcLogEntry`、intent 函数、`store` 单例)。web-ui **禁止**直接 import `@deepseek-ai/dsh-apiproxy` 任何路径——契约类型到 UI 必须经 §A 转译(UI 不认识契约 wire 单元类型,只认识 `RpcLogEntry`)。
+2. **契约类型只从 api/ 进**:web-runtime 中一切契约类型 `import type` 自 `@deepseek-ai/dsh-apiproxy/src/api/index.ts`;运行时值仅 `createApiClient`(fetch/client.ts)与 `RpcId()`(api/rpc.ts)两个(§A.0)。不准从 impl/ import 任何东西。契约具体类型名以 apiproxy-design 的**修订稿**(严格双向 RPC 版)为准,本文弱引用处(§A.2)实现时对照落名。
+3. **两处不一致以 §A 为准**:§B 提到的任何 store 字段/intent 名以 §A.1/§A.4 定义为权威;若实现中发现 §B 引用了 §A 没有的字段,按 §A 改 §B 侧用法并回报设计 owner,不准擅自往 store 加字段。
+4. **与契约的不一致同理升级**:若 W1 落地的真实契约代码与本文引用的类型名/形状冲突,以契约修订稿为准修正本文;发现文档层面缺口走 README「接缝问题」上报,不擅改契约。
+5. **写路径唯一**:store 写入只发生在 web-runtime(intents/rpc-log 泵);web-ui 零 `setState`。新交互 = 先在 §A.4 表加 intent,再在组件接线。
+6. **fixture 与 real 的行为等价面**:UI 代码不感知 mode(没有 `if (fixture)`);两模式差异全部收在 boot.ts 的 api 装配一处(§A.6)。
+7. **store 无业务对象红线**(22:0x 拍板):任何人往 store 加 session/connection 业务数据切片都是设计违例——那类数据走 §A.1「数据对象演进方向」的 OOP 路线,先找设计 owner 定型。
+
+## §D 验收清单(浏览器手工,配合 W1–W3 完成度分级;面板口径)
+
+| # | 前置 | 步骤 | 期望 |
+|---|---|---|---|
+| 1 | 仅 W4/W5 落码(无 server) | vite dev 起 apps/web,开 `?fixture` | 页面见「dsc web」占位 + 右下角 RPC 角标;未读数含 boot 自动 ping 的一对往返 + subscribed/status 帧,且随 5s 周期帧增长 |
+| 2 | 同上 | 点角标展开面板 | 台账见:两条流打开后的帧(subscribed×running 数 + 周期 status)+ `host.describe` 往返一对;未读清零 |
+| 3 | 同上 | 点「ping」 | 新增一对 client-request/server-response(method=host.describe,方向符 →/←);hover 任一行时同 rpcId 同族的配对行同步高亮 |
+| 4 | 同上 | 点任意行 | JSON payload 展开;再点收起 |
+| 5 | 同上 | 上滚列表 | 自动跟随暂停(按钮态同步);点「继续」回到底部跟随 |
+| 6 | 同上 | 点「清空」 | 列表空、统计归零;周期新帧继续进入 |
+| 7 | W1+W2+W3+dsc 接线全通 | `pnpm run demo:web` 起真 host,浏览器开首页(无 `?fixture`) | 面板见真 RPC 台账:mux/host 两流开流帧 + describe 往返滚动 |
+| 8 | 同上 | kill dsc 再重启 | 台账停止增长后恢复增长(重连重开流的新一轮帧进入;台账不清空,断线前后连续可对照) |
+
+---
+
+## §E 下一里程碑素材(22:0x 范围收窄拍板降级;保留不删,非本里程碑交付物)
+
+以下内容是 v1 稿为「布局分区」写的设计,随「只做 RPC 面板」拍板整体移出交付范围。下一里程碑启用前需先按届时的 OOP 数据对象定型(§A.1 注记)改写数据来源——**组件结构/CSS 可直接复用,selector/数据源部分肯定要改**(v1 稿假设 sessions/connection 住 store,已被推翻)。
+
+### §E.1 移出的 store 切片与 intents(v1 原案,仅供参考,数据源待 OOP 定型重写)
+
+- ConnectionSlice(phase/attempt/lastError/host 快照)、SessionsSlice(ids + byId + listLoaded/listError)、DraftSlice、UiSlice 的 view(blank/session/settings 三分支)与 theme —— OOP 化后这些以对象 + 窄投影/useSyncExternalStore 供给,不再是切片。
+- intents:refreshSessions(单飞合流 + 收尾核对选中)、createSession(create→刷新→选中)、selectSession、openSettings、toggleTheme(唯一 DOM 触点 `data-theme`)。
+
+### §E.2 移出的组件族(组件结构与 CSS 要点可复用)
+
+- 布局:App 改 grid 两列 `var(--sidebar-width) minmax(0, 1fr)`、高 100vh。
+- Sidebar 三段式:品牌行(dsc 字标 + ConnectionBadge 状态点:online 绿/connecting 黄/reconnecting 黄闪 + 次数)|SessionList(`flex:1; min-height:0; overflow-y:auto` 唯一滚动区;标题行右侧「+」新建;载入/错误/空三态)|SidebarFooter(Settings 入口 + 主题切换钮,横排两半,border-top)。
+- SessionListItem 两行布局:mono 截断 id + running 绿点|cwd 次要色 ellipsis;选中态 `--color-accent-soft`、hover `--color-hover`。
+- MainArea 按 view 三分支居中占位(blank 渲 host 快照小字/session 占位/settings 占位)。
+- 订阅纪律要点(届时按新数据源重写):列表壳只订 id 数组、行订 byId 单条——running 翻转只重渲该行。
+
+### §E.3 移出的验收项:左栏列表三态、新建即选中、Settings 切换、主题翻转(v1 §D 1/6/7 项)。
+
+### §E.4 双主题接入包(架构已定、亮色先行的原拍板在收窄后顺延至此)
+
+- `[data-theme='dark']` 占位块(值粗糙可用):bg #1e1f24 / bg-elevated #26272e / hover #2e2f36 / border #3a3b42 / text #e6e6ea / secondary #8a8a93 / accent #6b84ff / accent-soft #2b3560 / ok #3fb884 / error #e05c66 / frame-mux #a37cf0 / frame-host #539bf5 / shadow-panel 0 8px 24px rgba(0,0,0,.5);另补 sidebar 底色变量(亮 #f7f7f8 / 暗 #17181c)与 warn 色(#e8a13c 双主题同值)。
+- 纪律:dark 块与 `:root` **主题变量名集完全一致**(缺名静默漏亮色值,坏得无声);`--font-mono`/`--sidebar-width` 等主题无关变量只在 `:root` 声明,两块各加一行注释标注边界。
+- 接线:toggleTheme intent 写 ``,boot 用同一 `applyTheme` helper 设初值;切换按钮保留可点不藏不禁用(用户明示,暗色难看也放着)。
+
+---
+
+## 附:与既有拍板的对照索引(review 用,不新增决策)
+
+- 强浮动/readonly/环形 500/暂停+清空 —— step2 README「UI 六问」Q2/Q5 + 任务书。
+- **只做 RPC 面板、store 瘦身(无业务对象、OOP 演进)—— 2026-07-19 22:0x 两条拍板(经 team-lead 转达),覆盖六问中 Q1/Q3/Q4 的本里程碑执行(素材降级 §E)。**
+- **严格双向 RPC(签名收 RpcRequest 封装、帧=server request、respond 回填 rpcId)—— 22:0x 契约变更(apiproxy-design 修订中),本文以弱引用消费(§A.2)。**
+- CSS Modules + PostCSS + clsx 无组件库 —— Q6(deepseekchat 基线文档)。
+- React 不碰流/不发请求、intent 普通函数、useStore 直连无 bridge —— step2 README「已定 React 架构」。
+- onEnvelope 咽喉 tap、契约零污染 —— 同上。
diff --git a/missions/tasks/20260719-2247-step-session-design/README.md b/missions/tasks/20260719-2247-step-session-design/README.md
new file mode 100644
index 0000000000..a839724a97
--- /dev/null
+++ b/missions/tasks/20260719-2247-step-session-design/README.md
@@ -0,0 +1,44 @@
+# step-session 里程碑设计(session 列表 + 对话流)
+
+设计「session 左侧列表 + 单 session 对话流」里程碑的实现级设计文档。排在 RPC 调试面板里程碑之后实施。核心命题:Session 面向对象 + 逻辑面与 UI 展示面分离。只写文档不写代码。
+
+## 进展
+
+| 时间 | 事项 |
+| --- | --- |
+| 2026-07-19 22:52 | 归档目录建立,开始读上下文 |
+| 2026-07-19 23:05 | 上下文读毕(契约 v2.0 + api/ 真码 + RPC 面板设计 + surface.ts + ui-product);design.md §A.0–§A.4 落盘(对象层布局/Session/SessionManager/快照类型) |
+| 2026-07-19 23:14 | §A.5–§A.9 落盘(fold 适配 padding 方案/chunk 累积器/store·intents 增量/boot 装配/通知合批+帧分发表);§A 完 |
+| 2026-07-19 23:20 | §B 落盘(useSessionList/useConversation/useSelectedSession 实现规格 + uSES 防撕裂清单);hook 层住 web-ui/hooks/,分界即包界 |
+| 2026-07-19 23:28 | §C 落盘(容器/View 全 props 契约:列表+对话流+工具卡+占位卡+InputBar;滚动锚定算法;App 两列改版) |
+| 2026-07-19 23:36 | §D–§G 落盘(契约消费清单/翻页数据路径/缝合规则/成本模型/两级验收/不做清单/工单切分);**v1 完稿待 review** |
+| 2026-07-20 00:0x | **review 整改 #3(v1.2)**:§F 升级架构妥协台账——12 条妥协各带【触发条件→返工点→预埋要求】(F.1 视图态 vs 虚拟列表、F.2 tool 卡 key vs 动画、F.3 replace 降级 vs 契约补语义、F.4 PAGE_MESSAGES vs 配置化 + 8 条自查隐性妥协),纯范围排除收 F.13 一行;F.10/F.11 预埋(PendingCard.onRespond?/MessageText 单组件)同步写进 §C。单例边界澄清入 §A.7:SessionManager 单例维持,只革视图选中态。**设计定稿,免二次 review,转入实现(本 teammate 任 owner)** |
+| 2026-07-19 23:5x | **review 整改(v1.1)**:①选中态去全局单例——selectedSessionId 移出 zustand 归 SessionsScreen 容器 useState(多视图前瞻:分屏=多实例各持选中态);drafts 同题裁定挂 Session 对象(per-session 数据跟对象走,容器局部会随 key 重挂载丢稿)——store 本里程碑零增量,useSelectedSession/selectSession/drafts intents 删除,§A.0/§A.1/§A.2/§A.4/§A.7/§B/§C 联动改写。②新增 §D.5 core 对齐对照表——方法链+数据推导逐条直核 core 源码 file:line;0 红线,2 个注意点(ToolCallBlock 字段名 id/arguments 与事件 callId 命名不一致;AgentStatus 三态→running 二态投影);接缝 #4(SurfaceManager 出口)顺带核实收口 |
+
+| 2026-07-20 00:3x | **实现 S1+S2 落盘 tsc 绿**:web-runtime session/ 六文件(conversation/fold-adapter/partial/notifier/session/lineage/manager)+ connection sinks + boot/intents/index 增量 + api-types 扩展(RpcRequest<帧> 窄形、SessionEvent/ContentBlock 真 core 类型)+ fixture 全重写(fx-alpha 60turn 历史脚本、prompt 打字机回放、cancel 中断、常驻 pending approval、fx-beta 子 session 谱系)。tsconfig 加 llm/session/brand references,package.json 加两 workspace dep |
+
+| 2026-07-20 01:2x | **W3 真契约对接 + S3 组件层 + S4 验收全绿**:api-types.ts 删除→api.ts 集中转口(apiproxy 新增 ./api、./client 浏览器安全出口;dsh-session 新增 ./surface 出口);rpc-log/fixture/connection/session/manager 全链改真四象限形(unary RpcRequest→RpcResponse 回显、流 RpcRequest<帧>、根 respond/RpcReceipt);hooks 2 文件 + 组件 11 文件(SessionsScreen 局部选中态/翻页锚定/工具卡双态/占位卡/InputBar);fixture host 流加 fx-gamma 5s 翻转(面板 §D-6b 素材);Manager 加审批/问答帧未实例化缓冲(pending 不落 history 的 F.7 例外面)。client.ts 修浏览器 base(dsh.internal→location.origin,Node 注入不变)。**验收:verify-session.mjs 31/31 PASS(fixture 级全清单)+ verify-session-real.mjs 5/5 PASS(真 host:真列表/真历史/真模型流式回显)+ verify-rpclog-panel.mjs 10/10 PASS(面板零回归)**;web-runtime/web-ui/apiproxy 三包 tsc 绿 |
+
+| 2026-07-20 02:3x | **重连风暴 bug 修复 + 注释英文化**。根因=node:http 桥接层:`req.on('close')` 自 Node 16 起在请求体读完即触发(无体 GET 立即),两条 SSE 一开即被 client abort→循环重连(12s 132 请求);fixture 假流不走桥接故三脚本全绿掩盖。修复=改挂 `res.on('close')` + `writableEnded` 区分正常结束(落在 webserver/src/index.ts——step1-design 拆包后的新家,bin.ts 旧址改动随拆包废弃;注释写明 Node 16 语义防回归)。verify-session-real.mjs 增 E2-0a/b 连接稳定性断言(12s ≤10 请求+零 abort,防 fixture 掩盖类 bug)。同批:我 touch 过的 web-runtime/web-ui/scripts 全部代码注释翻英文(新纪律:注释英文、文档中文;fixture 数据字符串与 UI 文案保持中文——产品语言非注释)。**验证:12s 请求 132→4、零 abort;三脚本 31/31+10/10+7/7 全绿**;真 host 验证 E2-1 改走「+」新建(fresh host 列表空是 impl 已知 TODO 非本层 bug) |
+
+| 2026-07-20 03:2x | **rpcId caller 视图(形状 a)落地**:createApiClient 返回新类型 CallerApi(unary=payload 直传、载体内 mint+包封;泛型面从 RpcMethodMap 的 RequestPayload/ResponseValue 机械导出;流=payload+signal;respond=ClientResponse 透传不 mint)——契约 ApiProxy 签名零改动,impl 侧照旧。web-runtime 全调用点去 rpcRequest 包裹(session/manager/connection/intents 持 CallerApi);rpcRequest 降级 carrier-internal(唯一消费者=fixture 假载体,wrapApiWithFakeEnvelopes 改吐 CallerApi 与真载体同形)。验证:apiproxy/web-runtime/web-ui tsc 绿 + 三脚本 31/31+10/10+7/7 全绿。headless.ts(step1-design 属地)3 处调用点如预期破——已回执 main 转派 |
+
+| 2026-07-20 04:5x | **泵方向反转(用户架构纠正)**:批量缓冲从 rpc-log 模块级 let(pendingBatch/flushScheduled——多实例/测试串台隐患)收进 AbstractApiClient 实例——envelope 观测升格数据中间层正式切面:实例私有 batch+微任务 flush+`subscribeEnvelopes(listener)` 订阅面(listener 异常隔离,观测不得破坏载体);onEnvelope 保留为逐条虚方法(内喂缓冲,无订阅者零成本)。rpc-log 降级纯订阅者:`ingestEnvelopeBatch` 只做 batch→RpcLogEntry 映射+环形截断入 store(nextId 留模块级——纯展示 key 非 wire 态,注明理由);tapToStore/clearPending 删除,WebApiClient/FixtureApiClient 的 tap 构造参数删除(boot 里 `api.subscribeEnvelopes(ingestEnvelopeBatch)` 一行接线)。三包 tsc 绿+三脚本 ALL PASS×3 |
+| 2026-07-20 04:3x | **收口批**:fetch/handler.ts(本轮 touch 文件)注释全翻英按减量口径;client.ts 文件头随改。全仓 grep 确认 ApiClientBase/CallerApi 零残留(headless.ts 已由 step1-design 并轨 InProcessApiClient)。apiproxy/dsc tsc 绿 + 三脚本 ALL PASS |
+| 2026-07-20 04:1x | **命名修订落地**:ApiClientBase→`AbstractApiClient`、CallerApi→`IApiClient`(用户拍板);三面关系一句话已写进 IApiClient JSDoc——ApiProxy=impl 实现的窄形签名契约、IApiClient=client 消费的 payload 直传视图、AbstractApiClient=两者之间的桥。9 文件机械改名,三包 tsc 绿 + 三脚本 31/31+10/10+7/7 全绿 |
+| 2026-07-20 03:5x | **caller 视图 + 抽象基类合并落地(追加拍板)**:createApiClient 废弃 → `ApiClientBase` 抽象类(协议不变量全在基类:mint/四象限信封/zod/SSE 解析/CallerApi 域方法;切面=abstract doFetch 传输 + 可覆写 onEnvelope tap 默认 no-op;callUnary/openMux/openHost 设 protected virtual 供无 HTTP 平台覆写)。子类三个:`InProcessApiClient`(apiproxy 内——进程内注入是本包自有能力,-p 用)、`WebApiClient`(web-runtime 新文件,doFetch=globalThis.fetch+同源 base、onEnvelope=tapToStore)、`FixtureApiClient`(fixture.ts,覆写协议层 virtuals 直连内存 impl,替代 wrapApiWithFakeEnvelopes 包装器——已删)。ApiProxy 契约不动;rpcRequest 从 api.ts 撤下(fixture 本地私有 mint)。验证:三包 tsc 绿 + 三脚本 31/31+10/10+7/7 全绿。headless.ts 届时改 `new InProcessApiClient(host.handler)`(step1-design 排队单里带上) |
+
+| 2026-07-20 05:2x | **input-ux 批次1 契约偏差评审入档(设计 owner 评审)**:①PromptError{op:'send'\|'stop'} 判别 union——无冲突,采纳(快照仍单错误槽、清空时机不变,op 只加判别);②sendDraft 乐观清稿+失败回填+draftInFlight 在途锁——无冲突,采纳:与「draft 挂常驻 Session 对象」恢复语义正交互补(回填依赖实例常驻,切换/切回 sent 不丢;failed 回填 `sent+新输入` 顺序正确);连带发现 input-ux 给 Notifier 加了 `notifyNow` 同步通知——评审认定合理但需边界纪律,已在 §A.9.6 补「仅用户手势直接回响可用 notifyNow,帧驱动一律合批」防误用扩散。design.md §A.2/§A.4/§A.7/§A.9/§B.2/§C.6 六处同步更新 |
+
+| 2026-07-20 06:2x | **input-ux 定格修正回刷入档(bb1a7ed5f)**:①§A.4 AssistantMessageNode 加 `interrupted?: true`(停止定格终态标记;分数 seq `turn/end-0.9` 保序;中断工具卡同理 `-0.8+偏移`/error.code='interrupted');②§A.2 内部状态表加 `frozenNodes` 派生态行;③§A.9 turn/end 行升级「定格清扫」语义(bb16d956b 删除式→bb1a7ed5f 冻结式:中断输出是价值非残渣,live 与 history 重放同函数收敛);④§C.3 滚动规则改两规则并存(atBottom 改 onScroll 监听维护修跟随链断裂 + 用户发送强制置底至自己消息入流)。纯回刷无代码改动 |
+
+## 接缝问题(草记,随批补充)
+
+1. **history 分页不保证 replace 闭包**:`surfaceOp: {op:'replace', start, end}` 引用的 seq 若被翻页截在窗口外,core fold throw「start seq not found」。§A.5 已设计 client 侧降级防御(foldDegraded),但契约层「页边界对齐消息边界」未提 replace 语义——将来 compact 落地后建议契约补「replace 目标所在页整体返回」或 server 侧展开。只报告不擅改。
+2. **隐式 resume 后 subscribed 帧是否补发未明确**:mux 流打开时只对 attached session 发 `session/subscribed`;冷 session 经 `history()` 隐式 resume 变 attached 发生在流已开之后——契约未写 host 是否为新 attach 的 session 补发 subscribed(lastSeq 基线)。不补发时 client 缝检测降级为 liveBuffer seq 去重(§D.3 可运行但基线语义残缺)。请 apiproxy-design 明确并写进契约 §3.3。
+3. **SessionSummary 无 title 字段**:ui-product §6 标题规则(首条用户消息生成+手动重命名)无契约支点;本里程碑列表用 sessionId 截断顶替(§F 明确出局),将来 additive 加 `title?: string` 即可,无破坏性。
+4. ~~dsh-session 包出口是否 re-export SurfaceManager~~ **已核实收口**(review 整改 #2 顺带):包根只出口 foldSurface/守卫,SurfaceManager 走 `@deepseek-ai/dsh-session/src/surface.ts` 子路径(`./src/*` 通道在,apiproxy 先例)。已写进 §A.5。
+
+## 接缝问题
+
+(发现契约缺口记在这里,只报告不擅改)
diff --git a/missions/tasks/20260719-2247-step-session-design/design.md b/missions/tasks/20260719-2247-step-session-design/design.md
new file mode 100644
index 0000000000..2eb07ffe5c
--- /dev/null
+++ b/missions/tasks/20260719-2247-step-session-design/design.md
@@ -0,0 +1,865 @@
+# step-session 里程碑 · 实现级设计(v1 完稿,待 review)
+
+> 2026-07-19 起草。读者 = 无上下文编码 teammate。排在 RPC 调试面板里程碑(`../20260719-2140-ui-milestone1-design/design.md`)验收之后实施。
+> 契约基线:`../20260719-1902-apiproxy-api-design/design.md` v2.0(四象限);api/ 代码已落地 typecheck 绿(`packages/host/apiproxy/src/api/`),本文类型名直接引用真代码。
+> 核心命题(用户 2026-07-19 22:4x 拍板,9 条全记在任务书):**Session 面向对象**(对象封装一切需 sessionId 的底层调用)+ **逻辑面与 UI 展示面分离**(UI 组件可整体替换而逻辑层零改)。
+> 纪律:GUI 期间跳过仓库门禁;本文只设计不写代码。
+
+## 目录
+
+- §A 数据对象层(web-runtime):Session / SessionManager / fold 适配 / 与 RPC 面板产物的关系
+- §B hook 层(逻辑面,web-ui/hooks):useSessionList / useConversation + useSyncExternalStore 接线
+- §C 展示组件层(可替换 UI 面,web-ui/components):纯 props 契约
+- §D 与契约的对接面:方法/帧清单、翻页锚定、增量 fold 策略
+- §E 验收清单(fixture / 真 host 两级)
+- §F 不做清单
+
+---
+
+## §A 数据对象层(web-runtime)
+
+### §A.0 模块布局与依赖增量
+
+```
+packages/client/web-runtime/src/
+  session/
+    conversation.ts   ← ConversationSnapshot / ConversationNode 等 UI 节点类型(§A.4/§A.5)
+    fold-adapter.ts   ← SurfaceManager 接线 + 节点缓存 + padding 窗口(§A.5)
+    partial.ts        ← assistant/chunk 累积器(§A.6)
+    session.ts        ← Session class(§A.2)
+    manager.ts        ← SessionManager + initSessionManager/getSessionManager(§A.3)
+    lineage.ts        ← 列表谱系树扁平化(§A.3)
+  connection.ts       ← 既有 ConnectionController **本里程碑扩展**:帧下沉回调(§A.1)
+  store.ts            ← 本里程碑零改动(选中态/草稿均不进全局 store,§A.7 review 整改 #1)
+  intents.ts          ← 加 createSession / refreshSessions(§A.7)
+  boot.ts             ← 装配点扩展:initSessionManager + controller 回调接线(§A.8)
+```
+
+- 新增依赖:**core 类型**。web-runtime 的 `package.json` 不加运行时依赖;`SessionEvent`/`ContentBlock`/`StreamChunk` 等一律 `import type`(类型擦除后 vite 不见这些包)。运行时值仍只有 apiproxy 的 `createApiClient` / `RpcId()`(W3 后)。
+- **实现前置依赖**:本设计按契约真形(流 yield `RpcRequest`,信封 rpcId 可见)编写。当前 web-runtime 的临时 `api-types.ts`(W3 前副本)流签名是裸帧无信封——实现本里程碑时若 W3(fetch 载体)已落地则直接替换 import;未落地则先给临时副本补上 `RpcRequest<帧>` 窄形(fixture 同步补 mint),不改本设计。
+- 唯一出口纪律沿用:`index.ts` 导出 hooks 所需最小面(`getSessionManager`、快照/节点类型、intents);Session/SessionManager 的构造函数不导出给 UI(只有 boot 与 manager 能建)。
+
+### §A.1 与 RPC 面板里程碑产物的关系(谁 own 谁)
+
+```
+boot.ts(唯一装配点)
+  ├─ createApiClient(fetch, { onEnvelope: tapToStore })   ← rpcLog tap 原样不动
+  ├─ SessionManager(本里程碑新建,持有 api 引用)
+  └─ ConnectionController(既有,本里程碑加三个回调)
+        onMuxEnvelope   → manager.handleMuxEnvelope
+        onHostEnvelope  → manager.handleHostEnvelope
+        onConnected     → manager.handleConnected(每代连接建立后回调,含首连与重连)
+```
+
+- **ConnectionController 仍 own 物理流**(打开/迭代/断线退避重连——RPC 面板里程碑 §A.3 原样),本里程碑给它加构造参数 `sinks?: { onMuxEnvelope?; onHostEnvelope?; onConnected? }`:泵循环体从「空转」改为「逐帧调 sink」。sink 抛异常不得炸泵(包 try/catch console.error——业务层坏不拖垮连接层)。
+- **SessionManager own 业务分发**:帧按 sessionId 路由到 Session 实例;host 帧维护列表。Controller 不认识 Session,Manager 不碰流与重连——单向:Controller → sinks → Manager。
+- **rpcLog 面板零改动**:tap 在载体层咽喉,本里程碑新增的所有流量(history/prompt/cancel/帧)自动进台账——调试面板天然成为本里程碑的开发观测工具。
+- **store 红线延续并加严**(RPC 面板 §C.7 + review 整改 #1):sessions/conversation 业务数据一律不进 zustand;且本里程碑 zustand **零增量**——选中态是视图容器局部 state、草稿住 Session 对象(§A.7)。
+- `onConnected` 时机 = 每代连接的两条流开启且 `host.describe` 成功之后(Controller 既有序列的第 3 步成功点);Manager 在此刻做 `refreshList()` + 通知各活 Session `resync()`(重连=重建,§D.4)。首连也走同一路径(首次 refreshList 即来自它,boot 不再单独调)。
+
+### §A.2 Session class(session.ts)
+
+**职责**:封装 apiproxy 一切需传 sessionId 的调用(拍板 1:外界不再手传 sessionId);持有本 session 的事件窗口 + fold 状态 + 流式 partial + 待答交互,产出不可变快照供 React 订阅。**实例常驻**(拍板 2):一旦创建不销毁,后台持续吃 mux 帧更新自己。
+
+```ts
+/** 由 SessionManager 懒建与持有;UI 经 hook 拿到实例只调公开方法,不 new。 */
+export class Session {
+  readonly sessionId: SessionId
+
+  // ---- 操作面(内部带自己的 id 调契约方法;rpcId mint 由 client 载体层收口,Session 不感知信封)----
+
+  /** 发送(拍板 6:queue/steer 双按钮语义 1:1 透传)。返回业务结果:ok / agent-busy 等 RpcResult 原样给调用方;同时把失败以 `{op:'send', error}` 写进快照 promptError(§A.4;input-ux 批次1:op 判别让停止失败不误标发送失败)。 */
+  prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise>
+
+  /** 停止:契约 session.cancel 的 1:1(清两条 FIFO + abort 当前 step)。 */
+  cancel(): Promise>
+
+  /** 草稿写入(review 整改 #1:per-session 数据跟对象走,不进全局 store);触发订阅通知,快照 draft 字段承载读路径。 */
+  setDraft(text: string): void
+
+  /** 发送草稿全链内聚:trim 空白 no-op → `[{type:'text',text}]` → prompt(mode)。**乐观清稿**(input-ux 批次1 修订,原「ok 后清」废弃):发送瞬间即清并同步通知(真 host 延迟下滞留草稿读作「没发出去」);失败把已发文本回填到在途期间新输入之前(`sent + 新输入`——回填安全性依赖 draft 挂常驻 Session 对象,切换/切回不丢 sent);在途锁 `draftInFlight` 吞重入(Enter 连发/双击),settle 后再发=正当排队。UI 的发送按钮只传 mode。 */
+  sendDraft(mode: 'queue' | 'steer'): Promise
+
+  /** 首次打开:拉尾页 history(幂等——已加载或在途则直接返回既有 Promise)。openSession intent 调用(§A.7)。 */
+  open(): Promise
+
+  /** 向上翻页:以窗口首事件 seq 为 beforeSeq 拉更早一页并前插(§D.2 锚定算法)。hasMore=false 或在途时 no-op。 */
+  loadOlder(): Promise
+
+  /** 重连重建(manager 在 onConnected 时对已 open 过的实例调用):窗口重置回尾页 + 清 pending 交互重收基线重放(§D.4)。 */
+  resync(): Promise
+
+  // ---- 订阅面(useSyncExternalStore 直连,拍板 3)----
+
+  /** 注册变更监听;返回退订函数。通知语义见 §A.9(微任务合批)。 */
+  subscribe(listener: () => void): () => void
+
+  /** 返回缓存的不可变快照对象;仅在数据实际变更后才换新引用(uSES 防撕裂前提)。 */
+  getSnapshot(): ConversationSnapshot
+
+  // ---- manager 专用入口(UI 不调;文档标注 @internal)----
+
+  /** mux 帧到达(信封 rpcId + 帧)。见 §A.9 帧分发表。 */
+  handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void
+
+  /** host/session-status 翻转(manager 从 host 流路由过来)。 */
+  handleRunning(running: boolean): void
+
+  /** host/agent-error 透传(无 turn 位置的 live 失败诊断)。 */
+  handleAgentError(message: string): void
+}
+```
+
+**内部状态(全私有,不直接暴露;快照是唯一读窗口)**:
+
+| 字段 | 类型/说明 |
+|---|---|
+| `events` | `SessionEvent[]`:已加载窗口,seq 连续升序;尾部随 live `session/event` 帧 append,头部随翻页 prepend |
+| `baseSeq` | 窗口首事件 seq(padding fold 的偏移,§A.5) |
+| `hasMore` | 契约 history 返回透传 |
+| `openState` | `'cold' | 'loading' | 'open' | 'error'`:open() 状态机;error 存 RpcError 供快照 |
+| `loadingOlder` | 翻页在途标志(防重入) |
+| `foldAdapter` | `FoldAdapter` 实例(§A.5):SurfaceManager + 节点缓存 |
+| `partial` | `PartialAccumulator | null`(§A.6):进行中 assistant 输出 |
+| `openCalls` | `Map`:已见 `tool/call` 未见 `tool/result` 的在途工具卡素材 |
+| `frozenNodes` | `ConversationNode[]`:中断终态冻结节点(turn/end 定格清扫产物,input-ux bb1a7ed5f);分数 seq 归并进快照 nodes;随 rebuildDerivedFromWindow 从窗口事件重建(派生态同 partial/openCalls) |
+| `pending` | `Map`:审批/问答 requested 占位(key 见 §A.9) |
+| `running` | host 流 status 与 list 快照合成的运行位 |
+| `promptError` / `lastAgentError` | 最近一次 send/stop 失败 `PromptError{op:'send'\|'stop', error}` / agent-error 文本(下次 prompt 发起时清空;op 判别驱动 UI 文案,input-ux 批次1) |
+| `draftInFlight` | sendDraft 在途锁(重入即弃;不进快照——纯防抖非展示态,input-ux 批次1) |
+| `draft` | 输入框草稿(review 整改 #1:per-session 数据跟对象走——切 session 草稿不串、常驻实例天然保稿;不进全局 store,也不放容器局部 state——容器 key=sessionId 重挂载会丢稿) |
+| `liveBuffer` | `SessionEvent[]`:open()/resync() 在途期间到达的 live 事件暂存,历史就绪后按 seq 合并去重(§D.3 缝合规则) |
+| `snapshotCache` / `dirty` | 快照缓存与失效标志(§A.9) |
+
+**纪律**:Session 不碰 zustand、不碰 DOM、不做展示格式化(相对时间/截断都在 UI 侧);一切输出经 `ConversationSnapshot`。事件窗口内只存契约透传的原始 `SessionEvent`——UI 节点是 fold 适配层的派生缓存,可随时由原始窗口重建。
+
+### §A.3 SessionManager(manager.ts)
+
+**职责**:单例持有 `Map`(拍板 2 懒建、常驻);mux/host 帧总入口按 sessionId 分发;session 列表状态(summaries + live 覆盖 + 谱系扁平化)自己持有并供订阅——列表数据同样不进 zustand。
+
+```ts
+export class SessionManager {
+  /** boot 注入 api;构造不发请求。 */
+  constructor(api: ApiProxy)
+
+  // ---- 实例管理 ----
+  /** 懒建:已有实例直接返回;没有则 new Session 并入 Map(不自动 open——open 由 intent 显式触发)。 */
+  get(sessionId: SessionId): Session
+
+  // ---- 列表面 ----
+  /** 拉 session.list 全量刷新 summaries(单飞:在途时复用同一 Promise)。 */
+  refreshList(): Promise
+  /** 契约 session.create;成功后就地把新条目并入 summaries(不等下次 refresh)并返回 id。 */
+  create(cwd?: string): Promise>
+
+  // ---- 订阅面(useSessionList 用)----
+  subscribe(listener: () => void): () => void
+  getListSnapshot(): SessionListSnapshot
+
+  // ---- ConnectionController sinks(boot 接线;UI 不调)----
+  handleMuxEnvelope(envelope: RpcRequest): void
+  handleHostEnvelope(envelope: RpcRequest): void
+  handleConnected(): void
+}
+```
+
+**帧路由(handleMuxEnvelope / handleHostEnvelope)**:
+
+| 帧 | 路由 |
+|---|---|
+| mux `session/*`、`approval/*`、`question/*`(都带 sessionId) | `sessions.get(sessionId)?.handleMuxEnvelope(rpcId, frame)`——**只投给已存在的实例**,未实例化的 session 丢帧(不懒建:打开时 history 全量补齐,见 §D.3;避免 mux 全量广播把所有 session 都实例化,违背懒建初衷) |
+| mux `stream/error` | 不路由(ConnectionController 已把它当流故障处理,Manager 忽略) |
+| host `host/session-added` | summaries 增条目(`updatedAt=Date.now()` 占位,下次 refresh 校正;parentSessionId 入谱系) |
+| host `host/session-removed` | summaries 删条目;**Session 实例不销毁**(拍板 2;实例若存在标记 `removed` 进快照,UI 显示已结束态即可,v1 素朴处理) |
+| host `host/session-status` | summaries 就地改 running + `sessions.get(id)?.handleRunning(running)` |
+| host `host/agent-error` | `sessions.get(id)?.handleAgentError(message)`(列表不表现) |
+| host `stream/error` | 同 mux,忽略 |
+
+**列表快照与谱系扁平化(lineage.ts,拍板 8)**:
+
+```ts
+export interface SessionListEntry {
+  sessionId: SessionId
+  updatedAt: number
+  running: boolean
+  parentSessionId?: SessionId
+  cwd?: string
+  /** 谱系缩进层级:根=0;由 lineage 扁平化计算,UI 只乘 indent 宽度。 */
+  depth: number
+}
+export interface SessionListSnapshot {
+  items: readonly SessionListEntry[]
+  state: 'idle' | 'loading' | 'error'
+  error: RpcError | null
+}
+```
+
+扁平化算法(纯函数 `flattenLineage(summaries): SessionListEntry[]`,可单测):
+1. 按 parentSessionId 建 children 索引;parent 不在 summaries 里的条目视为根(孤儿谱系降级,不丢条目)。
+2. 根层按 updatedAt 倒序;DFS 展开,每层子节点同样 updatedAt 倒序,depth=父+1。
+3. 环防御:DFS 带 visited 集合,命中环时该条目按根输出(fail-soft,console.warn)。
+
+**单例接线**:模块级 `let instance: SessionManager | null`;`initSessionManager(api): SessionManager`(boot 专用,重复调用覆盖——与 bindIntents 同纪律)与 `getSessionManager(): SessionManager`(hooks 用;未 init 时 throw——misconfiguration fails loud,UI 在 boot 之后 mount,正常时序必然已 init)。
+
+### §A.4 快照类型(conversation.ts)——逻辑面与展示面的数据分界
+
+快照是逻辑面吐给 UI 的唯一数据形状(拍板 9 的「接口形状」主体)。**不可变契约**:每次变更换新顶层对象;未变的子结构保持引用(React.memo 生效前提)。
+
+```ts
+export interface ConversationSnapshot {
+  sessionId: SessionId
+  /** surface fold 产物(§A.5),已定稿的对话节点,surface 序。 */
+  nodes: readonly ConversationNode[]
+  /** 进行中 assistant 输出(chunk 累积,§A.6);无进行中输出为 null。 */
+  partial: PartialAssistant | null
+  /** 已请求未出结果的工具调用(tool/call 已到、tool/result 未到),渲在 partial 之后。 */
+  runningCalls: readonly RunningToolCall[]
+  /** 审批/问答占位卡片(拍板 4:可见不可答),渲在对话流末尾。 */
+  pending: readonly PendingInteraction[]
+  running: boolean
+  /** 列表已移除(host/session-removed 后);UI 置灰禁输入。 */
+  removed: boolean
+  openState: 'cold' | 'loading' | 'open' | 'error'
+  openError: RpcError | null
+  hasMore: boolean
+  loadingOlder: boolean
+  /** send/stop 失败并集(input-ux 批次1):op 判别子驱动 UI 文案(停止失败≠发送失败)。 */
+  promptError: { op: 'send' | 'stop'; error: RpcError } | null
+  lastAgentError: string | null
+  /** 草稿(§A.2 setDraft 写入;per-session 数据住对象,review 整改 #1)。 */
+  draft: string
+}
+```
+
+**对话节点 union(判别子 kind;每节点带 seq 作 React key 与调试锚)**:
+
+```ts
+export type ConversationNode =
+  | UserMessageNode | AssistantMessageNode | SteeringMessageNode
+  | ContextMessageNode | ToolResultNode | UnknownSurfaceNode
+
+export interface UserMessageNode {
+  kind: 'user'; seq: number
+  content: readonly ContentBlock[]          // 透传;UI 只渲 text 块,其余 JSON 折叠
+  source: MessageSource
+}
+export interface AssistantMessageNode {
+  kind: 'assistant'; seq: number
+  turn: number; step: number
+  /** content 按块序拆好给 UI:text 块正文、reasoning 块可折叠(拍板 4)、tool-call 块转卡片头。 */
+  blocks: readonly AssistantBlock[]
+  usage?: TokenUsage
+  /** 中断终态标记(input-ux bb1a7ed5f):停止定格的 partial 冻结节点——非 fold 产物,由 turn/end
+   *  清扫生成(§A.9),seq 用分数 `turn/end seq - 0.9` 保序(严格晚于本 turn 全部事件、早于下一 turn);
+   *  live 冻结与 history 重放走同一清扫函数,刷新后重建出相同节点(chunk 已落日志)。UI 渲安静
+   *  「已中断」内联标签非报警态。中断工具卡同理(seq-0.8+偏移,error.code='interrupted')。 */
+  interrupted?: true
+}
+export type AssistantBlock =
+  | { kind: 'text'; text: string }
+  | { kind: 'reasoning'; text: string }
+  | { kind: 'tool-call'; callId: CallId; name: string; argsRaw: string }  // 卡片体在 ToolResultNode / runningCalls
+  | { kind: 'other'; block: ContentBlock }   // merge-extensible 兜底:JSON 折叠
+export interface SteeringMessageNode {
+  kind: 'steering'; seq: number; turn: number
+  content: readonly ContentBlock[]; source: MessageSource
+}
+export interface ContextMessageNode {
+  kind: 'context'; seq: number
+  content: readonly ContentBlock[]; source: MessageSource
+  envelope?: ContextEnvelope    // core 原样透传(§D.5 对齐表核出的补字段:v1 折叠卡里 JSON 渲出,不解释语义)
+  meta?: unknown                // 同上(core JsonValue)
+  // ui-product §7 的「诊断视图」后置(§F);v1 在普通流里渲折叠卡,先可见。
+}
+export interface ToolResultNode {
+  kind: 'tool-result'; seq: number
+  callId: CallId
+  /** 从 openCalls / tool/call 事件回填的调用头(name/argsRaw);窗口截断致 call 不在窗口时为 null,卡片头显示 callId。 */
+  call: { name: string; argsRaw: string } | null
+  content: readonly ContentBlock[]
+  isError: boolean
+  error?: { name: string; code: string }
+  meta?: unknown            // 透传不解释(presentation 后置,§F)
+}
+export interface UnknownSurfaceNode {
+  kind: 'unknown'; seq: number; type: string; data: unknown   // merge-extensible surface 扩展兜底:JSON 折叠
+}
+
+export interface RunningToolCall {
+  callId: CallId; name: string; argsRaw: string
+  turn: number; step: number
+}
+export type PendingInteraction =
+  | { kind: 'approval'; rpcId: RpcId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
+  | { kind: 'question'; rpcId: RpcId; questions: readonly AskUserQuestionItem[] }
+
+export interface PartialAssistant {
+  turn: number; step: number
+  /** 已定稿/增长中的块序列(§A.6 累积器产物),形状与 AssistantBlock 一致。 */
+  blocks: readonly AssistantBlock[]
+}
+```
+
+设计要点:
+- **tool 卡片一分为二**:assistant 消息里的 `tool-call` 块只是「卡片头引用」;完整卡片 = 在途时 `runningCalls`(等结果)、定稿后 `ToolResultNode`(surface 序中天然紧跟其 assistant 消息)。状态「原地翻转」的观感由 UI 层用 callId 对齐实现(§C);逻辑层不做合并节点,保持与 surface 序一一对应(fold 复用的最短路径)。
+- **reasoning 在 content 块里**(core `ReasoningBlock`),不是独立事件——拆块交给 fold 适配层,UI 拿到的 AssistantBlock 已分好类。
+- `PendingInteraction.rpcId` = requested 帧的**信封 rpcId**(server mint、重放复用)——它就是将来 respond 回填键,v1 只展示;approval 另带 approvalId(契约 §3.4 id 双层,帧 payload 自带);question 的帧 payload 无 id(契约如此),信封 rpcId 是唯一标识。
+
+### §A.5 fold 适配层(fold-adapter.ts)——复用 core foldSurface(拍板 5)
+
+**为什么能直接复用**:core `SurfaceManager`(`packages/core/session/src/surface.ts`)构造收 `readonly SessionEvent[]`(借引用),`nodes` getter 惰性折叠**新追加的**事件(`_lastProcessedSeq` 游标),天然增量——Session 往 `events` 尾部 push 后读 `surface.nodes` 只折叠新事件。surface.ts 无 Node 依赖,浏览器可 import。**已核实(review 整改 #2 顺带)**:包根 index.ts:27 只 re-export `foldSurface`/守卫/类型,**不含 SurfaceManager class**——走子路径 `import { SurfaceManager } from '@deepseek-ai/dsh-session/src/surface.ts'`(package.json `"./src/*"` 通道在,apiproxy 同款先例)。
+
+**两个适配问题与解法**:
+
+1. **seq 偏移(padding 窗口方案)**:`foldSurface`/`SurfaceManager` 断言事件 seq 与数组下标连续相等(`event.seq !== expectedSeq` 即 throw),而翻页窗口的首事件 seq = `baseSeq > 0`。**解法**:fold 输入数组前部填充 `baseSeq` 个哨兵事件 `{ seq: i, type: 'noop/padding', data: {} }`——非 surface-eligible 类型走 `surfaceOpOf` 的 undefined 分支被安全跳过,O(baseSeq) 一次性成本只在构造/重建时发生(session 万级事件 = 万次空循环,微秒级;不改 core)。窗口数组 `padded = [...Array(baseSeq) 哨兵, ...events]`,`SurfaceManager` 借它的引用;**尾部 append 直接 push 进同一数组**(增量惰性折叠生效);**头部 prepend(翻页)必须重建**——baseSeq 变小、哨兵数变少,游标失效:new 一个 SurfaceManager 重折整窗(§D.2 翻页频率低、每页消息数有限,全量重折可接受;这就是「至少每消息级缓存」性能注记的取舍点——节点缓存见下,重折不重做节点物化)。
+   - **replace 语义的窗口性风险**:`surfaceOp: {op:'replace', start, end}` 若引用**窗口之前**的 seq(被翻页截掉的 surface 节点),`replacementRange` 会 throw「start seq not found」。契约 history 按消息边界切页不保证 replace 闭包(接缝问题 #1,报 README)。**防御**:fold 调用包 try/catch;throw 时该窗口降级为「从最近一次成功 fold 的节点集 + 尾部逐事件宽容追加」不再走 SurfaceManager,快照置 `foldDegraded: true`(快照类型补此布尔),UI 顶部渲一条细警告。v1 触发面极窄(compact/replace 事件本就罕见),不为它做窗口扩拉。
+2. **fold 输出是 seq 数组,UI 要节点对象**:`surface.nodes: readonly number[]` → 逐 seq 物化 `ConversationNode`。**节点缓存 `Map`**:物化纯函数 `materializeNode(event, ctx): ConversationNode`(switch on `event.type`,五个 surface 类型 + unknown 兜底,见 §A.4 节点形状;`ToolResultNode.call` 从 `ctx.callIndex`——窗口内 `tool/call` 事件的 `Map`——回填)。缓存键 = seq:事件不可变,seq 级缓存永不失效(除非重建窗口整体清空);每次快照重算 `nodes` 数组 = `surface.nodes.map(seq => cache.get(seq) ?? materialize…)`——数组引用每次变更换新,但节点对象引用稳定(React.memo 边界,§C)。
+
+```ts
+export class FoldAdapter {
+  /** 窗口重建(open/resync/翻页 prepend 后):换 padded 数组、new SurfaceManager、清节点缓存、重建 callIndex。 */
+  reset(events: SessionEvent[], baseSeq: number): void
+  /** 尾部追加(live session/event):push 进 padded 数组 + callIndex 增量维护;tool/call 到达时顺带失效其 pending 中 ToolResultNode 缓存(不存在,no-op)。 */
+  append(event: SessionEvent): void
+  /** 当前节点数组(内部读 surface.nodes + 缓存物化)+ 降级位。 */
+  nodes(): { nodes: readonly ConversationNode[]; degraded: boolean }
+  /** 窗口内 tool/call 索引(Session 拿它算 runningCalls 与回填)。 */
+  readonly callIndex: ReadonlyMap
+}
+```
+
+(`ConversationSnapshot` 补一个字段:`foldDegraded: boolean`——上文防御位。)
+
+### §A.6 chunk 累积器(partial.ts)——流式增长(拍板 4)
+
+**输入** = `session/event` 帧里的 `assistant/chunk` 事件(`{ turn, step, chunk: StreamChunk }` 透传,token 流即事件流)。core `StreamChunk` 六型(`packages/llm/llm/src/types.ts`)按块 index 相关联;累积器把 delta 流折成 `AssistantBlock[]`:
+
+| chunk | 累积动作 |
+|---|---|
+| `block-start` | `blocks[index] = 按 blockType 建空块`(text→`{kind:'text',text:''}`;reasoning 同理;tool-call→`{kind:'tool-call', callId: 待 delta 补, name:'', argsRaw:''}`;未知 blockType→`{kind:'other', block: null}` 占位) |
+| `text-delta` / `reasoning-delta` | `blocks[index].text += text`(**字符串拼接每 chunk 换该块对象引用,其余块引用不动**——块级不可变) |
+| `tool-call-delta` | 对应块 `argsRaw += argumentsDelta`;`id`/`name` 首见回填 |
+| `block-end` | 用定稿 `block` 整体替换该 index 的累积块(含 other 兜底的真身回填) |
+| `usage` / `finish` | 忽略(usage 定稿走 assistant/message;finish 后紧跟 assistant/message 事件收尾) |
+
+- **生命周期**:首个 `assistant/chunk`(新 turn/step)到达 → new 累积器;对应 `assistant/message` 事件到达(surface fold 收编定稿消息)→ 累积器丢弃(partial=null)——**定稿即切换**,UI 观感是 partial 区变成正式节点(内容一致,无闪烁风险:同一批通知里完成,§A.9 合批保证单次 render 完成切换)。
+- **turn/step 错位防御**:累积中若来了不同 (turn,step) 的 chunk(乱序理论不发生,seq 连续),直接弃旧起新 + console.warn。
+- **增量 fold 策略的答案(任务书 §D 性能注记)**:chunk 根本**不进** fold——`assistant/chunk` 非 surface-eligible,SurfaceManager 跳过它;但它进 `events` 窗口(透传纪律:窗口=原始事件),fold 增量游标扫过为 O(1) 跳过。真正的每 chunk 成本 = 累积器一次字符串拼接 + 一次微任务合批通知 + React 一次 partial 区重渲——已是消息级缓存之下的块级增量,无「每 chunk 全量重 fold」问题。
+
+### §A.7 zustand store 与 intents 增量(review 整改 #1 后:本里程碑 store 零增量)
+
+**单例边界澄清(用户 2026-07-19 追认)**:「不应有全局单例」只针对 **selectedSessionId 这类视图选中态**(「哪个面板在看」的 UI 局部事实);**SessionManager 模块级单例维持不动**(initSessionManager/getSessionManager 照旧,hooks 可用),bindIntents/rpcLog store 单例形态同样不动。
+
+**选中态不进全局 store(review 整改 #1,用户拍板「不应有这种全局单例」)**:`selectedSessionId` 归属**视图容器局部 state**——组件树里拥有「列表+会话区」组合的容器(§C.1 SessionsScreen)`useState` 持有,选中回调经 props 下发。**多视图前瞻**:将来分屏/多面板 = 多个 SessionsScreen 实例各自持有自己的选中态,互不干扰——全局单例恰恰是那条路的死障,容器局部是其自然形。
+
+**drafts 也不进全局 store**:草稿挂 **Session 对象**(§A.2 `setDraft` + 快照 `draft` 字段)。理由一句:草稿是 per-session 数据,跟着 per-session 对象走——常驻实例让切换/切回天然保稿;若放容器局部 state 会随 key=sessionId 重挂载丢稿,若放全局 store 则违反本条整改的原则(Record 切片就是变相全局单例)。清稿由 Session.sendDraft 内部完成(乐观清稿:发送瞬间 `draft=''` 同步通知、失败回填,§A.2 input-ux 修订),§B.2 的 send 句柄不再管草稿清理。
+
+于是 **store.ts 本里程碑零改动**(仍只有 rpcLog + rpcLogOpen);zustand 只承载真正的跨视图全局展示态,本里程碑没有新增的这类状态。
+
+**intents.ts 增量**(intent=普通函数纪律不变;仅剩不依赖选中态的两个):
+
+| 函数 | 行为 |
+|---|---|
+| `refreshSessions()` | `manager.refreshList()` 透传(列表手动刷新钮/首连自动) |
+| `createSession(): Promise>` | `manager.create()` 透传;**选中新 session 是容器的事**——容器回调里 await 结果后 setState 本地选中(§C.1),intent 不做导航副作用 |
+
+原 `selectSession` intent 删除:选中=容器 setState + `manager.get(id).open()`(容器回调内联做,见 §C.1);`setDraft/clearDraft` intent 删除(挂 Session 对象)。prompt/cancel/loadOlder 仍不做 intent——Session 对象方法(拍板 1 封装面),hook 层绑定暴露(§B)。
+
+### §A.8 boot 装配(boot.ts 增量)
+
+```ts
+export function bootWebRuntime(options: BootWebRuntimeOptions): WebRuntimeHandle {
+  const api = /* 既有:fixture | createApiClient(fetch, { onEnvelope: tapToStore }) */
+  bindIntents(api)
+  const manager = initSessionManager(api)
+  const controller = new ConnectionController(api, {
+    onMuxEnvelope: (e) => manager.handleMuxEnvelope(e),
+    onHostEnvelope: (e) => manager.handleHostEnvelope(e),
+    onConnected: () => manager.handleConnected(),
+  })
+  controller.start()
+  return { stop: () => controller.stop() }
+}
+```
+
+- 装配顺序保证 hooks 在首帧到达前就能 `getSessionManager()`(React mount 晚于 boot 同步段)。
+- fixture 路径同一装配零分叉(§C.6 纪律沿用);fixture 能力增量见 §E.1。
+
+### §A.9 订阅与通知(Session/Manager 共用模式)
+
+**变更→通知管线**(两类对象同构,写一个 `Notifier` 小基件复用):
+
+1. 任何内部状态变更(帧到达、请求状态迁移)→ `dirty = true` + `scheduleNotify()`。
+2. `scheduleNotify` 微任务合批(同 rpcLog 泵思路):`queueMicrotask` 一次 flush——**一帧 SSE 常带多个事件、chunk 风暴常态**,合批把 N 次变更收敛为一次 listener 调用(React 一次 re-render)。
+3. flush 时**先重算快照缓存再通知**:`snapshotCache = buildSnapshot()`;`getSnapshot()` 只返回缓存引用,**绝不在调用中计算**——uSES 要求 `getSnapshot` 稳定(同一状态多次调用同一引用),否则无限重渲。
+4. 快照构建的引用纪律:顶层对象每次新建;`nodes` 数组每次新建但元素引用来自缓存(§A.5);`partial.blocks` 只有变更块换引用;`pending`/`runningCalls` 无变更时**沿用上一快照的数组引用**(构建函数按 dirty 细分位判断,v1 简化为:这些子数组在各自变更计数未变时复用旧引用——每类状态一个 revision 计数器,构建时比对)。
+5. `subscribe` 返回退订闭包;listener 异常不吞(React 的 uSES listener 不会 throw,无需防御性 catch——出问题要炸在开发期)。
+6. **`notifyNow` 同步逃生口(input-ux 批次1 增补)**:微任务合批对「用户直接输入的回显」有一帧滞后感——sendDraft 乐观清稿与 setDraft 键入回显走 `notifyNow()`(同步 rebuild+通知);帧驱动的状态变更一律仍走 `markDirty` 合批。边界纪律:**只有用户手势的直接回响允许 notifyNow**,其余入口用它即违例(重新引入逐帧 setState 风暴)。
+
+**Session.handleMuxEnvelope 帧分发表**(§A.2 的实现规格):
+
+| 帧 | 动作 |
+|---|---|
+| `session/event` | 事件 seq ≤ 窗口尾 seq → 丢(重放重叠,§D.3);open 在途 → 进 liveBuffer;否则 `events.push` + `foldAdapter.append` + 按 type 附加动作:`assistant/chunk`→累积器;`assistant/message`→partial 清除;`tool/call`→openCalls 增;`tool/result`→openCalls 删;**`turn/end`→定格清扫同 turn 的 partial 与 openCalls**(aborted turn 不补发 assistant/message——core loop 实证;audit S2 bb16d956b 首修删除式清扫,bb1a7ed5f 升级「定格」:有内容 partial 冻结为 `interrupted:true` 终态节点、在途工具卡冻结为 interrupted 终态卡——中断输出是价值非残渣;冻结节点入 `frozenNodes` 派生态,快照时与 fold 产物按分数 seq 稳定归并,rebuildDerivedFromWindow 同函数重放保证 live 与刷新一致);其余无附加 |
+| `session/subscribed` | 记 `lastSeq` 供缝检测(§D.3);open 前到达则暂存 |
+| `approval/requested` | `pending.set('a:'+rpcId, …)`(key 前缀防两域 rpcId 理论碰撞——不同 mint 空间,纯防御) |
+| `approval/resolved` | 按 approvalId 扫 pending 删除(resolved 帧带 approvalId 非 rpcId——契约如此)|
+| `question/requested` | `pending.set('q:'+rpcId, …)` |
+| `question/resolved` | 帧带 `questionRpcId`,删 `'q:'+questionRpcId` |
+
+(`stream/error` 不进 Session——Controller 层已收敛为重连。)
+
+---
+
+## §B hook 层(逻辑面;web-ui/src/hooks/)
+
+**定位(拍板 9 的分界线)**:hook 层是逻辑面的 React 出口——把 §A 对象翻译成「纯数据 + 操作句柄」。展示组件(§C)只吃 hook 返回值经 props 传下去的数据;**hook 只在容器组件(§C.1)调用,展示组件零 hook、零数据获取**。将来换 UI 库 = 重写 §C 组件、§B 与 §A 零改。
+
+```
+packages/client/web-ui/src/
+  hooks/
+    useSessionList.ts
+    useConversation.ts
+```
+
+(住 web-ui 而非 web-runtime:hook 是 React 绑定,runtime 无 React 依赖——分界即包界。web-ui 由此获得对 `getSessionManager` 等 runtime 出口的依赖,仍不许碰 apiproxy——§C 对齐纪律沿用。)
+
+### §B.1 useSessionList
+
+```ts
+export interface SessionListHandle {
+  /** 谱系扁平化后的列表(§A.3 SessionListSnapshot 透传)。 */
+  list: SessionListSnapshot
+  // ---- 操作句柄(绑定 intents,引用稳定)----
+  create: () => Promise>   // = createSession intent 透传(容器拿结果做本地选中)
+  refresh: () => void                                          // = refreshSessions
+}
+export function useSessionList(): SessionListHandle
+```
+
+**选中态不在此 hook**(review 整改 #1):`selectedSessionId` 是容器局部 state(§C.1),列表条目高亮由 `SessionListViewProps.selectedId` props 驱动;本 hook 只供数据与无导航副作用的操作。
+
+实现规格:
+
+```ts
+export function useSessionList(): SessionListHandle {
+  const manager = getSessionManager()
+  const list = useSyncExternalStore(
+    useCallback((cb) => manager.subscribe(cb), [manager]),
+    () => manager.getListSnapshot(),
+  )
+  return useMemo(() => ({ list, create: createSession, refresh: refreshSessions }), [list])
+}
+```
+
+- `getSnapshot` 直接传 manager 方法包装箭头:Manager 保证缓存引用稳定(§A.9.3),满足 uSES 合同。
+- 操作句柄就是模块级 intent 函数——引用天然稳定,useMemo 只为聚合对象。
+
+### §B.2 useConversation
+
+```ts
+export interface ConversationHandle {
+  /** §A.4 全形快照(nodes/partial/runningCalls/pending/openState/hasMore/draft…)。 */
+  snapshot: ConversationSnapshot
+  // ---- 操作句柄(绑定 Session 实例方法;对本 hook 的同一 id 引用稳定)----
+  setDraft: (text: string) => void          // = session.setDraft(草稿住对象,§A.7)
+  /** 发送当前草稿:Session.sendDraft 内聚——空白 no-op、组 ContentBlock、乐观清稿+失败回填+在途锁(§A.2)。 */
+  send: (mode: 'queue' | 'steer') => void
+  stop: () => void                          // = session.cancel() fire-and-forget(错误进快照 promptError)
+  loadOlder: () => void                     // = session.loadOlder() fire-and-forget
+}
+export function useConversation(sessionId: SessionId): ConversationHandle
+```
+
+实现规格:
+
+```ts
+export function useConversation(sessionId: SessionId): ConversationHandle {
+  const session = getSessionManager().get(sessionId)   // 懒建;常驻实例,重复调用同一引用
+  const snapshot = useSyncExternalStore(
+    useCallback((cb) => session.subscribe(cb), [session]),
+    () => session.getSnapshot(),
+  )
+  const ops = useMemo(() => ({
+    setDraft: (text: string) => session.setDraft(text),
+    send: (mode: 'queue' | 'steer') => void session.sendDraft(mode),
+    stop: () => void session.cancel(),
+    loadOlder: () => void session.loadOlder(),
+  }), [session])
+  return useMemo(() => ({ snapshot, ...ops }), [snapshot, ops])
+}
+```
+
+- **草稿读写全在 Session 对象**(review 整改 #1 连带):`snapshot.draft` 读、`setDraft` 写、`sendDraft(mode)` 发——「trim 空白 no-op→`[{type:'text',text}]`→prompt→乐观清稿/失败回填」整链内聚在 Session(§A.2;原 hook 里读 store 组装的逻辑随 drafts 切片一并删除)。hook 层零 zustand 依赖。
+- **切换 session 即换 id 重跑 hook**:uSES 自动退订旧实例订阅、订新实例;旧 Session 常驻后台继续吃帧(拍板 2),下次切回快照即最新,无需重拉(缝检测兜底 §D.3)。
+- `open()` 不在 hook 里调——由容器的选中回调触发(§C.1 SessionsScreen.select),hook 保持纯订阅(渲染路径无副作用;StrictMode 双调安全)。
+
+### §B.3 ~~useSelectedSession~~(review 整改 #1 删除)
+
+选中态无全局读出口——它是 SessionsScreen 容器的 `useState`(§C.1)。hooks/ 目录只有 useSessionList 与 useConversation 两个文件。
+
+### §B.4 uSES 接线要点(防撕裂清单,写给实现者)
+
+1. **getSnapshot 恒返缓存引用**(§A.9.3 已定):对象层 flush 时先重算缓存再通知;React 在通知后调 getSnapshot 拿到新引用,比对旧引用触发 re-render。绝不在 getSnapshot 内 build——同渲染两次调用必须同引用,否则 React 18 dev 撕裂告警 + 无限循环风险。
+2. **subscribe 引用稳定**:useCallback 依赖 [session]/[manager];session 实例常驻保证切换外零重订。
+3. **服务端渲染缺位**:不传 getServerSnapshot——本项目纯 CSR(vite SPA),SSR 不在范围。
+4. **列表与对话双源一致性**:running 位同时活在列表条目与对话快照,同一 host 帧驱动两处(Manager 改 summaries + 转发 Session),同一微任务批内 flush——单 render 周期内两处一致,无中间态闪烁。
+
+---
+
+## §C 展示组件层(可替换 UI 面;web-ui/src/components/)
+
+**红线(拍板 9)**:本节所有组件**纯 props 进、回调出**——零 hook(React 内建 useState/useRef/useEffect 做纯视图态除外:折叠开合、滚动 ref)、零 store/manager/intent import、零 runtime 类型之外的数据感知。类型只 import §A 快照/节点类型与本节 props 接口。**将来换 List/UI 库 = 整目录替换,§A/§B 与容器零改。**唯一例外是两个容器组件(§C.1)——它们是分界线本身:调 hook、传 props、不写样式结构。
+
+本轮素朴实现口径:无样式追求(沿用 RPC 面板变量表的基本色),布局能用即可;不引组件库、不引 markdown 渲染(正文纯文本 `white-space: pre-wrap`——GFM/KaTeX 在 ui-product §7 有产品口径,本里程碑后置进 §F)。
+
+### §C.0 文件清单
+
+```
+packages/client/web-ui/src/
+  App.tsx                                ← 渲  + (RPC 面板浮层保留)
+  hooks/…                                ← §B
+  components/
+    sessions/
+      SessionsScreen.tsx                 ← 视图容器:选中态 useState 在此(§C.1);两列布局归它
+      SessionListContainer.tsx           ← 容器:useSessionList → SessionListView(§C.1)
+      SessionListView.tsx                ← 纯列表(§C.2)
+      SessionListItem.tsx                ← 单条(§C.2)
+    conversation/
+      ConversationContainer.tsx          ← 容器:useConversation → ConversationView(§C.1)
+      ConversationView.tsx               ← 对话流骨架:滚动区 + 节点分发 + 输入区(§C.3)
+      MessageItem.tsx                    ← user/steering/context/unknown 四类简单节点(§C.4)
+      AssistantMessage.tsx               ← assistant 节点:blocks 循环(text/reasoning 折叠/tool-call 头)(§C.4)
+      ToolCallCard.tsx                   ← 工具卡片:running/result 双态(§C.5)
+      PendingCard.tsx                    ← 审批/问答占位卡(§C.5)
+      JsonBlock.tsx                      ← JSON 折叠块(复用思路同 RPC 面板 PayloadJson:stringify+截断;独立实现避免跨面板耦合)
+      InputBar.tsx                       ← 草稿 + queue/steer/停止(§C.6)
+    panels/RpcLog/…                      ← 既有不动
+```
+
+(每组件同目录 `.module.css` 同名文件,全清单略——素朴实现,类名跟组件节结构走。)
+
+### §C.1 容器组件(分界线本身;review 整改 #1 后选中态在 SessionsScreen 局部)
+
+```tsx
+/** 视图容器:「列表+会话区」组合的 owner,选中态是它的局部 state——非全局单例。
+ *  多视图前瞻:将来分屏/多面板 = 渲多个 SessionsScreen 实例,各自持有自己的选中态互不干扰。 */
+export function SessionsScreen() {
+  const [selectedId, setSelectedId] = useState(null)
+  const select = useCallback((id: SessionId) => {
+    setSelectedId(id)
+    void getSessionManager().get(id).open()   // 选中即触发打开(fire-and-forget,错误进该 Session 快照)
+  }, [])
+  return (
+    
{/* grid: var(--sidebar-width, 280px) minmax(0,1fr); height:100% */} + +
+ {selectedId === null ? : } +
+
+ ) +} + +export function SessionListContainer({ selectedId, onSelect }: { + selectedId: SessionId | null; onSelect: (id: SessionId) => void +}) { + const h = useSessionList() + const create = useCallback(async () => { + const r = await h.create() + if (r.ok) onSelect(r.value.sessionId) // 新建即选中:容器回调组合,intent 无导航副作用(§A.7) + }, [h.create, onSelect]) + return void create()} onRefresh={h.refresh} /> +} + +/** key=sessionId(上面 JSX 已加)强制切 session 重挂载:滚动位置/折叠态等视图态按 session 重置(v1 简化拍板——不做跨切换视图态保持;草稿不受影响——住 Session 对象,§A.7)。 */ +export function ConversationContainer({ sessionId }: { sessionId: SessionId }) { + const h = useConversation(sessionId) + return +} +``` + +(SessionsScreen/容器是允许调 hook 与 `getSessionManager` 的仅有两层;SessionListContainer 收 props 转 props,是「容器也可被组合」的示例——分界线在「展示组件零数据获取」,不在「容器必须零 props」。) + +### §C.2 SessionListView / SessionListItem(拍板 8) + +```ts +export interface SessionListViewProps { + list: SessionListSnapshot + selectedId: SessionId | null // 容器局部 state 下发(§C.1);高亮纯 props 驱动 + onSelect: (id: SessionId) => void + onCreate: () => void + onRefresh: () => void +} +export interface SessionListItemProps { + entry: SessionListEntry + selected: boolean + now: number // 相对时间基准:View 层 30s tick(RPC 面板同款模式) + onSelect: (id: SessionId) => void +} +``` + +- View 结构:标题行(「Sessions」+「+」新建钮 + 刷新钮)|滚动列表(`flex:1; overflow-y:auto`)|state==='loading' 且空列表渲「载入中」、'error' 渲错误行+重试(onRefresh)、空渲空态。 +- Item 单行:`padding-left: calc(8px + depth * 16px)`(谱系缩进=纯 CSS,数据已给 depth);内容 = running 状态点(绿=true 灰=false)+ mono sessionId 截断(头 8 字符 + `…`,title 挂全值)+ 右侧相对时间(formatRelative 复用 utils/,RPC 面板已建)。选中态底色 `--color-accent-soft`。 +- `React.memo(SessionListItem)`:list.items 数组换引用时壳重渲,条目 entry 引用未变的行跳过(Manager 快照构建保持未变条目引用稳定——§A.9.4 同纪律,summaries 增量更新只换变更条目)。 + +### §C.3 ConversationView(对话流骨架) + +```ts +export interface ConversationViewProps { + snapshot: ConversationSnapshot // draft 在快照内(§A.4;review 整改 #1 后无独立 draft prop) + onDraftChange: (text: string) => void + onSend: (mode: 'queue' | 'steer') => void + onStop: () => void + onLoadOlder: () => void +} +``` + +纵向三段: + +1. **头行**(固定):sessionId 截断 + running 点 + `foldDegraded`/`lastAgentError`/`removed` 的细条警示(有则渲)。 +2. **滚动区**(`flex:1; overflow-y:auto`): + - 顶部哨兵:`hasMore` 时渲「加载更早」行(`loadingOlder` 时转圈禁点);**v1 用显式按钮不用 IntersectionObserver 自动触发**(素朴实现;自动化留给 UI 替换轮)。 + - `snapshot.nodes.map(node => 按 kind 分发)`:user/steering/context/unknown→`MessageItem`、assistant→`AssistantMessage`、tool-result→`ToolCallCard`(key 一律 `node.seq`)。 + - `snapshot.partial` 非空 → `AssistantMessage`(partial 形状复用 blocks 渲染,加「生成中」脉冲点)。 + - `snapshot.runningCalls.map` → `ToolCallCard`(running 态,key=callId)。 + - `snapshot.pending.map` → `PendingCard`(key=rpcId)。 +3. **InputBar**(§C.6)。 + +**滚动行为(翻页锚定 + 跟随,任务书拍板 7)**: + +- 跟随底部(input-ux bb1a7ed5f 修订,两规则并存):①**流式跟随**——`atBottom` 位由 `onScroll` 监听维护(原「effect 里测距」在程序化滚动与快照连发下会断链),贴底时每快照置底、用户上滚离底即自然停跟;②**用户发送强制置底**——发送手势记 `forceBottom`(含发送时节点数),直到自己的消息节点入流为止强制贴底(即使此前处于离底状态——发消息表达的就是「看最新」意图)。无显式 paused 态,素朴版不做「回到底部」浮钮。 +- **翻页锚定算法**(prepend 不跳屏):`onLoadOlder` 点击前记 `prevScrollHeight = el.scrollHeight` 与 `prevScrollTop`;节点 prepend 渲染后(`useLayoutEffect` 观察 nodes[0]?.seq 变小),设 `el.scrollTop = prevScrollTop + (el.scrollHeight - prevScrollHeight)`——内容高度差整体补偿,视口停在原消息上。记录值放 ref(`pendingAnchor: {h, t} | null`),补偿一次即清。 +- 挂载时(open 完成首批 nodes 到达)滚到底一次:`useLayoutEffect` 监 openState 变 'open'。 + +### §C.4 MessageItem / AssistantMessage + +```ts +export interface MessageItemProps { node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode } +export interface AssistantMessageProps { + /** 定稿节点或 partial 投影(此形状差异收在容器分发处:partial 时 seq 传 -1、streaming=true)。 */ + blocks: readonly AssistantBlock[] + streaming: boolean +} +``` + +- text 块渲染统一走 `` 单组件(F.11 预埋:Markdown 化=换其内部实现)。 +- MessageItem 按 kind 渲:user=右对齐气泡(text 块拼接 pre-wrap;非 text 块 JsonBlock 折叠);steering=user 同款加「插话」徽标;context=折叠卡(标题「上下文注入」+ JsonBlock,默认收起——ui-product「非人类交互不进普通时间线」的素朴近似);unknown=折叠 JsonBlock(标题=type)。 +- AssistantMessage 按块序渲:text→pre-wrap 正文;reasoning→折叠区(默认**收起**,标题「思考过程」+字符数;拍板 4 可折叠);tool-call→内联卡片头(名称+callId 短形,实体卡片在 ToolCallCard——视觉上仅是「调用了 X」一行);other→JsonBlock。`React.memo`:blocks 数组引用不变即跳过(§A.9.4 partial 只换变更块引用,但 blocks 数组本身每 chunk 换引用——partial 消息始终重渲,定稿消息 memo 命中,符合预期成本模型 §A.6)。 + +### §C.5 ToolCallCard / PendingCard + +```ts +export interface ToolCallCardProps { + callId: CallId + call: { name: string; argsRaw: string } | null // null=窗口截断(§A.4),头部渲 callId + /** running=无 result;done=有。 */ + result: { content: readonly ContentBlock[]; isError: boolean; error?: { name: string; code: string } } | null +} +export interface PendingCardProps { + item: PendingInteraction + /** F.10 预埋:respond 里程碑传入即出按钮;本轮不传=纯展示。 */ + onRespond?: (rpcId: RpcId, payload: unknown) => void +} +``` + +- ToolCallCard:头行 = 状态点(running 黄脉冲/ok 绿/isError 红)+ name mono + callId 短形;体 = args JsonBlock(默认收起)+ result 有则 content 渲染(text 块 pre-wrap、其余 JsonBlock)。**「原地翻转」观感**:ConversationView 分发时 running 卡与 result 卡 key 不同(callId vs seq)会导致 DOM 重建——v1 接受(素朴实现无过渡动画,重建无感知差异);UI 替换轮若做动画再统一 key。JSON 折叠、presentation 后置(拍板 4)。 +- PendingCard:approval=黄底卡「等待审批:{toolName}」+reason;question=黄底卡逐条渲 questions 的 question/header 文本。**无按钮**(拍板 4 可见不可答;respond 交互 §F);注一行灰字「请在原客户端处理」。 + +### §C.6 InputBar(拍板 6:queue/steer 双按钮 + 停止) + +```ts +export interface InputBarProps { + draft: string + running: boolean + disabled: boolean // removed 或 openState!=='open' 时禁输入 + promptError: { op: 'send' | 'stop'; error: RpcError } | null + onDraftChange: (text: string) => void + onSend: (mode: 'queue' | 'steer') => void + onStop: () => void +} +``` + +- props.draft 来自 `snapshot.draft`(ConversationView 拆传;草稿住 Session 对象——§A.7 整改后 InputBar 仍是纯 props,零感知归属变化)。 +- 结构:textarea(自动增高 1–6 行;Enter=发送、Shift+Enter=换行)+ 右侧竖排按钮组。 +- **按钮语义**(core 三原语一步到位,空闲发送即开轮——契约 prompt 的 queue 空闲时自动开轮,UI 无需分支): + - 「发送」= `onSend('queue')`——排队/空闲开轮一个按钮(core send 语义一体,ui-product §8 表)。 + - 「插话」= `onSend('steer')`——仅 `running` 时可用。置灰是 **UI 教育语义**非 core 限制(直核 agent/src/types.ts:110:steer idle 时行为=send,不会拒绝;§D.5 对齐表)——置灰让两按钮语义区分可感知,避免「idle 时两个按钮等价」的困惑。 + - 「停止」= `onStop()`——仅 `running` 时渲染。 + - Enter 默认走「发送」;draft 空白时两发送钮禁用。 +- promptError 非空渲错误细条(`error.message` + code),下次发送自动清(§A.2 语义)。 + +### §C.7 App.tsx 改版 + +```tsx +
{/* height:100vh;两列布局归 SessionsScreen(§C.1)——选中态 owner 与布局 owner 同一组件 */} + + {/* 浮层不动,继续当开发观测器 */} +
+``` + +RPC 面板里程碑 §E.2 的 Sidebar 三段式素材(品牌行/Footer/Settings)**仍不启用**——本里程碑左栏只有列表本体;`--sidebar-width` 变量此轮引入 `:root`。 + +--- + +## §D 与契约的对接面 + +### §D.1 消费清单(契约方法/帧 ↔ 本设计消费点) + +| 契约面 | 消费点 | +|---|---| +| `session.list` | `SessionManager.refreshList`(onConnected 自动 + 刷新钮) | +| `session.create` | `SessionManager.create`(createSession intent;新建即选中由容器回调组合,§C.1) | +| `session.history` | `Session.open`(尾页)/ `Session.loadOlder`(beforeSeq 页)/ `Session.resync`(重连重拉尾页) | +| `session.prompt` | `Session.prompt`(queue/steer) | +| `session.cancel` | `Session.cancel` | +| `host.describe` | 不新增消费(Controller 既有连通探测;host 快照展示后置) | +| mux `session/event` | Session 窗口 append + fold/累积器/openCalls(§A.9 分发表) | +| mux `session/subscribed` | 缝检测基线(§D.3) | +| mux `approval|question/requested|resolved` | Session.pending 占位卡 | +| mux/host `stream/error` | Controller 重连(既有),业务层忽略 | +| host `session-added/removed/status/agent-error` | Manager 列表维护 + Session 转发(§A.3 路由表) | +| `/api/respond`(ClientResponse) | **不消费**(respond 交互 §F;PendingCard 只展示) | + +未消费的契约面(fork/inject/task/listModels §8 预留、`since` 续传、`approvals/questions` respond)本里程碑均不触碰——契约零改动诉求。 + +### §D.2 历史翻页(拍板 7 的完整数据路径) + +``` +open(): history({ sessionId }) → events=E, baseSeq=E[0].seq, hasMore +loadOlder(): history({ sessionId, beforeSeq: baseSeq, maxMessages: PAGE_MESSAGES }) + → 前插 events = [...older, ...events];baseSeq=older[0].seq;FoldAdapter.reset(§A.5.1 重建) +``` + +- `PAGE_MESSAGES = 50`(open 尾页与 loadOlder 同值;模块常量,GUI 免门禁期不做 config——契约 maxMessages 缺省行为由 server 定,client 恒显式传)。 +- **返回窗口连续性断言**:`older` 尾事件 seq + 1 必须 === 旧 `baseSeq`(契约页边界按消息切但事件 seq 连续无洞);不满足则 console.error + 丢弃该页并置 hasMore=false(fail-soft:显示已有窗口,不渲乱序流)。 +- UI 锚定补偿在 §C.3(scrollHeight 差);数据层职责止于「前插后同一微任务 flush 一次快照」——锚定需要 prepend 前后各一次同步测量,由 useLayoutEffect 保证在 paint 前完成。 +- 翻页与 live append 并发:prepend 只动窗口头部、append 只动尾部,天然无交叠;FoldAdapter.reset 在 prepend 时以「当时窗口全量」重建,期间到达的 live 事件排在 JS 任务队列后续处理(单线程顺序保证一致性)。 + +### §D.3 打开/重连的缝合规则(subscribed.lastSeq 缝检测) + +打开 session 的事件序(契约 §5 主路径的 client 侧精化): + +1. mux 流常开(Controller 起代即开,全 session 聚合);`session/subscribed` 帧在流打开时对 attached session 下发——**冷 session 无 subscribed 帧**(未 attach),其 lastSeq 基线视为「无」。 +2. `open()` 发 `history()`(冷 session 由 impl 隐式 resume——契约 §3.1;resume 后该 session 变 attached,此后帧照常来。**接缝问题 #2**:resume 发生在 mux 流已开之后,契约未明确 host 会不会为「新 attach 的 session」补发 subscribed 帧——若不补发,client 拿不到 lastSeq 基线,缝检测降级为「liveBuffer 合并去重」路径,可接受但基线语义残缺;报 README 请契约明确)。 +3. history 响应就绪:`events` 窗口初始化 → 合并 `liveBuffer`(open 在途期间到达的 live 事件):按 seq 过滤 `> 窗口尾 seq` 的 buffer 事件依次 append,重叠丢弃——**seq 是唯一去重键,透传纪律的直接红利**。 +4. 缝检测:若曾收 `subscribed.lastSeq > 当前窗口尾 seq` 且 liveBuffer 未覆盖中间段 → 再拉一次 history 补缝(契约 §3.3 拍板用途 1:1);实现为 open() 完成前的一次收尾核对。 +5. `resync()`(重连):= 清窗口回 `open()` 路径重跑(重连=重建,契约 §0.7);pending 交互清空等 subscribed 基线重放帧重建(契约 §3.4——host 对 pending 的 requested 帧原样重放,rpcId 不变,PendingCard 无感)。 + +### §D.4 增量成本模型(任务书性能注记的汇总答案) + +| 事件 | 成本 | +|---|---| +| 每 assistant/chunk | 累积器一次字符串拼接(块级引用更新)+ dirty 标记;fold 游标 O(1) 跳过;React 一次 partial 区重渲(微任务合批后) | +| 每消息定稿(assistant/message) | SurfaceManager 增量折一个事件(O(1) append)+ 物化一个新节点(缓存 miss 恰一次)+ partial 清除 | +| 翻页 prepend | SurfaceManager 全量重建 O(窗口事件数)+节点缓存清空重物化 O(窗口消息数)——低频用户操作,可接受(§A.5.1 取舍) | +| 帧风暴(多 session 并发跑) | 非选中 Session 照常吃帧更新内部状态,但其 listener 集为空(无订阅)→ 只有 dirty 标记无快照构建(§A.9.3 flush 仅在有 listener 时 build——实现细则:Notifier 无监听者时跳过 build,仅置 dirty;下次 subscribe/getSnapshot 时惰性 build) | + +### §D.5 core 对齐对照表(review 整改 #2;2026-07-19 逐条直核 packages/core/{session,agent}/src 与 packages/llm/llm/src 源码,非契约转述) + +#### 方法链(本设计方法 | 契约方法 | core 原语 + file:line) + +| 本设计 | 契约 | core 原语(直核) | +|---|---|---| +| `Session.sendDraft('queue')` → `prompt(content,'queue')` | `session.prompt` mode:'queue' | `Agent.send(content, options?)` — agent/src/types.ts:103(queue detached input;**空闲自动开轮**「starts a turn when idle」——§C.6 按钮语义的 core 依据) | +| `Session.sendDraft('steer')` → `prompt(content,'steer')` | `session.prompt` mode:'steer' | `Agent.steer(content, options?)` — agent/src/types.ts:110(injected between steps of the current turn;**idle 时行为=send**——§C.6「非 running 置灰」是 UI 教育选择,core 不会拒绝) | +| `Session.cancel()` | `session.cancel` | `Agent.cancel(reason?)` — agent/src/types.ts:127(clear queued+steering work+abort active step——契约「清两条 FIFO + abort 当前 step」1:1 成立) | +| `Session.open()/loadOlder()/resync()` | `session.history` | core `Session.events` getter — session/src/index.ts:322(append-only log 的不可变快照;seq=下标连续从 0——§D.2 连续性断言的 core 依据);分页切边界用的消息事件类型即 surface-eligible 五型 — session/src/surface.ts:11-17 | +| `SessionManager.create()` | `session.create` | `SessionStore.create(id?, options?)` — session/src/index.ts:606(options.meta 带 cwd/parentSession 入 SessionHeader) | +| `SessionManager.refreshList()` | `session.list` | **无单一 core 原语**(契约即如此设计):持久化条目=impl readdir+stat(updatedAt=mtime);live running 位可由 `SessionStore.list()` — session/src/index.ts:826(仅 live session,creation order)+ `Agent.status` 合成。非不对齐,是 impl 组合面,此处备档 | +| 快照 `running` | `host/session-status` 帧 | `Agent.status: AgentStatus` — agent/src/types.ts:95;union `'idle'|'running'|'disposed'` — types.ts:47;翻转事件 `agent/status` — types.ts:165。**注意三态→二态投影**:契约 running:boolean = (status==='running'),`disposed` 与 idle 同渲为不 running(列表条目无生命周期终态语义;host/session-removed 才是移除信号) | +| 快照 `lastAgentError` | `host/agent-error` 帧 | `agent/error` 事件族(agent/src/types.ts 的 error 通道;契约 core-coverage L5 裁决透传)——client 只消费 message 文本,无字段推导 | +| (§F 不做,备档)fork | 契约 §8 预留 `session.fork` | `SessionStore.fork(source, boundary?, childSessionId?)` — session/src/index.ts:843 | + +#### 数据推导(ConversationSnapshot 字段 ← core 事件/字段;「core 原样」=透传零转换) + +| 快照字段 | 来源与推导 | +|---|---| +| `nodes`(surface 序) | `SurfaceManager.nodes`(session/src/surface.ts:255;`foldSurface` 同源 surface.ts:244)吐 seq 数组 → 逐 seq 物化。surface-eligible 五型 = user/assistant/tool-result/context/steering message(surface.ts:11-17)——§A.4 六节点 union 的前五种 1:1,第六种 unknown 兜底 merge-extensible 扩展 | +| `UserMessageNode.content/source` | `'user/message': { content: ContentBlock[]; source: MessageSource }` — session/src/types.ts:199,core 原样 | +| `AssistantMessageNode.turn/step/usage` | `'assistant/message': { turn; step; content; provenance; usage? }` — session/src/types.ts:226,core 原样。**provenance 不进快照**(v1 无消费方,物化时丢弃——标注非透传纪律违例:快照是 UI 投影非 wire) | +| `AssistantMessageNode.blocks` | 同事件 `content: ContentBlock[]` 按块 type 分拣(llm/src/types.ts:44-49 四型 map):`TextBlock{type:'text',text}`→kind:'text';`ReasoningBlock{type:'reasoning',text}`(llm types.ts:17-20)→kind:'reasoning'——**reasoning 是 ContentBlock 类型非独立事件,直核确认**;`ToolCallBlock`→kind:'tool-call';其余→kind:'other' | +| `AssistantBlock(tool-call).callId/name/argsRaw` | `ToolCallBlock { type:'tool-call'; **id**: CallId; name; **arguments**: string }` — llm/src/types.ts:23-30。**字段名映射(易错点标出)**:块内是 `id`/`arguments`,**不是** callId/argsRaw——适配层映射 `block.id→callId`、`block.arguments→argsRaw`;而 `tool/call` **事件**的字段名是 `callId`/`arguments`(session/src/types.ts:232)——core 两处命名本就不一致,物化函数按各自真名取 | +| `ToolResultNode.*` | `'tool/result': { turn; step; callId; content; isError; error?; meta? }` — session/src/types.ts:242,core 原样;`call` 头 = 窗口内 `'tool/call': { turn; step; callId; name; arguments }`(types.ts:232)按 `CallId` join(callIndex,§A.5) | +| `SteeringMessageNode.turn/content/source` | `'steering/message': { turn; content; source }` — session/src/types.ts:244,core 原样(**无 step 字段**,直核确认——节点不设 step) | +| `ContextMessageNode.content/source/envelope/meta` | `'context/message': { content; source; envelope?; meta? }` — session/src/types.ts:212-217,core 原样(envelope/meta 本次整改补进节点,v1 JSON 渲出不解释) | +| `UnknownSurfaceNode.type/data` | merge-extensible `SessionEventMap` 未知扩展(session/src/types.ts:254 注释:plugin-merged extensions included)——documented-default 兜底 | +| `PartialAssistant.blocks` | `'assistant/chunk': { turn; step; chunk: StreamChunk }` — session/src/types.ts:219 累积;`StreamChunk` 六型 — llm/src/types.ts:151-163,§A.6 表逐型核对:block-start 带 `blockType`、text/reasoning-delta 带 `index/text`、tool-call-delta 带 `index/id/name?/argumentsDelta`、block-end 带定稿 `block`、usage/finish 忽略——**与源码逐字段一致** | +| `runningCalls` | 窗口内 `tool/call` 减去已有 `tool/result` 的 CallId 差集(两事件 callId 同名同 brand——llm CallId,session types.ts:232/242) | +| 节点 `seq` / 去重键 / 翻页锚 | `SessionEvent.seq`(session/src/types.ts:324 信封;seq=log 下标,index.ts:322 快照注释——「seq 连续无洞」断言的 core 保证) | +| `hasMore` | 契约 history 返回值(server 分页产物,core 无对应——分页是 apiproxy 层发明,core 只有全量 log) | +| `pending` | mux `approval/question requested/resolved` 帧(apiproxy 控制面发明,core 对应物是 approval waterfall/userInteraction provider——不经 SessionEvent,无字段推导) | +| `draft/promptError/openState/loadingOlder/foldDegraded/removed` | client 侧自造态,无 core 对应(备档防误会) | +| 列表 `parentSessionId/cwd` | `SessionHeader.parentSession/cwd` — session/src/types.ts:45/47(readonly,contract 经 SessionSummary 透传;header 不在事件日志里——index.ts:264 注释「kept out of the event log」,所以走 list 快照不走 fold) | +| 列表 `updatedAt` | 持久化文件 mtime(impl 产物,core 无「最后活动时间”字段——备档) | + +**不对齐发现:0 红线,2 处已消化进设计的注意点**——① ToolCallBlock 字段名 `id`/`arguments` vs 事件字段 `callId`/`arguments`(上表标出,物化函数按真名映射);② AgentStatus 三态 vs 契约 running 二态投影(disposed 的列表语义靠 session-removed 帧补齐)。方法链全部 1:1 成立,无 core 能力缺口。 + +--- + +## §E 验收清单(两级) + +### §E.1 fixture 级(无 host;`?fixture`;fixture.ts 能力增量前置) + +fixture 需扩展(实现工单的一部分,RPC 面板 §A.5 基线上加): +- `session.history`:对 fx-alpha 返回一段**手造事件脚本**(约 3 页量:含 user/assistant/tool call+result/steering/reasoning 块/context 各若干,seq 连续、surfaceOp 齐全),支持 beforeSeq 切页;fx-beta/gamma 返回空。 +- `events.mux`:打开后对 fx-alpha 依次推「subscribed → 延时逐帧回放一段 live 脚本(含 assistant/chunk 流式段 + tool call/result + approval/requested)」——chunk 段按 80ms/帧回放模拟打字机。 +- `session.prompt`:收到后往 mux 流回推「user/message 事件 → chunk 流式段 → assistant/message 定稿」循环脚本;`cancel` 停止当前回放段。 + +| # | 步骤 | 期望 | +|---|---|---| +| 1 | 开 `?fixture` | 左列表 3 条(fx-alpha running 绿点;谱系若 fixture 配 parentSessionId 则见缩进);右侧空态 | +| 2 | 点 fx-alpha | 对话流渲出历史脚本全节点:user 气泡/assistant 正文/reasoning 折叠(点开有内容)/工具卡双态/steering 徽标/context 折叠卡;滚动在底部 | +| 3 | 顶部「加载更早」 | 前插一页,**视口不跳**(锚定在原消息);到最早页按钮消失(hasMore=false) | +| 4 | 观察 live 脚本 | partial 区打字机增长 → 定稿瞬间转正式节点无闪烁;工具卡 running→done;approval 占位卡出现(无按钮) | +| 5 | 输入框发送(queue) | user 气泡入流 + 回放的流式回复;草稿清空;Enter 触发同按钮 | +| 6 | running 期间「插话」 | steer 路径走通(fixture 回 accepted;流里回放 steering/message 帧);非 running 时按钮置灰 | +| 7 | 「停止」 | 回放段停止;running 点熄灭(fixture 推 status 帧) | +| 8 | 切到 fx-beta 再切回 fx-alpha | fx-beta 空对话;切回 fx-alpha 即时呈现(实例常驻,无重拉 loading 闪烁);期间 fx-alpha 后台若有帧,切回可见 | +| 9 | 新建按钮 | 列表新增条目并自动选中打开 | +| 10 | RPC 面板对照 | 以上每步流量在调试面板可见(history/prompt/cancel 往返 + 帧台账)——两里程碑产物互证 | + +验收方式:playwright chromium headless 自跑(gui-playwright-self-verify 纪律),不留人手验。 + +### §E.2 真 host 级(W1–W5+impl 全通后) + +| # | 步骤 | 期望 | +|---|---|---| +| 1 | `dsc web` 起真 host,开首页 | 列表=真 .sessions 目录(updatedAt 倒序);含子 session 时缩进正确 | +| 2 | 打开一个有历史的 session | 尾页渲染正确(与 jsonl 对读抽查);向上翻页至最早,事件无缺无重 | +| 3 | 新建 + 发送真 prompt | 流式回复打字机;工具调用卡片随执行翻转;停止按钮中断 | +| 4 | 第二个浏览器页签打开同 session | 两页签同步收帧(多 client 行为未定义但不崩——契约 v1 口径) | +| 5 | kill host 重启 | 重连后列表刷新、打开中的 session 重拉尾页重建;pending 审批卡随基线重放恢复 | +| 6 | 长 session(数千事件) | 打开耗时可感知但不卡死;翻页/流式期间输入不掉帧(增量模型 §D.4 生效的粗验) | + +--- + +## §F 架构妥协台账(review 整改 #3:不只列「不做」,每条给【触发条件→返工点→预埋要求】) + +**读法**:触发条件是具体事件(「上了 X 之后」),不是「将来」;预埋要求是本轮实现就要守的形,让返工时收得拢。无返工含量的纯范围排除收在 F.13 一行。 + +| # | 妥协 | 触发条件 | 返工点 | 预埋要求(本轮就做) | +|---|---|---|---|---| +| F.1 | 跨切换视图态不保持(key=sessionId 重挂载,滚动/折叠全重置) | 上 recycle/虚拟列表分页时——虚拟列表本身要求滚动位置/可视窗口状态外置化,届时视图态保持是必做不是可选 | 视图态从组件局部提升到 per-session 归属(大概率挂 Session 对象或容器持有的 per-id Map) | 组件视图态读写走**单一入口**:ConversationView 及子组件的滚动/折叠态若超出单组件,就收敛为 props 可选对 `viewState?/onViewStateChange?`,不散落多处 useState | +| F.2 | tool 卡「原地翻转」靠 DOM 重建(running 卡 key=callId、result 卡 key=seq,两张卡非同一节点) | 上状态过渡动画时——动画要求 running→done 是同一 React 节点的状态变化 | ConversationView 分发处合并两态为单一 ``(result 从 snapshot 按 callId join 进 props) | ToolCallCardProps 已是双态一体(call+result 可空)——**分发逻辑集中在 ConversationView 一处**,不让子组件各自感知两态来源 | +| F.3 | 翻页 replace 跨窗即 foldDegraded 降级(fail-soft 显示+警条) | compact/replace 事件真实落地进任何被翻页打开的 session(现在触发面≈0,compact 上线后必现) | 契约补 replace 闭包语义(replace 目标所在页整体返回或 server 侧展开——接缝 #1),client 删降级分支 | 降级路径**独立成 FoldAdapter 内一个分支函数**+快照单布尔 foldDegraded,删除时零散点;不在 UI 层特判 | +| F.4 | `PAGE_MESSAGES = 50` 模块硬编码 | 本包转正进仓库门禁(GUI 免门禁期结束)——「无硬编码 tunables」家规届时直接命中 | 升 Config 字段走 cordis.yml/boot options | 常量**单点定义**在 session.ts 顶部并注明「转正时升 Config」;调用处全部引用常量名 | +| F.5 | 翻页 prepend 全量重建 SurfaceManager + padding 哨兵 O(baseSeq) | 万级事件 session 实测翻页可感知卡顿(§E.2-6 粗验不过) | core surface 支持 seq 偏移窗口(SurfaceManager 收 baseSeq 参数)或 client 自写增量 prepend fold | FoldAdapter 的 reset/append 已是唯一 fold 入口——返工只动 fold-adapter.ts 一文件;**不让 Session 直接碰 SurfaceManager** | +| F.6 | removed/闲置 Session 实例常驻不释放(拍板 2 全实例活着) | 长跑单页(数百 session 打开过)内存实测超预算,或 host/session-removed 高频场景出现 | SessionManager 加逐出策略(removed 且无订阅者 N 分钟后 dispose;Map 换 LRU) | Session 已有明确「无监听者不 build 快照」惰性(§D.4);**新增 dispose() 预留为 no-op 方法**,Manager 是唯一持有 Session 引用的地方(hooks 不长期持引用) | +| F.7 | 未实例化 session 的 mux 帧直接丢(§A.3 路由表「不懒建」) | 需要「后台未打开 session 的未读计数/预览」类需求(ui-product §6 待处理计数上列表时) | Manager 帧路由加轻量 per-session 计数器(不建全量 Session,只记 metadata) | 路由函数**单点 switch**(§A.3 表即代码结构);丢帧分支显式 `// drop: not instantiated` 注释可 grep | +| F.8 | 草稿仅内存(刷新即丢) | 用户实际丢稿投诉出现,或做「多 client 草稿同步」时 | Session.setDraft 加 localStorage 写透(key=sessionId),构造时读回 | draft 读写已收口 Session 对象两个方法——加持久化只动 session.ts,UI 零改 | +| F.9 | api-types.ts 临时契约副本(W3 前) | W3(apiproxy fetch/client + 包出口)落地即触发(不是可选:§C 对齐纪律 2 要求真 import) | 删 api-types.ts,全部 import 改 `@deepseek-ai/dsh-apiproxy` 真类型;流信封窄形随真签名 | 副本文件头已标「W3 后删除」;**web-runtime 内所有契约类型 import 集中经 api-types.ts 一个文件转口**,替换=改一处 re-export | +| F.10 | respond 交互不做(PendingCard 可见不可答) | 下一里程碑主菜(用户已排期),无额外触发条件 | PendingCard 加按钮 + `/api/respond` 通路(ClientResponse 回填 rpcId) | PendingInteraction 已带 rpcId(回填键在手);PendingCardProps 预留 `onRespond?` 可选回调位——**本轮不传即纯展示** | +| F.11 | 对话正文纯文本 pre-wrap(无 Markdown/GFM/KaTeX/高亮) | UI 打磨轮启动(style-design 调研落地后) | MessageItem/AssistantMessage 的 text 块渲染函数换 Markdown 组件 | text 渲染**抽 `` 单组件**,替换=换其内部实现,卡片结构零动 | +| F.12 | 诊断视图不做(context/message 折叠卡混在主流) | ui-product §7「非人类交互不进普通时间线」被用户重申(大概率随真实 harness 会话——context 注入高频——一起来) | ConversationView 分发处按 kind 分流到诊断区/主流两列表 | ContextMessageNode 独立 kind 已就位——分流只是分发处加一行 filter,节点类型无需重构 | +| F.13 | 纯范围排除(无预埋、无返工形状,触发即整块新做):tool presentation 附件(契约 §3.3 遗留)、虚拟列表(ui-product 一期口径)、样式/动画/暗色(RPC 面板 §E.4 素材另轮)、重命名/标题(契约无 title 字段,additive)、列表排序/筛选/搜索/分页、多 client 互斥/since 续传/rpcId 幂等(契约 §6 同步)、agent-error toast 通道 | — | — | — | + +## §G 实现工单切分建议(供 dispatcher 参考,非本文约束) + +1. **S1 runtime 对象层**:session/ 五文件 + connection sinks + store/intents/boot 增量(§A 全部)——纯 TS 可单测(fold 适配、lineage、累积器、缝合都是纯逻辑)。 +2. **S2 fixture 扩展**:§E.1 前置的脚本能力(依赖 S1 的类型,不依赖 UI)。 +3. **S3 hook + 组件**:§B+§C(依赖 S1 出口)。 +4. **S4 playwright 验收**:§E.1 清单脚本化。 + +S1/S2 可并行 S3 的组件静态部分(props 契约已定,可先用假快照渲);对接真契约(删 api-types.ts 换 import)视 W3 进度独立小工单。 diff --git a/missions/tasks/20260719-2315-style-research/README.md b/missions/tasks/20260719-2315-style-research/README.md new file mode 100644 index 0000000000..cccad6e52e --- /dev/null +++ b/missions/tasks/20260719-2315-style-research/README.md @@ -0,0 +1,37 @@ +# 20260719-2315 style-research:deepseekchat 样式风格调研 + +**负责人**:style-owner(GUI 样式常驻 teammate) +**参考仓(只读)**:`/weka-hg/prod/deepseek/permanent/ys/private/workspace/gitlab/deepsuite-frontend`(主应用 `apps/chat`) +**目标**:产出「风格基线 + 样式工程编码模式」调研报告,供 web-ui 侧边栏 / session 会话界面统一风格。调研风格与模式,不抄组件。 + +## 进展 + +| 步骤 | 状态 | +| --- | --- | +| README 存活信号 | ✅ | +| 1. 设计 token 体系 | ✅ | +| 2. 视觉风格基线(侧边栏+会话流) | ✅ | +| 3. 样式工程编码模式 | ✅ | +| 4. 暗色主题实现 | ✅ | +| 5. 可移植资产清单 + 阶段二建议 | ✅ | + +## 产出 + +- [style-research.md](style-research.md) — 调研报告(完稿,五节 + 阶段二建议) +- [docs/web-styling.md](../../../docs/web-styling.md) — 阶段二首件:长期样式规范(token 表权威定义 + 视觉基线 + 编码规范 12 条 + 演进规则),取值证据回链本报告 + +## 阶段二改造记录(RpcLog 面板照规范落地,2026-07-20) + +- `style/global.css` 重写为规范三分区(token 表 + 基础 + `.scrollable` 工具类);规范 §1.1 增补 `--scroll-color*`、`--text-on-solid` 两组 token。 +- `RpcLog.module.css` / `App.module.css` 全量换 token 引用(零裸色值;`#fff` → `--text-on-solid`),圆角对齐语义档(浮层 16 / 按钮 8),hover 换透明度制,交互过渡统一 `--dur-fast` + `--ease`;payload 底色改 `--bg-sidebar` 与面板分层。 +- TSX 仅动 className:`.list` / `.payload` 挂 `.scrollable`(RpcLogBody.tsx、PayloadJson.tsx),组件逻辑零变。 +- 验收:vite build 绿;`scripts/verify-rpclog-panel.mjs` 10/10 PASS;对比图 [rpclog-before.png](rpclog-before.png) → [rpclog-after.png](rpclog-after.png)(截图脚本 [shot-rpclog.mjs](shot-rpclog.mjs))。 +- 视觉基线零偏离(规范 §5 偏离表保持空)。附带修复:worktree 首次 `pnpm install` 缺失导致 web-runtime 的 zustand 未链接、build 红——install 后绿,与样式改造无关。 + +## 核心结论速览 + +- token 三层(static→alias→specific)+ `body[data-ds-dark-theme]` 整表覆盖,组件零主题感知;我们按体量压成两层。 +- 视觉基线:侧边栏 261px、条目 40px/圆角 12px/选中 deepseek-100 淡蓝底;会话流 840px 列宽,仅用户侧有气泡(22px 圆角、deepseek-50 底),助手侧纯文档流。 +- 边框与 hover 用黑/白透明度制(叠任何底色都成立),文字五级灰阶,动效三档时长+三条贝塞尔。 +- 工程模式:camelCase + clsx、composes 零使用、:global 只穿透第三方前缀、动态样式走 CSS 变量桥、tcm 生成 .css.d.ts 提交进仓。 +- §5.2 给出我们的 token 表草案(亮色实值+暗色占位)、§5.3 十条编码规范、§5.4 现有三 css 改造要点。 diff --git a/missions/tasks/20260719-2315-style-research/shot-rpclog.mjs b/missions/tasks/20260719-2315-style-research/shot-rpclog.mjs new file mode 100644 index 0000000000..7c9e8207c5 --- /dev/null +++ b/missions/tasks/20260719-2315-style-research/shot-rpclog.mjs @@ -0,0 +1,29 @@ +// RpcLog 面板截图(改造前后对比用)。跑法:node shot-rpclog.mjs <输出png> +import { chromium } from 'playwright' + +const out = process.argv[2] ?? 'rpclog.png' +const BASE = process.env.DSC_WEB_URL ?? 'http://127.0.0.1:3080' + +const browser = await chromium.launch() +try { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) + await page.goto(`${BASE}/?fixture`, { waitUntil: 'load' }) + // step-session 换壳后(SessionsScreen 两列)以侧栏为壳存在断言 + await page.waitForSelector('aside') + const badge = page.locator('button', { hasText: 'RPC' }).first() + await badge.waitFor({ state: 'visible' }) + await page.waitForTimeout(500) + await badge.click() + await page.locator('div[class*="list"]').first().waitFor({ state: 'visible' }) + // 造几行 + 展开一行 payload,让截图信息量足 + for (let i = 0; i < 3; i += 1) { + await page.locator('button', { hasText: 'ping' }).click() + await page.waitForTimeout(80) + } + await page.locator('button[class*="rowLine"]').first().click() + await page.waitForTimeout(200) + await page.screenshot({ path: out }) + console.log(`saved ${out}`) +} finally { + await browser.close() +} diff --git a/missions/tasks/20260719-2315-style-research/style-research.md b/missions/tasks/20260719-2315-style-research/style-research.md new file mode 100644 index 0000000000..a9659f6ebb --- /dev/null +++ b/missions/tasks/20260719-2315-style-research/style-research.md @@ -0,0 +1,218 @@ +# deepseekchat 样式风格调研报告 + +参考仓:`/weka-hg/prod/deepseek/permanent/ys/private/workspace/gitlab/deepsuite-frontend`(下文以 `dsf/` 指代),主应用 `dsf/apps/chat`。所有 file:line 均相对 `dsf/`。 + +## 1. 设计 token 体系 + +### 1.1 三层命名法:static → alias → specific + +全部颜色 token 挂在 `body` 上(不是 `:root`,为了让 `body[data-ds-dark-theme]` 属性选择器天然胜出),前缀 `--dsw-`(deepseek web),由 cssVarLint 门禁约束(`apps/chat/cssVarLint.config.json:9` 只允许 `--dsw` / `--ds` 前缀的变量定义在指定文件中)。 + +三层结构(`packages/theme/src/newDesign.css`): + +| 层 | 命名模板 | 例 | 语义 | +| --- | --- | --- | --- | +| **static** | `--dsw-static--` | `--dsw-static-neutral-bluish-75: rgb(241,243,245)`(newDesign.css:60) | 原始调色板,亮暗两份定义但值几乎相同(palette 本身不随主题变) | +| **alias** | `--dsw-alias--` | `--dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000)`(newDesign.css:194) | 语义角色层,**组件主要引用这层**;亮暗主题在这层重新映射 | +| **specific** | `--dsw-specific--` | `--dsw-specific-sidebar-nav-item-hover: var(--dsw-static-neutral-bluish-75)`(newDesign.css:228) | 组件专属槽位,只给个别高频组件(sidebar/bubble/input/menu)开小灶 | + +static 层色相族:`amber / blue / deepseek(品牌蓝) / green / red / neutral / neutral-bluish`。阶梯是 Tailwind 式 50–950,且按需插半档(60/75/450/550/750/875)——**阶梯按视觉需要扩展,不追求等距**。品牌色 `--dsw-static-deepseek-500: rgb(57,100,254)`(newDesign.css:23)。 + +alias 层的 role 分类(newDesign.css:147-230 亮色全表): + +- `bg-*`:`bg-base` / `bg-layer-1..3`(**海拔分层**:亮色下全白,暗色下逐层变浅,见 §4)/ `bg-mask-1..3` / `bg-overlay` / `bg-skeleton` +- `border-l1..l4`:边框只用**黑/白透明度**(亮 `rgba(0,0,0,0.04)`→`0.16`,newDesign.css:161-165),不用实色灰——叠在任何底色上都成立 +- `label-*`:文字五级 `primary / secondary / tertiary / caption / dimmed` + 反相 `primary-inverted` / `primary-foreground` +- `interactive-bg-*`:hover/active 态统一用**带蓝调的透明色** `rgba(38,49,72,0.06)`(hover)/ `0.1`(active)/ `0.14`(hover-accent)(newDesign.css:184-188),保证叠加在不同底色上表现一致 +- `button--`:primary/ghost/link/floating/elevated/contrast × fill/hover/dimmed +- `state-*`:error/success/warn × primary/secondary/tertiary +- `markdown-*`、`scrollbar-*`、`toast-bg`、`tooltip-bg`:场景专属 + +### 1.2 非颜色 token:`--ds-` 前缀,住 theme/global.css + +`packages/theme/src/global.css:27-87` 挂在 `body, page, .ds-theme` 上: + +- **控件高度阶梯**:`--ds-control-height-xl/l/m/s/xs` = 44/40/36/32/28px(global.css:30-34) +- **字号+行高成对阶梯**:`--ds-font-size-l/m/sp/s/xsp/xs` = 16/14/13/12/11/10px,行高 28/25/23/21/19.5/18px(≈1.75 倍率)(global.css:40-62);`body` 默认 `font-size: var(--ds-font-size-m)`(global.css:92) +- **粗体权重平台自适应**:`--ds-font-weight-strong: 600`,Apple 或 en_US 环境降为 500(global.css:37, 106-111) +- **缓动曲线**:`--ds-ease-in-out/in/out` 三条贝塞尔(global.css:65-69) +- **过渡时长**:`--ds-transition-duration` 0.2s / fast 0.1s / slow 0.3s(global.css:82-86) +- **字体栈**:正文 `--dsw-font-family`('quote-cjk-patch' + Inter + system-ui 系,global.css:3-5);代码 `--ds-font-family-code`(Menlo/Consolas/JetBrains Mono 长栈,**末位故意不放 monospace** 防 Windows 中文宋体,global.css:72-79) + +**没有间距/圆角 token**——间距和圆角在各组件 CSS 里写死 px 值(见 §2 的节奏归纳),token 化只覆盖颜色/字号/高度/动效。 + +### 1.3 引用纪律与门禁 + +- 组件 CSS 只写 `var(--dsw-alias-*)` / `var(--dsw-specific-*)` / `var(--ds-*)`,基本不直接引 static 层(个别渐变特效除外)。 +- `cssVarLint.config.json` 声明 token 定义文件白名单(`packages/theme/src/**/*.css`、`apps/chat/src/style/global.css` 等),其余文件定义 `--dsw/--ds` 变量会被 lint 拒绝——**token 单一来源是被工具强制的**。 +- 应用侧准入:`apps/chat/src/index.tsx:53-62` 顺序 import `@deepseek/theme/es/global.css` → `newDesign.css` → md/auth 包 CSS → 应用自己的 `style/global.css`(应用级只补 `--scroll-color`、mermaid 高度等少量变量,`apps/chat/src/style/global.css:20-32`)。 + +## 2. 视觉风格基线(侧边栏 + 会话流重点) + +### 2.1 侧边栏 + +- **宽度**:`--sider-width: 261px`(注释「留了一像素给右边框」,`apps/chat/src/components/animationSider/AnimationSider.module.css:1-4`)。 +- **容器**:底色 `--dsw-specific-sidebar-fill`(亮=bluish-50 近白灰,暗=bluish-900),右边框 `1px solid var(--dsw-alias-border-l1)`(4% 黑),内边距 `6px 12px 10px`(`wideSider/WideSider.module.css:10-21`)。 +- **条目(会话项)**:高 40px、圆角 **12px**、padding `9px 6px 9px 10px`、字号 14px(`sessionItem/SessionItem.module.css:1-16`)。三态: + - hover:`--dsw-specific-sidebar-nav-item-hover`(亮=bluish-75),且用 `@media (hover: hover)` 包裹避免触屏误触发(SessionItem.module.css:46-58); + - active(当前会话):`--dsw-specific-sidebar-nav-item-active-accent`(亮=deepseek-100 淡品牌蓝底)+ 文字保持深色(SessionItem.module.css:37-44)——**选中态用品牌淡底而非改文字色**; + - 条目右端操作按钮:默认 `display:none`,hover/选中时显示,且左侧接一段**同底色渐变遮罩**盖住溢出文字(`--mask-base-color` 按亮/暗/选中三套 RGB 值切换,SessionItem.module.css:59-97)——文字截断不用 ellipsis 而用渐隐。 +- **分组小节**:sticky 标题(`top:0`、字号 12px/行高 18px、`label-tertiary` 色、weight 500、底色同 sidebar-fill 遮滚动内容,`sessionList/SectionShared.module.css:1-11`);节间距 `margin-top: 16px`(SectionShared.module.css:42-48)。列表底部再叠一条 68px 高的渐变遮罩过渡到容器底色(`sessionList/SessionList.module.css:108-124`,暗色下渐变起点色单独覆写)。 +- **新建会话按钮**:白底胶囊(圆角 100px、高 40px),**三层组合阴影**造浮起感,hover 换更深的三层阴影;暗色下改用 `inset` 高光 + 单层投影(`newChatButton2/NewChatButton2.module.css:15-70`)。快捷键提示 `⌘ J` 用 `::after` 常驻、hover 淡入(:38-55)。 +- **收合动画**:`transform: translateX(-sider-width)` + `max-width` 双过渡,时长用 token `--ds-transition-duration-slow`(0.3s) + `--ds-ease-in-out`;移动端 fixed+mask,桌面端(`--md-viewport`)相对布局压缩 max-width(AnimationSider.module.css:9-92)。 + +### 2.2 会话流 + +- **列宽**:`--message-list-max-width: 840px`,水平 padding 44px;`--max-lg-viewport`(<1024px)降为 712px/36px(`routes/session/Session.module.css:1-10`)。消息列 `flex:1` 居中、`max-width:100%`(:52-60)。 +- **用户消息(气泡)**:右对齐、底色 `--dsw-specific-bubble`(亮=deepseek-50 极淡品牌蓝,暗=bluish-850),**圆角 22px**、padding `10px 16px`、字号 16px/行高 24px、`max-width: calc(100% - 88px)`(小屏 -68px)(`userMessage/UserMessage.module.css:88-106,115-119`)。 +- **助手消息(无气泡)**:透明底直接排版,不加底色(`assistantMessage/AssistantMessage.module.css:1-13`)——**只有用户侧有气泡,助手侧是纯文档流**,这是 deepseekchat 会话流的核心视觉特征。搜索高亮时才用 `::before` 垫一块 12px 圆角的 `bg-multi-select` 底(:39-72)。 +- **消息操作条**:默认 `opacity:0`,父块 hover / focus-within 时淡入,时长/曲线走 token(UserMessage.module.css:58-73、AssistantMessage.module.css:75-86)。 +- **消息间距节奏**:用户消息块 `padding-bottom: 16px`(UserMessage.module.css:5);正文 markdown 字号 16px/行高 28px(`--dsw-font-markdown-base`,`packages/theme/src/newDesignGradientShadowText.css:50-55`)。 +- **输入框**:大圆角 24px、底色 `--dsw-specific-input-major`、box-shadow 过渡(`chatInputUi/ChatInputUi.module.css:18-38`);水平居中公式 `padding: 0 calc((100% - var(--message-list-max-width)) / 2)`(`routes/session/InputCompose.module.css:6`)。 + +### 2.3 字体与字号 + +- 正文栈:`'quote-cjk-patch', 'Inter', system-ui, ...`(theme/global.css:3-5);代码栈见 §1.2。 +- UI 字号实用阶梯:侧边栏条目/按钮 14px、分组标题/辅助文字 12px、气泡与 markdown 正文 16px。粗体统一 `--ds-font-weight-strong`(500/600 平台自适应)。 +- Figma 插件导出的复合字体 token `--dsw-font-: / var(--dsw-font-family)` 速记形式 + 拆分字段并存(newDesignGradientShadowText.css:20-56,注释注明由 `@deepseek-figma-plugin/custom-variable-name` 导出)——设计稿→token 有自动化管道。 + +### 2.4 圆角 / 阴影 / 图标 / 滚动条 + +- **圆角实用值普查**(apps/chat 全部 module.css):12px(侧边栏条目、高亮垫底)> 16px > 8px(小控件)> 100px/999px 胶囊 > 22-28px(气泡、输入框)。无圆角 token,按组件语义取值:**小控件 8、列表条目 12、大容器/气泡 22-28、胶囊 100px**。 +- **阴影 token**:`--dsw-shadow-lv1/lv1-blur/lv2/lv3` 三级海拔(多层低透明度黑,如 lv3 = 1px 描边影 + 4px 近影 + 32px 远影,newDesignGradientShadowText.css:5-9);组件特殊阴影(如新建按钮)直接写字面量。 +- **图标**:无 iconfont、几乎无 .svg 资产文件(apps/chat 仅 1 个 qrcode.svg)。主方案是 **手写 TSX 内联 SVG 组件库**:`packages/ui/src/icons/index.tsx` 94 个 `IconXxx{Outline|Fill}{16|20|24}` 导出,`fill="currentColor"` 吃 CSS `color`;配套 `` 包装组件控制盒尺寸(`packages/ui/src/icon/Icon.tsx`)。rspack 同时配了 @svgr/webpack(`apps/chat/rspack.config.ts:231-235`)作零散兜底。命名带尺寸后缀(16/20/24)对应 viewBox。 +- **滚动条**:两套并存——通用元素用 `.scrollable` 全局类:`scrollbar-color: var(--scroll-color) transparent` + `scrollbar-gutter: stable`,hover 才加深(`apps/chat/src/style/global.css:24-41`);重滚动区用自研 `ScrollArea` 组件画 gutter,颜色接 `--dsw-alias-scrollbar-*` token,带 1s 延迟淡出(`packages/ui/src/scrollArea/ScrollArea.css`)。**共同点:滚动条默认近隐形、hover 激活、永不占布局**。 +- **过渡尺度**:几乎所有交互过渡走三档 token(0.1/0.2/0.3s)+ 三条贝塞尔;opacity/transform 为主,配 `will-change`;不做大型 keyframe 动画(骨架屏 shimmer 除外)。 + +## 3. 样式工程编码模式 + +### 3.1 CSS Modules 纪律 + +- **类命名 camelCase**(`.sessionItem` `.actionButtonMask` `.menuContainer`),状态类用简单形容词(`.active` `.collapsed` `.show` `.editing`),无 BEM 残留。 +- **`composes` 零使用**(全仓 grep 无一处)——复用靠 token 变量和组件封装,不靠类继承。 +- **`:global` 只用于两类**(apps/chat 共 67 处):① 穿透 UI 库前缀类(`.ds-focus-ring` `.ds-modal` `.ds-select` 等 `ds-*`);② 穿透 markdown 渲染类(`.md-code-block`)。**从不**用 :global 定义新全局类——全局类只在非 module 的 global.css 里定义(如 `.scrollable`)。 +- **`.module.css` 之外的 css 只有四类**:token 表(theme 包)、应用 global.css、markdown 覆写、第三方覆写(cookieBanner)。 + +### 3.2 PostCSS 特性面 + +构建链只有 4 个插件(`shared/rspack-postcss-rule/index.ts:12-19`):`@csstools/postcss-global-data`(注入 media.css)→ `postcss-custom-media` → `postcss-nested` → `autoprefixer`。 + +- **nested**:全面使用 `&:hover` `&.active` 及子类嵌套,但嵌套层级实践上 ≤3 层。 +- **custom-media**:断点表集中在 `packages/viewport/media.css`,Tailwind 式命名 `--sm/md/lg/xl/2xl/3xl-viewport`(min-width 440/768/1024/1280/1536/1920)+ 对偶 `--max-*-viewport`(`not all and (min-width:)` 写法),由 postcss-global-data 注入所有 css 免 import。注释明确「sm 440 是设计师定的」。 +- **响应式组织**:断点内联在各组件 css 尾部(媒体查询贴着被覆盖的规则),不搞集中式响应式文件;变量级响应式直接在 `:root` 里嵌 `@media` 重设 token 值(Session.module.css:1-10 的做法)。 + +### 3.3 className 组合与类型 + +- **clsx 为唯一组合器**(89 处 import),模式统一:`clsx(styles.item, cond && styles.active, className)`(如 `avatarMenuSettingDialog/AvatarMenuSettingDialog.tsx:503`);组件一律接受外部 `className` 合入。 +- **typed-css-modules 工作流**:`tcm -p src/**/*.module.css` 生成 `.css.d.ts`(`apps/chat/package.json:28-29`),dev 时 `tcm --watch` 与 dev-server 并跑(package.json:9);**`.css.d.ts` 提交进仓**(.gitignore 只排 `.css.d.ts.map`),typecheck 前先跑 tcm(package.json:32)。d.ts 形如 `declare const styles: { readonly "sessionItem": string; ... }; export = styles`。非 module css 靠 `declare module '*.css' {}`(`apps/chat/src/css.d.ts`)。 +- **应用级 global.css 边界**:只放 ① 少量应用私有变量(--scroll-color、mermaid 高度)② body 级排版/字体平滑 ③ 极少数工具类(`.scrollable` `.pointer-events-none`)(`apps/chat/src/style/global.css`)。**没有 reset/normalize 文件**——靠 body margin:0 + 组件自理。 +- **动态样式走 CSS 变量桥**:TSX 里 `style={{'--dsl-icon-svg-height': h}}`(Icon.tsx:25-27)、CSS 里 `--mask-base-color` 按主题覆写(SessionItem.module.css:60,90-97)、focus ring 用 `--on: 1` 开关(SessionItem.module.css:18-22)——**JS 只写变量,规则始终在 CSS**。 + +## 4. 暗色主题实现 + +**机制**:`body[data-ds-dark-theme]` 属性选择器整表覆盖。亮色表 `body {...}`(newDesign.css:147),暗色表 `body[data-ds-dark-theme] {...}`(newDesign.css:232)重定义**同名 alias/specific 变量**。组件零感知——组件 CSS 里没有任何 `[data-theme]` / `prefers-color-scheme` 分支(全仓组件 module.css 无一处主题选择器)。 + +**切换器**:`packages/app-kit-web/src/plugins/theme.ts:44-61` `handleThemeChange()`——设/删 `document.body.dataset['dsDarkTheme']`,同时维护 `body.light/.dark` class(`.dark` 主要用来设 `color-scheme: dark` 让 Safari 原生滚动条变暗,theme/global.css:115-118)。主题偏好三态 light/dark/system,system 态用 `matchMedia('(prefers-color-scheme)')` 双监听(theme.ts:106-121)。 + +**防闪烁细节**:切换瞬间给 `body.change-theme` 注入 `* { transition: none !important }`(theme.ts:5-17, 45-60),setTimeout(0) 后移除——避免每个带 transition 的元素在换主题时各自渐变造成撕裂。 + +**暗色映射规律**(我们做暗色表时直接套用): + +- 底色海拔:亮色 `bg-base/layer-1/2/3` 全白;暗色 = bluish-950/875/850/800(newDesign.css:233-236)——**越浮起越亮**。 +- 文字:亮 `label-primary` = bluish-1000 → 暗 = bluish-50;secondary 700→300;tertiary 600→400(**围绕 500 轴对称翻转**)。 +- 边框/hover:黑透明度 → 白透明度,且暗色下透明度略调高(border-l2 亮 0.1 → 暗 0.12;interactive-bg-hover 亮 0.06 → 暗 0.08)。 +- 品牌色暗色下提亮一档:brand-primary 500 → 450,brand-text 500 → 400(newDesign.css:252-253)。 +- 侧边栏:亮 bluish-50 → 暗 bluish-900(比 bg-base 950 浮一层)。 + +## 5. 可移植资产清单 + 阶段二建议 + +### 5.1 可直接搬的 token 值 + +**颜色**(deepseekchat 实值,直接进我们 global.css): + +- 品牌蓝 `rgb(57,100,254)`(≈我们现有 `--color-accent: #4d6bfe` 的正源,建议改成 deepseek-500 实值 `#3964fe`;hover 提亮档 `#5686fe`=450) +- 淡品牌底:气泡 `#edf3fe`(deepseek-50)、选中 accent `#e4edfd`(deepseek-100) +- neutral-bluish 灰阶(我们只需 8 档):`#ffffff`(00) `#f9fafb`(50) `#f1f3f5`(75) `#ebeef2`(100) `#61666b`(700) `#232324`(875) `#1b1b1c`(900) `#151517`(950) +- 文字:primary `#0f1115`(bluish-1000) / secondary `#61666b`(700) / tertiary `#81858c`(600) / caption `#adb2b8`(400) +- 边框透明度制:l1 `rgba(0,0,0,.04)` l2 `.1` l3 `.12`;hover 透明度制:`rgba(38,49,72,.06)`,active `.1` +- 语义:error `#ec1313`(red-600) / success `#22c55e`(green-500) / warn `#f59e0b`(amber-500) + +**非颜色**: + +- 字号/行高对:16/28(正文长文)、14/25(UI 默认)、13/23、12/21(辅助) +- 控件高度:40/36/32/28 +- 动效:0.1/0.2/0.3s + `cubic-bezier(0.4,0,0.2,1)`(in-out) +- 圆角语义档:8(小控件)/ 12(列表条目、面板内块)/ 16(浮层)/ 22(气泡)/ 999(胶囊) +- 阴影三级:lv1 `0 2px 4px rgba(0,0,0,.05)`、lv2 `0 4px 12px rgba(0,0,0,.02), 0 2px 8px rgba(0,0,0,.04)`、lv3 `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` +- 代码字体栈整条照抄(§1.2,注意末位 sans-serif 防宋体的细节) +- 侧边栏几何:宽 260+1px 边框、条目高 40/圆角 12、分组标题 12px/500/sticky +- 会话列宽 840px(<1024 降 712);用户气泡圆角 22px/padding 10px 16px/max-width calc(100% - 88px) + +### 5.2 我们的 token 表草案(亮色实值 + 暗色占位) + +规模对齐我们的体量:**两层不三层**(palette 直接内联进语义层注释;specific 层只留 sidebar/bubble 两组),前缀沿用无前缀 `--color-*` 或换 `--ui-*` 由阶段二拍板。挂 `:root`,暗色用 `[data-theme='dark']` 覆盖(我们已有占位约定,等效 deepseekchat 的 body 属性方案)。 + +```css +:root { + /* 表面(海拔)*/ + --bg-base: #ffffff; /* dark: #151517 */ + --bg-layer: #ffffff; /* dark: #232324 浮层/面板 */ + --bg-sidebar: #f9fafb; /* dark: #1b1b1c */ + /* 文字 */ + --text-primary: #0f1115; /* dark: #f9fafb */ + --text-secondary: #61666b; /* dark: #cfd3d6 */ + --text-tertiary: #81858c; /* dark: #adb2b8 */ + /* 边框/交互态:透明度制,双主题只换黑白 */ + --border-l1: rgba(0,0,0,.04); /* dark: rgba(255,255,255,.06) */ + --border-l2: rgba(0,0,0,.1); /* dark: rgba(255,255,255,.12) */ + --hover-bg: rgba(38,49,72,.06); /* dark: rgba(255,255,255,.08) */ + --active-bg: rgba(38,49,72,.1); /* dark: rgba(255,255,255,.14) */ + /* 品牌 */ + --accent: #3964fe; /* dark: #5686fe */ + --accent-soft: #edf3fe; /* dark: #28313f 近似 deepseek-900 */ + --accent-item: #e4edfd; /* 侧边栏选中;dark: #35363a */ + /* 语义 */ + --ok: #22c55e; --error: #ec1313; --warn: #f59e0b; + /* 专属槽位 */ + --bubble-bg: #edf3fe; /* dark: #2c2c2e */ + /* 字体 */ + --font-ui: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-mono: Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif; /* 末位不放 monospace */ + --fw-strong: 600; + /* 动效 */ + --ease: cubic-bezier(.4,0,.2,1); + --dur: .2s; --dur-fast: .1s; --dur-slow: .3s; + /* 圆角语义档 */ + --radius-s: 8px; --radius-m: 12px; --radius-l: 16px; --radius-bubble: 22px; + /* 阴影 */ + --shadow-panel: 0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08); +} +``` + +(字号沿用「组件里写 px、成对写行高」的 deepseekchat 实践,不 token 化;间距同样不 token 化——参照仓也没做,见 §1.2。) + +### 5.3 样式编码规范草案(10 条) + +1. 颜色/圆角/动效/字体栈**只准引 token**,组件 css 不出现字面量色值(渐变遮罩等特效除外,须注释)。 +2. 暗色适配只在 token 表做:组件 css 禁止出现 `[data-theme]` 选择器;确需按主题换非 token 值(如渐变端点)时,用「CSS 变量桥」——组件定义局部变量、主题块只覆写变量。 +3. 类名 camelCase;状态类用单形容词(`.active` `.show`)由 clsx 条件挂载:`clsx(styles.x, cond && styles.active, className)`;组件必须透传 `className`。 +4. 不用 `composes`;复用靠 token 与组件抽取。 +5. `:global` 仅用于穿透第三方/跨包类名,禁止用它定义全局类;全局工具类只住 global.css 且总数个位数。 +6. 交互过渡一律 `var(--dur*) var(--ease)`,只过渡 opacity/transform/背景色/阴影;hover 展示型元素配 `@media (hover: hover)`。 +7. hover/active 底色优先用透明度制 token(`--hover-bg`),保证叠加在任意海拔底色上成立。 +8. 文字五级色阶按语义取用(primary 正文 / secondary 次要 / tertiary 辅助说明),不新造灰色。 +9. 滚动条统一 `.scrollable` 工具类(`scrollbar-color` + `scrollbar-gutter: stable` + hover 加深),不各自写 `::-webkit-scrollbar`。 +10. 媒体查询贴着被覆盖规则写在组件 css 尾部;断点先只设一档 1024px(列宽降档),有第二个消费者再扩表。 + +### 5.4 现有三个 css 文件改造要点 + +- **`style/global.css`**:① 按 §5.2 重排 token 表(现 `--color-hover: #ececee` 实色灰 → 换透明度制;`--color-accent: #4d6bfe` → `#3964fe`;补 radius/dur/ease/强调弱底/气泡槽位;`--color-frame-mux/host` 调试方向色保留);② 补 `[data-theme='dark']` 覆盖块(值照 §5.2 注释);③ body 字体栈换 `--font-ui` 并补 `-webkit-font-smoothing: antialiased`;④ 新增 `.scrollable` 工具类。 +- **`App.module.css`**:`.app` 拆出侧边栏骨架时直接用 `--bg-sidebar`/`--border-l1`;`.blank` 的 `28px` 标题字号无碍保留,次要文字色改 `--text-tertiary`。 +- **`RpcLog/RpcLog.module.css`**:① 硬编码 `#fff`(.unread 文字)→ token;② `.actions button:hover` / `.rowLine:hover` 的 `--color-hover` 换透明度制 `--hover-bg`;③ 圆角 4px/8px 归到 `--radius-s/m` 档;④ `.list` 加 `.scrollable` 行为;⑤ `.badge`/`.panel` 的 `border-radius: 999px`/`8px` 分别对齐胶囊档与 `--radius-m`;面板阴影已是 lv3 风格,接 `--shadow-panel` 即可。改造为纯替换,不动布局。 + +### 5.5 阶段二遗留决策点 + +- token 前缀要不要学 `--dsw-` 加命名空间(我们建议 `--ui-` 或维持无前缀,等 lead 拍板)。 +- 暗色触发选 `[data-theme='dark']`(已有占位)还是学 body dataset——建议维持现约定,语义等价。 +- tcm(.css.d.ts 生成)我们已有 `css-modules.d.ts` 通配声明,体量小可不上 tcm;若组件数过 20 再引入。 +- postcss-nested/custom-media:Vite 内置 postcss 支持,加两个插件成本低;但当前无嵌套需求,阶段二可先不加,写平铺 css。 diff --git a/missions/tasks/20260719-2315-style-research/upgrade-rpclog-v2.md b/missions/tasks/20260719-2315-style-research/upgrade-rpclog-v2.md new file mode 100644 index 0000000000..1fa3335bca --- /dev/null +++ b/missions/tasks/20260719-2315-style-research/upgrade-rpclog-v2.md @@ -0,0 +1,22 @@ +# RpcLog 视觉升级点清单(v2,试点模板) + +> 格式即模板:每条一句「现状 → 目标」,改前列出、改后逐条勾。token 增补记 web-styling.md §1。 + +1. [x] 面板抬升感:单薄 lv3 阴影 → 新 `--shadow-float`(近描边 + 中投影 + 大远影三层,lv3 加强档),浮层与页面明显分离。 +2. [x] 视觉分区:工具行/列表同底白 → 工具行与暂停条铺 `--bg-sidebar` 浅灰底,列表区留白,形成头/体分区。 +3. [x] 行节奏:行 padding 4px 12px 贴边挤 → min-height 32px、padding 5px 14px、列距 10px,列表上下留 4px 呼吸。 +4. [x] 方向符徽章化:裸箭头字符四色难辨 → 22px 圆角小色块(象限软底 + 深字),四象限一眼可辨;mux 紫 / host 蓝软底 token 化。 +5. [x] 配对高亮加强:accent-soft 太淡 → `--accent-item` 深一档底 + 左缘 2px 品牌蓝 inset 指示条。 +6. [x] 工具行按钮质感:裸文字 → 次要色文字按钮(padding 4px 10px、圆角 8、hover 透明底 + 文字变主色;激活态 accent-soft 底)。 +7. [x] 角标徽章:平面胶囊 → `--shadow-float` 抬升 + hover 阴影加深上浮 1px、未读红点加 2px 白描边提对比。 +8. [x] payload 分区:同白底 → `--bg-sidebar` 底 + 上缘细分隔 + 次要色文字,与行区分层。 + +token 增补(值取自 deepseekchat 色板,已进 web-styling.md §1 + global.css):`--ok-soft`(green-100/900)、`--error-soft`(red-100/900)、`--frame-mux-soft` / `--frame-host-soft`(方向色透明软底)、`--shadow-float`(lv3 加强档)。 + +验收(2026-07-20):build 绿;verify-rpclog-panel.mjs **9/10**——§D-6b「清空后周期帧继续进入」超时属 fixture 重写副作用(工作区新 fixture.ts 删除了旧版 `setInterval(5000)` 周期帧,CSS 无法影响帧到达;归 runtime/验收脚本侧对齐),其余 9 项含全部交互路径 PASS;截图 [rpclog-v2.png](rpclog-v2.png)。 + +## v2.1 修订(2026-07-20,用户拍板):方向符左右 → 上下 + +- 空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE。`↑` client-request / `↓` server-response / `⇟` server-request / `⇞` client-response;徽章配色不变。实测 mono 栈下 `⇟` 渲染清晰(v2.1 截图紫徽章可辨双线),不需 ⇊/⇈ 备选。 +- 改动面:LogRow.tsx 四个 symbol 字面量 + 注释;verify-rpclog-panel.mjs 三处符号断言同步(§D-2/§D-3 grep 旧箭头,不改会假红);shot-rpclog.mjs 壳断言跟进 SessionsScreen 换壳(h1 → aside)。语义表进 web-styling.md §2。 +- 验收:build 绿;verify **10/10 ALL PASS**(fixture 周期帧失败项已被 session-design 修复);截图 [rpclog-v2.1.png](rpclog-v2.1.png)。 diff --git a/missions/tasks/20260719-2339-web-cordis-design/README.md b/missions/tasks/20260719-2339-web-cordis-design/README.md new file mode 100644 index 0000000000..bd3a39164d --- /dev/null +++ b/missions/tasks/20260719-2339-web-cordis-design/README.md @@ -0,0 +1,7 @@ +# web 侧 Cordis 插件系统设计 + +- **状态**:v1 全稿完成(2026-07-20),§E 装配与配置含 Q1–Q7 问题清单待用户拍板后定稿;其余章节可 review +- **追加输入(2026-07-20)**:用户升格「双端插件包 + web Loader 可配置化」为核心命题,§E 由此从「build 期静态装配一句话」扩为七问选项分析 +- **负责人**:web-cordis-design teammate(常驻) +- **命题**:在浏览器侧建一套与 node harness 对等的 Cordis 插件系统——回答「web root context 上注册哪些 Service」「哪些与 node 对等、哪些各端独有」。本轮只设计服务层地基,不含 UI 插件(tool renderer/面板注入),不写代码。 +- **产出**:design.md(§A cordis 浏览器可运行性 → §B 服务清单 → §C 手工模块插件化路径 → §D 跨端通信面 → §E 装配与配置 → §F 分期 → §G 妥协台账) diff --git a/missions/tasks/20260719-2339-web-cordis-design/blueprint-v2.md b/missions/tasks/20260719-2339-web-cordis-design/blueprint-v2.md new file mode 100644 index 0000000000..3d81cec65d --- /dev/null +++ b/missions/tasks/20260719-2339-web-cordis-design/blueprint-v2.md @@ -0,0 +1,206 @@ +# web-cordis 蓝图 v2(用户口述定稿外化) + +> 2026-07-20。本蓝图与 design.md(旧设计)冲突处**以本文为准**,旧文后续按此重写。与旧文的推翻/保留关系见文末对照表。 +> 命名裁决:web-cordis 相关配置与命名**一律用 client,不用 browser**(入口名、program、Loader 等全部 client 措辞)。 +> 入口形态修正:**不做 `./node` 入口**——node 半边就是各包现有的 index 主入口(`"."`),不干涉存量;双端包只**新增**两个子路径。 + +## 1. 双端包入口:主入口 + 新增 client/shared + +一个 npm package 的双端形态 = 现有主入口 + 新增两个子路径: + +| 入口 | 内容 | +|---|---| +| `"."`(主入口,即现有 index) | 插件 node 半边——存量不动 | +| `./client`(新增) | 插件 client 半边 | +| `./shared`(新增) | 双边通信类型(双向 RPC 方法声明住这里) | + +package.json `exports` 写清楚;**构建规范强制**——导出信息、入口形态都定死,不留每包自由发挥。 + +## 2. 类型宇宙强隔离(必须拦住) + +client 的 TS program **不得看见** node 侧对 cordis Context 的 interface merge——client context 上只能看到 client 自己挂的 service。 + +- 这是**理论正确方式**,必须做到,不是 review 红线级的口头约定。 +- 拦截在**编译期**:file-set 不相交的 program / gate 脚本等,工程方案待定。 + +## 3. client 挂什么 service + +暂无倾向,可能都先不挂;cordis 本身基建(timer 等)先挂着。旧 design.md §B.1 的服务清单(api/connection/sessionHub…)降权,不是本蓝图关注点。 + +## 4. 对等 Loader:1:1 强对等 + +node Loader 加载的插件,若声明了 client 半边,client 侧**对等 Loader 以同一插件 ID 拉起对等体**;代码也许同一套、ID 同一个。 + +前置顺序(依赖链即时序): + +1. host 侧先注册(resolve 出 node 路径与实体); +2. client 侧持有 host 已加载插件列表(**要具备更新能力**); +3. client 按 ID 经 web server 代理拉取该插件的 client JS 产物。 + +## 5. 产物形态与构建强限制 + +- client 半边产物 = **完全 bundle 好的 dist.js**(非 CommonJS)。 +- 外部依赖**强制 external**(cordis 等);提供启动器、由宿主注入依赖——封装性比现在高一级。 +- **用我们的构建模型自动打出插件 client 半边**:内部包改 tsdown 配置加一份特殊编译方式,全员共用,不许每包自造。 + +## 6. 点对点 + 双向 RPC + +- **拓扑**:client 插件实例与 host 插件实例**点对点**(父亲也点对点);更复杂形态先不考虑,但设计时要想一遍有没有特殊问题。 +- **双向通信**:client ctx 挂一个符号访问 host 侧该插件声明的方法;host ctx 挂一个符号访问 client 侧声明的方法。 +- **声明方式**:方法由插件包自己在 `./shared` 入口声明(遵循我们提供的 creator/泛型);框架自动解析出**带类型的双向 RPC**并自动挂 context。官方不代为声明。 + +## 6a. ctx.peer API 设计 + +> 设计前置全部闭环(2026-07-20):三入口/类型隔离/builder(无 callback)/hold/ClientPeerProxy 路由/config 同源/Q3 拉取式/四问。 + +**①已拍:per-plugin scoped `ctx.peer`**——符号名即 peer(点对点语义自明);方法面来自 shared 声明;插件只能 serve/of **自己的**通道。 + +**shared 声明严格走 zod**(用户裁决,否掉 `{} as {...}` 幻影类型——「只为代码提示没意义」): + +```ts +// ./shared 入口 +export const echoPeer = definePeer('echo', { + host: { method: { args: z.tuple([...]), result: z.schema } }, // host 侧供 client 调的方法 + client: { ... }, // client 侧供 host 调的方法 +}) +``` + +- `z.infer` 推导编译期签名;wire 两端各 parse 一次(发送端出参、接收端入参),坏载荷在 peer 框架层拒收。 +- 信封层对 args/result 照旧透传——「官方协议不代表插件类型」(四问裁决③)不变,只是插件侧从幻影类型升级为 zod 实证。 + +**API 形态**: + +| 面 | 形态 | 语义 | +|---|---|---| +| 接收方 | `ctx.peer.serve(echoPeer.host, 实现对象)` | 一次性注册;缺方法/类型不符=编译错;fiber dispose 自动撤 | +| 发送方 | client 侧发往 host:形态留正式稿定(单对端,`ctx.peer.host.send` 或 `ctx.peer.call`);host 侧见 §6a.3(broadcast / clientPeerProxy.send) | Proxy 按属性转发 `plugin.invoke`;client→host 走 unary、host→client 走帧+respond | +| 生命周期 | `ctx.peer.available` + `peer/connected\|disconnected` 事件 | 事件随 ClientPeerProxy 建立/dispose 发(见 §6a.3) | + +**shared 运行时口径精化**:shared 必然含运行时(zod schema + definePeer 调用),「零运行时/import type only」不再成立;纪律改为**方向性**——shared 不得 import 任何半边;半边消费 shared 的 peer 对象是值 import(拿 schema)。 + +附带红利:zod 实现 StandardSchemaV1,与 fiber Config 校验同机制(design.md §A 早有记录)。 + +**待拍余项**:**清零**。~~②多 client 时 host→client 的路由语义~~〔已拍:ClientPeerProxy 模型,见 §6a.3〕;~~③client 半边 config 与 host 同源确认~~〔已拍(2026-07-20):**同源**——client 半边插件的 config 与 host 侧同一份,host 声明单一事实源,boot/重连拉取下发(Q3 拉取式的自然延伸)〕;~~声明风格圈选~~〔已圈定 builder,见 §6a.1〕。 + +### §6a.1 shared 声明形态:builder 终版(已圈定 2026-07-20) + +**裁决:builder(A 系)胜出**,吸收 raw shape 优点+「全对象」原则:`.input()/.output()` **只收 raw shape**(框架代包 `z.object`,全对象物理强制)。**v1 方法只有 input+output(一次请求一次应答)**——`.callback()` 不进 v1 词汇(收紧修正见下)。用户原话:「大家的 callback 形式都不太好看,更偏向 builder,能有效限制」。 + +```ts +// ./shared 入口 +export const echoPeer = peer('echo', (m) => ({ + host: { + echo: m.input({ text: z.string() }).output({ reply: z.string() }), + }, + client: { notify: m.input({ message: z.string() }) }, +})) +``` + +builder 状态机约束:链只有 `.input(shape)` 与 `.output(shape)` 两段,`output` 后链终结、每段至多一次——编译期限制。 + +**落选记录**:B 纯字面量(`{ input, callbacks, output }` 表驱动)与 C raw shape 字面量(同 B 但去 z.object 包裹)——两者的 callbacks 字面量形式均被否(「都不太好看」);C 的 raw shape 与「全对象物理强制」被吸收进 builder 终版;B/C 完整代码块见 git 历史。 + +**调用侧**(声明形态不影响此面): + +```ts +// client 半边:发(单对端;形态留正式稿定,此处示意) +const { reply } = await ctx.peer.host.send(echoPeer.host).echo({ text: 'hi' }) +// client 半边:收 +ctx.peer.serve(echoPeer.client, { + notify: async ({ message }) => { showToast(message) }, +}) +// host 半边:收(第二参=来源 ClientPeerProxy,见 §6a.3) +ctx.peer.serve(echoPeer.host, { + echo: async ({ text }, client) => ({ reply: `ECHO: ${text}` }), +}) +// host 半边:发——广播(void 方法)或经 ClientPeerProxy 定向 +await ctx.peer.broadcast(echoPeer.client).notify({ message: 'host started' }) +``` + +**回调(多次进度通知类)不进 v1**:多次回调本质是流语义。触发条件=真实插件出现「执行中多次通知」需求时再议,届时评估流/事件形态而非塞回请求-应答。(曾评估过「回调=关联原 rpcId 的反向 server-request 帧」方案,完整讨论见 git 历史。) + +### §6a.2 加载窗口 hold 语义(已拍 2026-07-20) + +host→client 调用遇 client 半边**尚未 apply** → **hold 不 reject**。根据:启动流程=host 通知 client 创建,client Loader 持有「创建中」集合——「加载中」与「不存在」可区分。 + +| # | 机制 | 内容 | +|---|---|---| +| 1 | 持有方=client | per-plugin hold 队列,apply 完成按到达序 drain;host 侧不感知 hold,unary 30s 超时天然兜底 | +| 2 | 三出口 | apply 成功→drain;加载失败→全队 reject(`plugin-load-failed`);host 超时先到→client drain 时弃(`not-pending` 兜) | +| 3 | 「不存在」立即 reject | 不在列表也不在创建中 → `no-such-peer`;hold 只覆盖「已通知创建、尚未 apply」窗口 | + +### §6a.3 多 client 路由:ClientPeerProxy 模型(已拍 2026-07-20;API 面简化修正同日) + +每个 client 连接一个 `ClientPeerProxy`(派发语义类比 AgentScope/dsh-scope 的 `Scoped` 先例——scope 一词下文仅作此语义说明用):连接建立分配 clientId+ClientPeerProxy,断线即 dispose(hold 队列/pending 随清)。(推翻曾提的「主对等体」方案。)**host 与 client 插件不是点对点——host 插件面向 group**:host 侧 `ctx.peer` 顶层只有 **serve / broadcast / clients 三件**,定向 send 下沉到 ClientPeerProxy 对象。 + +| # | API | 语义 | +|---|---|---| +| 1 | `ctx.peer.serve(X.host, impl)` | 实现第二参=来源 ClientPeerProxy(host 必须知道来源,沿 tools/execute 的 exec.agent 先例) | +| 2 | `ctx.peer.broadcast(X.client).method(...)` | 广播全部在线对等体;**只准调无 `.output()` 的方法**——builder 类型状态机编译期强制(有 output 的方法不出现在 broadcast 代理上) | +| 3 | `ctx.peer.clients` | 全对端 ClientPeerProxy Map | +| — | `clientPeerProxy.send(X.client).notify(...)` | **顶层无 peer.send/peer.of**——定向能力只在 ClientPeerProxy 上:从 serve 来源参数或 clients Map 拿到 proxy 才能定向发;proxy 随连接断而 dispose,send 自然失效,无悬空 clientId 问题。要返回值必须定向——「通知=广播、问答=定向」由 API 形状物理强制 | + +client 半边对称简化:单对端,形态(`ctx.peer.host.send` 或 `ctx.peer.call`)留正式稿定。 + +hold 融合(§6a.2 的 per-client 化):hold 队列归 per-client 的 ClientPeerProxy;定向调用按 §6a.2 hold;**广播对未就绪 client 跳过不 hold**(通知 best-effort)。 + +### §6a.4 实现组件与命名(已拍 2026-07-20) + +**命名规则**:无 Proxy 后缀=本体(在本进程);Proxy 后缀=**对岸实体在本进程的替身**。两对镜像: + +| host 进程 | ↔ | client 进程 | +|---|---|---| +| `ClientPeerProxy`(替身) | ↔ | `ClientPeer`(本体) | +| `HostPeer`(本体) | ↔ | `HostPeerProxy`(替身) | + +**host 侧三件**: + +| 组件 | 职责 | +|---|---| +| `HostPeer` | host 自己的 peer 本体:serve 注册 / `ctx.peer` 实现所在 | +| `PeerGateway` | group 管理者:`clients: Map`、broadcast 扇出、serve 来源路由——host 插件面向 group 的那个 group 就是它 | +| `ClientPeerProxy` ×N | 每个远端 client 的替身:`.send` 定向能力、per-client hold/pending 账本挂它上,dispose 随连接断 | + +**client 侧两件**:`ClientPeer`(client 自己的 peer 本体)+ `HostPeerProxy`(host 的替身,即 `ctx.peer.host`——client 发起调用经它;单对端所以无 gateway)。 + +**三层对象图**(前两层均 host 侧实现,用户确认): + +``` +hostCordisContext PeerGateway + ClientPeerProxy ×N clientCordisContext ×N +(现有 host 运行时; ↔ (host 侧新组件;宿主位置=apiproxy ↔ (各浏览器真实 cordis 运行时; + 插件 node 半边 apply 处) plugin.invoke 域旁、runtime 装配层挂载) 插件 client 半边 apply 处, + 内含 ClientPeer+HostPeerProxy) +``` + +两侧 Proxy 用同一套信封编解码(shared zod schema)——「代码也许同一套」(§4)的实现落点。 + +### §6a.5 收尾三答(已拍 2026-07-20,设计线闭环) + +| # | 问题 | 裁决 | +|---|---|---| +| 1 | client 半边装配上下文 | **「假装都没有」**——ctx 上只有 cordis 基建(timer/logger)+ `ctx.peer`,不暴露 api/connection/sessionHub 任何 web-runtime 对象;插件将来要 session 数据也**走 peer 问自己的 host 半边**,不开白名单服务。连带:旧 design §B.1 服务清单**正式作废** | +| 2 | plugin.invoke 信封 | **无独立信封设计**——peer 的 serve/send/broadcast 语义即信封全部;wire 表达在正式稿从 peer 语义直接推导 | +| 3 | UI 插件线 | 方向预告(不设计):底层原语 `ctx.ui.registerSlot()`(类比 tool 注册),tool 卡呈现层=其上封装的 tool ui registry;触发条件=三型卡 switch 落地+echo demo 跑通 | + +## 7. 与 React 无关 + +以上全部是**朴素浏览器插件层**(注入/双向通信/启动注册/依赖注入;「浏览器」指运行环境,命名仍用 client);cordis×React 关联后置。 + +## 四问裁决(2026-07-20) + +| # | 问题 | 裁决 | +|---|---|---| +| 1 | shared 入口纪律 | 可以是一个或两个文件(文件数不重要),核心=有一个专门定义类型与双边函数签名的东西;~~约束手段=对它的 import 必须是 `import type`~~〔已被 §6a 修订:shared 走 zod 后必然含运行时,纪律改为方向性——shared 不得 import 任何半边〕 | +| 2 | 父子树对等 | host 侧 reload → client 侧也 reload;**Loader 两边严格 1:1 绝对对应是硬保证**,Loader 之外的对应关系「别的说不准」——对等性收口在 Loader 层,不外溢承诺。约束推论:client Loader 消费的更新面(§4 前置顺序第 2 步)要能表达 reload 事件,快照 vs 增量的 wire 形态设计时以此为约束 | +| 3 | 双向 RPC 与四象限的关系 | **性质上复用四象限**,新域 `plugin.invoke` 逻辑设计成立;**但类型上官方协议不代表插件类型——底层纯透传**:payload 对官方 RpcMethodMap 是 opaque,插件自己的类型由 `./shared` 入口的 creator 泛型在插件侧两端成型,不进官方契约的编译期锁 | +| 4 | 产物分发安全 | 用户裁「后面再说」→ 记档:v1 仅分发第一方构建产物、无完整性校验;hash/签名校验进妥协台账,触发条件=第三方插件出现 | + +## 与旧 design.md 的对照 + +| 旧结论 | 本蓝图处置 | +|---|---| +| Q1 双端包形态:显式子路径(推荐 B) | **保留并升级**:主入口(`"."`,node 半边即现有 index)+ 新增 `./client`、`./shared` | +| Q2 web Loader:registry map 假动态(推荐 A) | **推翻**:真动态按 ID 经 web server 代理拉取 bundle 产物 | +| Q4 双端互通:v1 不支持(推荐 C) | **推翻**:双向 RPC 是一等公民,框架自动挂 context | +| §B.0 类型宇宙「绕不开」结论 | **推翻**:必须编译期强拦,client program 不得见 node merge | +| §B.1 服务清单 | 降权,非本蓝图关注点(见 §3) | diff --git a/missions/tasks/20260719-2339-web-cordis-design/bundle-loader-design.md b/missions/tasks/20260719-2339-web-cordis-design/bundle-loader-design.md new file mode 100644 index 0000000000..e95bbd0b41 --- /dev/null +++ b/missions/tasks/20260719-2339-web-cordis-design/bundle-loader-design.md @@ -0,0 +1,119 @@ +# 产物 bundle + Loader 拉包链路(明细设计) + +> 2026-07-20。正式稿 [design.md](design.md) §5 的明细展开;裁决基线=blueprint-v2(bundle dist.js/强制 external/启动器注入/按 ID 经 web server 代理拉取)。本文明细中标注【选型】的项给理由,标注【关键设计点】的项给选项+推荐,供用户 review 圈定。 + +## 1. 每插件单独打包 + +**【选型】构建器 = tsdown**(不用 vite lib mode):仓库运行时 bundle 本就是 tsdown(构型统一、共享 preset 即「全员共用的特殊编译方式」的落点);vite 只活在 apps/web 应用层,给每个插件包引 vite 是第二套构建系统。tsdown 原生支持 esm 单文件+external+独立 d.ts 发射,够用。 + +| 项 | 定案 | +|---|---| +| entry | `src/client.ts`(`./client` 出口的源头) | +| format | esm 单文件(非 CJS——浏览器原生 import) | +| 产物 | `dist/client.js`(+ `.map`);`.d.ts` 走既有独立发射(类型消费者是 TS program,不经 bundle) | +| target | 浏览器基线(es2022;与 apps/web vite target 对齐,实施时取同一常量) | +| sourcemap | 带(dev 排障必需);发布裁剪与否随第三方开放一并议(§6 台账) | + +**依赖处置判据**(三条规则,不是清单): + +| 判据 | 处置 | 例 | +|---|---|---| +| **跨边界身份**:对象要 apply 进宿主 ctx / 与宿主共享 class 身份(instanceof、Symbol) | **不打进 bundle**:`import type` 只取类型,运行时实体经启动器参数注入(§2)——插件运行时零跨边界 import | cordis(Context/Service)、cosmokit(若暴露类) | +| **纯值语义**:只被鸭子协议消费(调方法不验身份) | **一律内联**(各 bundle 自带副本) | zod(peer 框架经 StandardSchemaV1 鸭子协议调 parse,不 instanceof——这正是 §6a zod 红利的用处);插件私有依赖 | +| **插件自有代码** | **一律内联**(bundle 自包含) | `./shared` 的 peer 声明(值 import 进 client 半边) | + +结果:bundle **没有任何运行时裸包名 import**——依赖供给全走 §2 参数注入,不依赖浏览器模块解析配置。 + +## 2. 启动器形态(终裁:DSHClientProxy 命名空间注册,bundle 不 export) + +**bundle 不 export 任何东西**——执行时主动调框架命名空间对象的注册方法。全局面只占一个名字 **`window.DSHClientProxy`**(用户定名;不带下划线前缀——是否加防撞前缀曾议,以此拼写为准): + +```ts +// dist/client.js 的执行效果(包装层由构建自动生成,见下) +window.DSHClientProxy.loadPlugin({ + id: 'echo', // = 插件 ID(与 node 半边同 ID,1:1 对等的钥匙;Loader 对账用) + callback() { // 惰性工厂:注册 ≠ 实例化 + return { + Config, // zod schema(config 同源下发后 client 侧再 parse;zod 为内联副本) + apply(ctx: ClientContext, config: Config): void { … }, + } + }, +}) +``` + +**DSHClientProxy = client 侧框架总入口**,框架未来能力都长在此对象上、不再新增全局(「还能干别的」是设计动机): + +| 成员 | 职责 | +|---|---| +| `loadPlugin({id, callback})` | 插件注册口(本节协议) | +| `newContext()` | client realm 的 cordis Context 创建也经它——web-runtime boot 里 `new Context()` 那步统一走此口(形态提案:方法返回新 realm 的 Context;boot 自己也是消费者) | +| `version` | 供 bundle 兼容自检 | +| (按需生长) | 调试钩子、registry 查询等 | + +- **callback 惰性层的价值**:主框架先收齐注册、再按自己的序点火——实例化时机/依赖序完全归主框架,bundle 执行只表达「我到了」。 +- **预置与防御**:`window.DSHClientProxy` 由 web-runtime boot 在拉任何 bundle 前挂好(loadPlugin+newContext+version 起步);防御一句——若 bundle 因缓存等原因先于 boot 执行(理论不应发生),入口脚本顶部的极小 shim 缓冲注册、boot 后重放。 +- **依赖供给 = apply 参数注入**(不变):宿主构造 `Context` 经 `apply(ctx, config)` 传入;插件对 cordis 的耦合面=`ClientContext` 类型(`import type`,编译后消失)。bundle **零运行时 import**(cordis 从参数来、zod/shared 内联)——双实例问题消解为无问题,成本只是体积(§6 台账)。 +- **包装层自动生成**:`DSHClientProxy.loadPlugin({id, callback})` 调用壳由 tsdown 共享 preset 的 banner/footer 生成(id 取自包声明)——**插件作者只写 apply(与 Config)**,注册协议零手写。 +- **备胎(不实施)**:共享实体注入——DSHClientProxy 下挂运行时实体 + tsdown `globals` 映射。触发条件:出现参数注入覆盖不了的共享需求(如插件间共享同一大型运行时实体)。 + +**单例部署、多例可测**(实现约束,正面写成设计约束——G-2 getSessionManager 单例的教训不再重演): + +1. DSHClientProxy 的全部状态(注册表/「创建中」集合/hold 队列/newContext 产出的 realm 引用)**收在实例内,禁止模块级状态**(仓内先例教训:rpc-log 模块级 Map 跨实例串味、boot 重入——同族问题已收编两次)。 +2. window 挂载只是 boot 时的**部署动作**(`createDSHClientProxy()` 工厂 + `window.DSHClientProxy = 实例`),不是构造约束。 +3. 测试维度:vitest 直接 `new` 多实例并行验证(注册隔离/各自 loadPlugin 不串/各自 newContext 独立 realm/dispose 互不影响),不经 window。这同时是将来多 runtime 场景(并行测试/Electron 多窗/一页多 host 连接)的预埋。 + +## 3. Loader 拉包链路 + +``` +host 侧 client 侧 +──────── ──────── +Loader 加载插件 node 半边 + └─ resolve `./client` 出口 → dist/client.js 物理路径 + └─ 登记清单条目 {pluginId, clientUrl, version} + │ + │ ①插件清单(boot/重连 unary 拉取;含 reload 事件的更新面) + ▼ + 收到清单 + └─ 「创建中」集合登记(hold 判据开窗) + └─ ② 加载 bundle(import(url) 或 script 注入,见选型) + └─ ③ bundle 执行 → DSHClientProxy.loadPlugin({id, callback}) 注册 + └─ id 对账:自报 id ≠ Loader 预期 → plugin-load-failed + └─ ④ 主框架点火:callback() → ctx.plugin(工厂产物, config) + └─ ⑤ apply 完成 → 创建中集合移除 + → drain 该插件 hold 队列(§4.3 三出口) +``` + +**【选型】加载方式:import(url) 与 script 注入并列**——全局注册协议下 bundle 不 export,加载方式只需「把脚本跑起来」,两者都成立: + +| 方式 | 机制 | 错误通道 | +|---|---|---| +| **`import(url)`(推荐)** | 执行副作用即注册(不读导出);仍是 esm 模块作用域 | import reject 直接可捕获 → `plugin-load-failed` | +| ``(JSON 序列化时 `<` 转义 `<` 防 `` 截断)。 +- startWebServer options 增可选 `webPlugins?: { snapshot(); clientPath(id) }` 形注入(不传=行为不变,现有测试零扰动)。 + +### 4.5 e2e 验收 + +真 host 起服 → GET / 断言 __DSH_BOOT__ 八包清单与 immediately 标志;GET /plugins/<id>/client.js 断言 200+js;未知 id 404。fixture 侧同协议注入由 loader 测试盖。 + +## 5. 实现顺序(T0 后) + +1. connection 对账刀:导出清单实测(tsc 盘面)→ 附 v3 §3 尾 + 本文件 §2 清单定稿。 +2. host 侧刀(不依赖 client 桩,可先行):registry → 端点 → 注入 → 装配接线 → e2e。 +3. runtime 服务:SlotsService(对桩)→ Session 两行 → SessionsService+scope 树 → 存量 spec 从 attic 捞回平移绿(connection/session/manager/notifier/partial/fold/lineage/fixture 族;boot-intents/preinit/rpc-log 退役记档)。 +4. ClientLoader:核心态机+拓扑 → fixture 注入测试 → 真 bundle 冒烟(等 T0 tsdown preset 产物)→ 与 ui-shell 对接 deps 形状。 + +## 6. 台账 / 开问 main + +- [问 main] §4.2 装配缺口两问(内存 Loader 树口径 + apps/cli/web.ts 跨属地接线报备)。 +- [问 main] Context.sessions merge 冲突:v3 §4 的 client `sessions: SessionsService` 与 host dsh-session `sessions: SessionStore` 同名 merge 相撞(connection→apiproxy 类型链拉入 host d.ts)=TS2717。建议 a) client 侧改名;备选 c) P-I ctx.get('sessions')(现状实现,FIXME 在 runtime/src/index.ts)。 +- [台账] SessionSummary.title wire 无供数,P-I 客户端派生占位。 +- [台账] viewFor/backscanArgs 删除刀涉 wire 面,等 toolviews 迁移触发、main 排期。 +- [台账] history 隐式 resume 改纯持久化读,P-II 议。 +- [台账] scope「无人观看」= 最近解析 binding 者近似,口径写 JSDoc。 +- [台账] intents.ts 溶解(rpc-log 五件+pingHost 死;refresh/create 归 SessionsService)。 +- [规矩→全员] client 半边 bundle 要用 ctx. 必须 `export const inject=[...]`——loader 以 object-plugin 整面递 cordis,fiber 依赖检查生效(apply-only 丢 inject=postmortem 0001 同型)。 +- [规矩→全员] dist/client.js 是 build 产物不进 git;改 client/ 源码后必须重跑 tsdown,否则 loader e2e 吃旧产物。 + +## 7. 进度(冷启动锚点) + +| 刀 | commit | 状态 | +|---|---|---| +| connection 对账(index 精确清单+intents 溶解+v3 §3.2 附录) | 3102c99a4+b4ce5c137 | ✅ | +| host 侧刀属地半(registry+分发端点+__DSH_BOOT__ 注入+11 测) | ba9768e90 | ✅ | +| spec 平移(connection 3+runtime 6+api-helpers 拆分) | dd8b37809 | ✅ | +| runtime 服务(SlotsService/SessionsService+scope 树/Session 两行/scopeOf/invariant×2) | e2ea1b99b | ✅ | +| ClientLoader(handoff/DI require/immediately 屏障/双模式验收/./loader 子路径) | be0261eb5+f94085878 | ✅ | +| 双入口拆分吸收(connection/runtime client 半边 apply+tsdown noExternal+双 specifier 回登记) | a05e28b5c | ✅ | +| host 装配刀(mountWebPlugins 内存 Loader 树×八包+invariant 伴生+built e2e) | 93b954c4d | ✅ | +| apps/cli/web.ts 接线(跨属地备案单独成刀) | e4661e7b0 | ✅ | +| 装配真链修缮(baseUrl 锚/tsdown preset lib 半/strip-only 内联/trajectory 空 apply) | 9c6f41c47 | ✅ | + +真链验收实录(2026-07-22 02:26,apps/cli 语境 plain node):mountWebPlugins→registry→startWebServer 起服→GET / 出 __DSH_BOOT__(plugins=8,immediately=4,序=connection,runtime,ui-theme,i18n,ui-layout,ui-sidebar,ui-conversation,ui-trajectory)→GET /plugins/@deepseek-ai/dsh-client-runtime/client.js 200 且含 DSHClientProxy.loadPlugin→未知 id 404。web-plugins.e2e 2/2 绿(lib 未构建自跳)。 + +排坑记录(后人别再踩):①Loader 裸名 import 需 ctx.baseUrl 锚,否则静默失败全员 fiber-less;②包级 tsdown.config.ts 会整体替换根 workspace 形状——clientBundle preset 必须双 config(lib 半+client 半);③apiproxy 浏览器子路径与 dsh-session/surface 的 runtime default 指 src/*.ts,plain-node 消费必须内联(strip-only 模式炸 parameter property);④ctx.loader 每次访问是新 traced proxy,不可做恒等断言。 diff --git a/missions/tasks/20260721-p1-ui-shell/notes.md b/missions/tasks/20260721-p1-ui-shell/notes.md new file mode 100644 index 0000000000..5a4cee0f7d --- /dev/null +++ b/missions/tasks/20260721-p1-ui-shell/notes.md @@ -0,0 +1,164 @@ +# ui-shell 实现计划(ui-layout + web 壳 + tsdown client preset) + +> owner=ui-shell(常驻)。契约=api-contracts v3 §5/§9.1/§9.3/§0.3(immediately=先行装载组、loader 机件壳静态持有);验收面=plugins.md §0.1 规则 1/2/5/6 + dispatch ui-shell 行。T0 骨架刀完成前不动 packages/。 + +## 1. ui-layout + +### 1.1 AppFrame 让步链(核心算法,抽纯函数) + +``` +computeColumns(viewportW, sidebar: PanelState, details: PanelState) → + { sidebarW, centerW, detailsW } +``` + +输入=观看态原值(persist 的用户偏好永不被让步链改写);输出=本帧生效列宽。链序(写死): + +1. 期望值:sidebarW = sidebar.open ? sidebar.width : 0;detailsW = details.open ? details.width : 0;centerW = viewport − 两侧。 +2. centerW ≥ 640 → 完成。 +3. clamp details:detailsW = max(300, viewport − sidebarW − 640)。 +4. 仍不足 → clamp sidebar:sidebarW = max(240, viewport − detailsW − 640)。 +5. 仍不足 → auto close details:detailsW=0(**派生关闭,不写 details.open**——窗口回宽自动恢复,persist 语义不被让步链污染)。⚠假设待 main 确认。 +6. 兜底:sidebar 已到 240、details 已 0 仍不足 → centerW=剩余全部(可 <640,中栏兜底)。 + +纯函数单测穷举边界;组件层只做接线。 + +### 1.2 AppFrame 组件 + +- 三栏 grid:`gridTemplateColumns: ${sidebarW}px minmax(0,1fr) ${detailsW}px`;viewport 宽经 window resize 监听(rAF 节流)进本地 state。 +- 两条拖拽把手:sidebar 右缘 + details 左缘。pointerdown+setPointerCapture → pointermove 记最新 x → rAF flush 调 layout.setSidebarWidth/setDetailsWidth(service 内 clamp [240,420]/[300,520])→ pointerup 释放。把手命中区 ≥8px 宽(视觉窄条+padding)。 +- **details 收起/让步=0 宽不 unmount**:第三列 0px + overflow hidden,子树保留。 +- 组件零框架 import:布局态经 props 注入 hooks(useSidebar/useDetails selector)+ actions(引用恒定);三坑一律 `slots.renderSlot`;中右两坑包在 SessionProvider 内(组件经 props 收 `SessionProvider: FC`)。 +- CSS Modules,只用 `var(--dsw-*)`;sidebar 右描边/背景等 token 见 figma sidebar §5。 + +### 1.3 LayoutService(四面观看态,zustand+persist) + +- `current: SnapshotStore`、`sidebar/details: SnapshotStore`——createSnapshotStore(persist opt-in),四面全 persist(刷新恢复选中+布局)。 +- 默认:sidebar {open:true, width:300};details {open:false, width:360}。P-I details 全局不随 session(规则5,per-session keyed 升级位=将来把 details 换 keyed store,接口不动)。 +- `open(id)`:校验存在于 ctx.sessions.list,不存在=throw(fail loud)。`openView(id,view)` 写 viewFor。 +- prune:订 sessions.list → removed id 清 viewFor 条目;current.sessionId 指向已删会话时置 undefined(⚠后者契约未明写,按 fail-safe 补,待 main 确认)。 +- SlotMap declare merge 三坑 + apply 里 ctx.slots.define 三条(single;sidebar=root,conversation/details=session)。 + +## 2. web 壳(packages/client/web) + +### 2.1 boot 时序 + +1. 入口(vite 产物):静态 import cordis/react/react-dom/ui-slots/web-react/ui-primitives + **loader 机件**(代码家在 runtime 包,壳静态 import——见 §4 对界)。 +2. new cordis Context(root ctx)→ 实例化 loader → 挂 ctx.loader → 播种模块表六实体(与壳同实例,插件 require 到同一 React/cordis)。 +3. import ui-theme 的 src/styles/ 两份 CSS 为 base 样式表(vite 静态引入;t0-checklist 已知坑:font-family base 变量 T0 补档)。 +4. createRoot → `` boot loading 页(纯壳组件:logo+loading,零插件依赖,样式独立不依赖插件 CSS)。 +5. `loader.start()`(读 __DSH_BOOT__:immediately 组并行→其余拓扑)→ `await loader.settled()`。 +6. settled 成功 → AppRoot 一次切换真 UI;单插件失败 → loading 页显式列出 failed 插件 id(订 loader.status store),不做部分可用。 + +### 2.2 真 UI 装配闭合(settled 后,壳内一处) + +- `SessionProvider = createSessionProvider({ useCurrent: ()=>ctx.layout.current.useSelector(s=>s.sessionId), resolveBinding: id=>ctx.sessions.binding(id) })`。⚠契约缺口:SessionProviderDeps 无 slots/core 注入位,provider 要渲染 conversation+details 两坑拿不到 renderSlot 来源——已报 main 仲裁(fw-react 属地)。 +- `slots = scopedSlots(core, 'sidebar','conversation','details')`(layout define 的三坑显式转授壳)。 +- AppFrame 组件经 loader 模块表 `require('@deepseek-ai/dsh-client-ui-layout')` 取导出面(壳持有 loader,settled 后可读),props 注入 §1.2 全套后渲染。 +- SessionProvider 渲染的两坑需落进 grid 第 2/3 列:需要 provider 输出 display:contents 兼容结构(与 fw-react 对齐 DOM 形状,随上条仲裁一并定)。 + +### 2.3 构建管线拓扑 + +``` +packages/client/web: vite build(壳 bundle:react/react-dom/cordis/纯库三包/loader 机件/AppRoot/boot) + index.html 模板(__DSH_BOOT__ 占位由 host 注入) +8 个插件包: tsdown -c 引用共享 preset → dist/client.js(闭包工厂,CSS 内联,external→require) +dsh web serve: host webserver 托管壳 dist + GET /plugins//client.js + GET / 注入 + —— serve/注入归 rt-core;壳 dist 位置与构建归我 +dev 工作流: 壳 vite build --watch;插件 tsdown --watch;手动刷新(无 HMR) +``` + +## 3. tsdown client preset(T0 模板后接手) + +- `packages/client/tsdown.client.ts` export 工厂(入参 {id}): + - banner `window.DSHClientProxy.loadPlugin({ id: '', factory: (require) => {`;footer `return <模块导出面>; } })`——导出面含 apply,loader 装载后登记模块表。 + - external=模块表清单(react/react-dom/cordis/ui-slots/web-react/ui-primitives + 全部 @deepseek-ai/dsh-client-* 插件名)→ 编译为 require('') 调用。 + - CSS Modules 内联:css 文本进 bundle,执行时注入 `