DSH / Atlas
2026-07-19implementedarchitecture

Web client architecture — the client cordis plugin tree, the slot system, and the React-free object layer

Web 客户端架构——client cordis 插件树、slot 体系与 React-free 对象层

Two forces shape the browser client. First, streaming: in an event-driven conversation UI, if business state (the event window, streaming accumulation, pending interactions, the connection state machine) scatters across React components and a global store, every token chunk shakes the render tree, and swapping the UI library means rewriting the business logic. Second, modularity: UI features (layout, sidebar, convers

English

Problem

Two forces shape the browser client. First, streaming: in an event-driven conversation UI, if business state (the event window, streaming accumulation, pending interactions, the connection state machine) scatters across React components and a global store, every token chunk shakes the render tree, and swapping the UI library means rewriting the business logic. Second, modularity: UI features (layout, sidebar, conversation, theme, locale) must be independently loadable plugins — composed at runtime from a host-served manifest, not compiled into one bundle — without giving up compile-time type safety across plugin boundaries.

Decision

Both ends run cordis. The host is a cordis plugin tree; the browser runs a second, client-side cordis tree whose every UI capability is a plugin loaded dynamically by a shell-held loader. Inside that tree, cordis ctx hosts all runtime facts (services, stores, session scopes) and React is pure projection: components import nothing from the framework, receive everything through props, and subscribe to immutable snapshots via useSyncExternalStore (uSES below).

┌─ Host ─────────────────────────┐   ┌─ Browser ─────────────────────────────────────────┐
│ sessions/agents/SessionLog     │   │ client cordis root ctx                             │
│ apiproxy: RPC + mux/host 双流  │◀─▶│  ├ vendored Loader + ctx.modules(内核,壳静态持有)│
│ webserver:                     │   │  ├ immediately entries: connection/runtime/        │
│  ├ GET /plugins/<id>/client.js │   │  │   ui-theme/i18n(fetch bundle,boot 预拉)       │
│  └ GET / 注入 __DSH_BOOT__ 图  │   │  ├ lazy entries: layout/sidebar/                   │
│                                │   │  │   conversation/trajectory(fetch bundle,按需) │
└────────────────────────────────┘   │  ├ ui-renderer(fetch bundle,React 根)       │
                                     │  └ session scope ×N(观看驱动,惰性建)            │
                                     │ DOM loading 页 → settled → React UI 一次成型       │
                                     └────────────────────────────────────────────────────┘

The client cordis tree and the loading chain

The loading chain — the two package kinds (plain vs dsh.client plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the client plugin loading note. The load-bearing facts for this document: the browser boots the same vendored @cordisjs/plugin-loader as the host with a client module system (ctx.modules, packages/client/modules) filling its internal contract; every unit with product behavior is an entry in the host-authored __DSH_BOOT__ graph — every production plugin package (infrastructure included) carries the dsh.client declaration and arrives as a fetched ./client tsdown closure bundle, immediately rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute window.__ModuleLoader__.load({ id, factory }) and their require is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); global styles and CSS Modules are inlined in their owning plugin bundle and injected as <style data-plugin="<id>"> at materialization (CSS Modules also receive hashed names; ownership tags make reload removal possible); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts rebuilt SSE frames, and the client-hmr plugin swaps one fiber per frame. After loader.await() and an all-ACTIVE sweep, the framework-free kernel calls the dynamic UI renderer's ctx.uiRenderer.mount(container) once — every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).

Type universes stay split at the aggregate level — tsconfig.host.json is the host program and tsconfig.client.json the client program, both referenced by the solution root tsconfig.json — because both sides merge cordis Context under the same keys (sessions, loader) with different services; client packages consume the wire vocabulary through pure type subpaths (@deepseek-ai/dsh-session/types and kin) so no host augmentation rides into the client program.

The slot system: how the page composes

The slot system has its own note — the slot system standard — and this document defers to it entirely. The one-paragraph summary for orientation: ui-renderer renders only 'root'; a plugin composes UI through a single register call that occupies a slot, declares+authorizes its child slots (children spec object), declares its store, and injects its business face; component props arrive in four auto-derived shares (PropsRuntime<K> / PropsRenderSlots<S> / PropsStore<H> / inject), each from its single source of truth. SlotMap declaration merging is the type authority and entries carry only the owner share ("whoever injects it, owns its type"); every rendered entry sits in a per-entry error boundary.

Implementation homes: registry core and the props-share types live in packages/client/ui-slots; the outlet renderer, uSES bridge, application-level installation, and root mounting live in packages/client/ui-renderer.

Services and scope addressing

A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: ctx.connection (api client + stream handles), ctx.slots (registry wrapper emitting slots/changed, render entry, renderer installation contract), ctx.sessions (list store, current-session state, scope tree), ctx.loader, ctx.theme, ctx.i18n, ctx.layout (cross-plugin view navigation), ctx.conversation (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the slot system standard.

There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the 'conversation.view' list slot ui-conversation declares, tab metadata rides the registration options (id/order/label), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session 'conversation.chat.node' slot; ui-tool owns its tool-call entry, recursively renders the supplied subCalls, and declares the keyed/session 'tool.call.toolview' child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by entryKey: toolName with GenericToolCard as the fallback. Business packages register atomic views through ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>' }, Row)); the declaration is the load and reload dependency (decision). ui-conversation separately delegates the selected call's details body through 'conversation.details.tool', so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries (decision).

Scope addressing mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (scopeOf(ctx)). Inside a session scope, ctx.conversation.send('hi', 'queue') targets that session; cross-session calls re-target by switching ctx (ctx.sessions.scope(id)!.conversation.send(...)); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).

The data object layer (packages/client/runtime/src/client/sessions/)

Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable):

mux/host frames (ConnectionController pump, injected sinks)
        │
        ▼
SessionManager.handleMuxEnvelope / handleHostEnvelope
        │ session frames target existing instances (requested waits buffer)
        ▼
Session.handleMuxEnvelope ──► contiguous Event window
        │                        │ replace / prepend / append
        │                        ▼
        │                ConversationNodeAssembler
        │                  Definitions -> Contexts -> view builders
        ▼
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
  • Session (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: prompt/cancel (RPC passthrough; failures land in the snapshot's promptError), open (pull the tail history page, idempotent), loadOlder (upward paging, reentry-guarded), resync (reconnect = clear the window and rerun open). Subscription: subscribe/getSnapshot (always the cached reference) — implements ObservableSnapshot<ConversationSnapshot>, with useSelector = bindSnapshotSelector(this) attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: session/event frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if subscribed.lastSeq outruns the window tail.
  • ConversationSnapshot (conversation.ts): the top-level immutable snapshot contract. chat contains structural order, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; nodes, partial, runningCalls, turnTimings, and turnEnds are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. Reference discipline (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided useSession(selector) isolates Node and Location aggregate updates.
  • SessionManager (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question requested frames are the exception — they never land in history, so they buffer in pendingBuffers and replay on instantiation.
  • Notifier (notifier.ts): two channels chosen by change source. markDirty() (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. notifyNow() (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned.
  • ConversationNodeAssembler (runtime/src/client/conversation/): the Session-owned incremental engine runs independently registered Definitions over raw events. match(event) selects (kind, id) without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; buildViewNode(target) materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, useSession selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The Conversation Node decision owns assembly, while Tool presentation ownership owns recursive Tool rendering.
  • ConnectionController (in packages/client/connection): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: onConnected → list refresh + per-open-session resync. The object layer faces only IApiClient; Web carriage uses HTTP POST for the two client→server quadrants and one WebSocket per logical stream for the two server→client quadrants, while the client class family remains the layering note's territory.

The React face (packages/client/ui-renderer)

The dynamic ui-renderer plugin owns the ctx↔React adapter, application-level installation, root mount, and title projection. Business components receive bound hooks through slot props and do not value-import the renderer.

  • The snapshot store engine lives in the runtime package (zustand vanilla with draft-based updates, flush: 'sync' by default with opt-in 'raf' batching, opt-in whole-value localStorage persistence, dev-mode deep freeze — all exported from runtime's ./client main entry, no subpath): store products are bare observable sources with no hook members. Plugins reach the engine only through defineStore declarations per the slot system standard. ui-renderer composes every hook at the binding site (bindSnapshotSelector, per-source cached) from the one data contract React consumes: ObservableSnapshot<T> (getSnapshot/subscribe) — a Session object and a snapshot store both satisfy it.
  • bindSnapshotSelector(source): binds a source into a typed selector hook over uSES-with-selector. The four uSES contract clauses hold by construction: getSnapshot returns the cached reference; subscribe is a bind-time closure (reference-stable forever); pure CSR passes no server snapshot; equality defaults to Object.is with shallowEqual opt-in per call.
  • Equality protocol, whole chain: producers use structural sharing; consumers short-circuit with Object.is or shallowEqual; React.memo shallow. Deep comparison is banned everywhere.

Directory shape

Client packages live under packages/client/*, with apps/web as the thin Vite application over the shell's boot export. Plugin packages keep their browser half under src/client/; every build artifact lands in lib/ — the node half as lib/index.js/lib/invariant.js, the browser bundle as lib/client.js (the shared tsdown client preset emits both; there is no dist/ directory, and exports["./client"] points at ./lib/client.js). ui-slots, runtime, and ui-renderer form the infrastructure direction; feature plugins cooperate through services and slots rather than importing presentation implementations.

A multi-domain plugin package additionally splits its client half by future package boundaries — ui-conversation is the exemplar:

src/client/
  contract/    shared slot and cross-domain types
  service.ts   cross-domain orchestration
  skeleton/    conversation shell and details host
  conversation-nodes/ independently registered business Definitions and Chat builder
  chat/        ordered conversation view
  input/       composer state machine
  queue/       queued-message presentation
  settings/    conversation settings rows
  apply.ts     cross-domain assembly point
  index.ts     public contract surface

Domain implementation files never import a sibling domain; shared surfaces route through contract/. scripts/verify-client-domain-graph.ts enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). Tool presentation is already a separate ui-tool package and reaches chat and details only through the slots ui-conversation declares.

How to develop

  • A new UI feature = a new plugin package: declare dsh.client (+ inject topology) in package.json, write the browser half under src/client/ (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
  • A new slot: see the slot system standard note — merge the contract into SlotMap, declare it in the parent entry's children, render through the auto-injected renderSlot prop. Never export components globally.
  • Consuming a new frame type: transport-only session frames → Session's dispatch switch; host-level frames → the Manager routing table; logged conversation business events → a Definition plus a keyed view renderer, without a Session business branch.
  • Where does this state live: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store (slot system standard).
  • Notification channel: frame-driven/async = markDirty batching; direct user-gesture echo whose controlled input needs the same tick = notifyNow.

Consequences

Token streams no longer shake the render tree: Assistant chunks update one business Context and publish its keyed Node at most once per animation frame; unrelated rows' selector results retain their references, so those rows do not re-render. UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer.

Alternatives considered

RejectedOne-line reason
One statically-linked SPA bundlePlugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build
window globals / import maps for shared depsThe DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently
Business data in zustand slicesThe event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable
Parallel string-keyed component registry for Tool rowsui-tool's keyed child slot carries the runtime-open Tool-name set through the one slot registration model (toolview dissolution)
Progressive/Suspense boot in the initial web client deliveryOne-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture

中文

Problem

浏览器客户端受两股力塑形。其一是流式:事件驱动的对话 UI 里,若业务状态(事件窗口、流式累积、待答交互、连接状态机)散落在 React 组件与全局 store 中,每个 token 分片都会震荡渲染树,且换 UI 库等于重写业务逻辑。其二是模块化:UI 功能(布局、侧栏、对话、主题、语言包)必须是可独立装载的插件——按 host 下发的 manifest(元数据清单)在运行时组合,而非编译进单一 bundle——同时不放弃跨插件边界的编译期类型安全。

Decision

两端都跑 cordis。host 是一棵 cordis 插件树;浏览器里跑第二棵 client 侧 cordis 树,其中每一项 UI 能力都是插件,由壳静态持有的 loader 动态装载。树内 cordis ctx 承载一切运行时事实(服务、store、会话 scope),React 是纯投影:组件对框架零 import,一切经 props 注入,经 useSyncExternalStore(下称 uSES)订阅不可变快照。

┌─ Host ─────────────────────────┐   ┌─ Browser ─────────────────────────────────────────┐
│ sessions/agents/SessionLog     │   │ client cordis root ctx                             │
│ apiproxy: RPC + mux/host 双流  │◀─▶│  ├ vendored Loader + ctx.modules(内核,壳静态持有)│
│ webserver:                     │   │  ├ immediately entries: connection/runtime/        │
│  ├ GET /plugins/<id>/client.js │   │  │   ui-theme/i18n(fetch bundle,boot 预拉)       │
│  └ GET / 注入 __DSH_BOOT__ 图  │   │  ├ lazy entries: layout/sidebar/                   │
│                                │   │  │   conversation/trajectory(fetch bundle,按需) │
└────────────────────────────────┘   │  ├ ui-renderer(fetch bundle,React 根)       │
                                     │  └ session scope ×N(观看驱动,惰性建)            │
                                     │ DOM loading 页 → settled → React UI 一次成型       │
                                     └────────────────────────────────────────────────────┘

client cordis 树与装载链

装载链——两类包(普通包 vs dsh.client 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双阶段 boot、热重载——归 client 插件装载笔记 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored @cordisjs/plugin-loader,由 client 模块系统(ctx.modulespackages/client/modules)填上其 internal 约定;凡带产品行为的单元都是 host 独家撰写的 __DSH_BOOT__ 图里的 entry——每个生产插件包(含基础设施)都携带 dsh.client 声明、以 fetch 到达的 ./client tsdown 闭包 bundle 供给,immediately 行的差别仅在 boot 第一阶段预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 window.__ModuleLoader__.load({ id, factory }),其 require 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);全局样式与 CSS Modules 都内联在其持有插件的 bundle 中,物化时注入为 <style data-plugin="<id>">(CSS Modules 还会取得哈希类名;归属标签使重载时移除成为可能);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 rebuilt SSE 帧,client-hmr 插件每帧换掉一个 fiber。loader.await() 与全 ACTIVE 扫描完成后,不依赖框架的内核会调用动态 UI 渲染器的 ctx.uiRenderer.mount(container) 一次——此时每个 entry 已创建、每个 fiber 都到达 ACTIVE,FAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。

类型宇宙在聚合层拆分——tsconfig.host.json 是 host program、tsconfig.client.json 是 client program,二者由 solution 根 tsconfig.json 引用,因为两侧都在相同键(sessionsloader)上对 cordis Context 做声明合并且服务不同;client 包经纯类型子路径(@deepseek-ai/dsh-session/types 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。

slot 体系:页面怎么拼

slot 体系有自己的笔记——slot 体系标准——本文整体移交给它。此处只留一段定位摘要:ui-renderer 只渲染 'root';插件用单独一次 register 调用组合 UI——占用 slot、声明并授权子 slot(children spec 对象)、声明 store、注入业务面;组件 props 分四份额自动推导到达(PropsRuntime<K> / PropsRenderSlots<S> / PropsStore<H> / inject),各有唯一真源。SlotMap 声明合并仍是类型权威,entry 只携带 owner 份额(「谁注入的,类型归谁」);每个被渲染的注册项都在 per-entry 错误边界之内。

实现的家:注册表核心与 props 份额类型在 packages/client/ui-slots;outlet 渲染器、uSES 桥、应用级安装与根挂载在 packages/client/ui-renderer

服务与 scope 寻址

服务是插件对其他插件的唯一 API(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图 slot 注册)。名册:ctx.connection(api client + 流句柄)、ctx.slots(注册表包装层,发 slots/changed,渲染入口,渲染器安装约定)、ctx.sessions(列表 store、当前会话状态、scope 树)、ctx.loaderctx.themectx.i18nctx.layout(跨插件视图导航)、ctx.conversation(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 slot 体系标准 住 entry 声明的 store。

slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 'conversation.view' list slot entry,tab 元数据随注册 options(id/order/label)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session 'conversation.chat.node' slot 分发;ui-tool 拥有其中的 tool-call entry,递归渲染传入的 subCalls,并声明 keyed/session 'tool.call.toolview' 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 entryKey: toolName 分发,以 GenericToolCard 兜底。业务包通过 ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>' }, Row)) 注册原子视图;声明本身就是加载与重载依赖(决策)。ui-conversation 还通过 'conversation.details.tool' 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件注册表和视图注册表是数据组装 seam,不是平行组件注册表(决策)。

scope 寻址与 host 侧 agent(智能体)scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(scopeOf(ctx))。在会话 scope 内,ctx.conversation.send('hi', 'queue') 自动打到该会话;跨会话调用换 ctx 定向(ctx.sessions.scope(id)!.conversation.send(...));从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。

数据对象层(packages/client/runtime/src/client/sessions/

帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言):

mux/host frames (ConnectionController pump, injected sinks)
        │
        ▼
SessionManager.handleMuxEnvelope / handleHostEnvelope
        │ session frames target existing instances (requested waits buffer)
        ▼
Session.handleMuxEnvelope ──► contiguous Event window
        │                        │ replace / prepend / append
        │                        ▼
        │                ConversationNodeAssembler
        │                  Definitions -> Contexts -> view builders
        ▼
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
  • Session(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:prompt/cancel(RPC 透传;失败落进快照的 promptError)、open(拉尾页 history,幂等)、loadOlder(向上翻页,防重入)、resync(重连 = 清窗口重跑 open)。订阅面:subscribe/getSnapshot(恒返缓存引用)——implements ObservableSnapshot<ConversationSnapshot>,构造时挂 useSelector = bindSnapshotSelector(this),Session 本身就是 uSES 源。帧分发是一个 switch:session/event 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,subscribed.lastSeq 超出窗口尾则回补一次。
  • ConversationSnapshot(conversation.ts):顶层不可变快照约定。chat 包含结构化 order、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;nodespartialrunningCallsturnTimingsturnEnds 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、queue、running、removed、open state、paging 和 prompt error 仍是 Session 信息。引用纪律(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 useSession(selector) 隔离 Node 与 Location 聚合更新。
  • SessionManager(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 requested 帧——它们不落 history、open 无法回补,故缓冲进 pendingBuffers,实例化时回放。
  • Notifier(notifier.ts):两条通知通道,按变更来源取用。markDirty()(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。notifyNow()(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。
  • ConversationNodeAssemblerruntime/src/client/conversation/):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。match(event) 无须扫描 Context 即可选出 (kind, id);start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;buildViewNode(target) 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,useSession selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。Conversation Node 决策拥有组装边界,Tool 展示所有权拥有 Tool 递归渲染。
  • ConnectionController(在 packages/client/connection):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:onConnected → 列表刷新 + 各已打开会话 resync。对象层只面向 IApiClient;Web 承载以 HTTP POST 载两个 client→server 象限、以每逻辑流一条 WebSocket载两个 server→client 象限,客户端类族归分层笔记属地。

React 面(packages/client/ui-renderer

动态 ui-renderer 插件持有 ctx↔React 适配器、应用级安装、根挂载与标题投影。业务组件通过 slot props 接收绑定后的钩子,不对渲染器做值 import。

  • 快照 store 引擎住 runtime 包(zustand vanilla + 草稿式更新,缺省 flush: 'sync',可选 'raf' 合批,可选整值 localStorage 持久化,dev 深冻结——全部从 runtime./client 主出口导出,无子路径):store 产物是裸的可观察源,不带任何钩子成员。插件只经 slot 体系标准defineStore 声明触及引擎。ui-renderer 在绑定处(bindSnapshotSelector,按源缓存)从 React 消费的唯一数据约定合成每个钩子:ObservableSnapshot<T>getSnapshot/subscribe)——Session 对象与快照 store 同构满足它。
  • bindSnapshotSelector(source):把一个源绑定为经 uSES-with-selector 的带类型 selector 钩子。uSES 约定四条按构造成立:getSnapshot 恒返缓存引用;subscribe 是绑定期闭包(引用永稳);纯 CSR 不传 server snapshot;相等性缺省 Object.is,按调用可选 shallowEqual
  • 相等性协议,全链一致:生产端结构共享;消费方以 Object.isshallowEqual 短路;React.memo 浅比较。深比较全链禁止。

目录形态

Client 包位于 packages/client/*apps/web 是壳 boot 导出之上的薄 Vite 应用。插件包的浏览器半边在 src/client/ 下;一切构建产物落 lib/——node 半边为 lib/index.js/lib/invariant.js,浏览器 bundle 为 lib/client.js(共享 tsdown client 预设两者皆出;无 dist/ 目录,exports["./client"] 指向 ./lib/client.js)。ui-slots、runtime 与 ui-renderer 构成基础设施方向;功能插件通过服务与 slot 协作,不导入展示实现。

多域插件包的 client 半边还按未来包边界再拆——ui-conversation 即样板:

src/client/
  contract/    shared slot and cross-domain types
  service.ts   cross-domain orchestration
  skeleton/    conversation shell and details host
  conversation-nodes/ independently registered business Definitions and Chat builder
  chat/        ordered conversation view
  input/       composer state machine
  queue/       queued-message presentation
  settings/    conversation settings rows
  apply.ts     cross-domain assembly point
  index.ts     public contract surface

各领域实现文件不 import 兄弟领域;共享面统一经过 contract/scripts/verify-client-domain-graph.ts 把守分层(contract=0、domain=1、apply/index=2;import 只准指向不高于自身的层级;兄弟领域依赖会失败)。Tool 展示已经拆为独立 ui-tool 包,只通过 ui-conversation 声明的 slot 到达 chat 与 details。

怎么开发

  • 新 UI 功能 = 新插件包:package.json 声明 dsh.client(+ inject 拓扑),浏览器半边写在 src/client/(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。
  • 新 slot:见 slot 体系标准笔记——约定合并进 SlotMap,在父 entry 的 children 里声明,经自动注入的 renderSlot prop 渲染。永不全局导出组件。
  • 消费新帧类型:纯传输 session frame → Session 分发 switch;host 级 frame → Manager 路由表;已记录的 conversation 业务事件 → Definition 加 keyed view renderer,不增加 Session 业务分支。
  • 状态住哪:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store(slot 体系标准)。
  • 通知通道:帧驱动/异步 = markDirty 合批;受控输入需要同 tick 的用户手势直接回响 = notifyNow

Consequences

token 流不再震荡渲染树:Assistant chunk 只更新一个业务 Context,每 animation frame 最多发布一次对应 keyed Node;无关行的 selector 结果保持原引用,因此不会重渲染。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。

Alternatives considered

RejectedOne-line reason
静态链接的单 SPA bundle插件必须由 host 在运行时按配置组合;单体把每个 UI 功能重新耦回一次构建
window 全局变量 / import map 供共享依赖DI require 表让共享显式、大声失败、可替换;全局变量静默泄漏身份与版本
业务数据进 zustand 切片事件窗口/累积器是行为状态机,不是扁平切片;对象层保住快照粒度与合批的可控性
Tool 行使用平行的字符串键组件注册表ui-tool 的 keyed 子 slot 通过唯一的 slot 注册模型承载运行时开放的 Tool 名称集合(toolview 溶解
首个 web 客户端交付就做渐进/Suspense 启动一次成型严格更简单;loader 的按插件状态面已保留,渐进点亮日后可落地而无需重构