feat(client): expose indexed positions to group definitions

This commit is contained in:
imccyu committed 2026-09-21 14:56:14 +08:00
1 parent 7dd6d1fc31
commit a67ad91237
14 files changed
+109 -28

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md
2026-09-21-conversation-build-groups.md: ab55de55767cca1da63d4c52a6b1defba1244ad3
2026-09-21-conversation-build-groups.zh.md: ebef2df31b623125b8aa96ae4a42c5d6a97ec29b
2026-09-21-conversation-build-groups.md: 6591ebea61d8edd06edbaf5f54f2bebd1bd00602
2026-09-21-conversation-build-groups.zh.md: 70657ded8e04f0badb987ada965aef1184cfc7b0
@@ -46,9 +46,10 @@ These records retain independent rationale and remain active. Cross-View navigat
- Registration requires an existing View target, but never constructs a Builder. First activation checks the constructed Builder for `groupInput()`; missing input fails. Ungrouped targets do not require it or allocate Group state.
- Each Session owns one derived context per registered Definition. `create()` initializes its State; it is not a `turn/start` event or an event Definition's unique start Match. Definition objects do not hold mutable cross-Session state.
- `update(context, input)` returns the adopted State. `buildGroups(context)` materializes pending output without interpreting events again or advancing counts on repeated reads. Replacement input requires a complete root and group replacement so removed Nodes cannot leave dangling references; `null` on apply retains output.
- `replace` supplies target order, synchronous `readNode`, and timeline; `apply` also supplies projected previous/current Nodes and `changedTurns`. The reader is valid during the synchronous call only and must not be retained in State.
- `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers; `apply` also supplies projected previous/current Nodes, `changedTurns`, and `changedTurnOrders`. Readers are valid during the synchronous call only and must not be retained in State.
- Node data stays in the target Node Store. Content-only updates preserve the order array. Previous values are captured before the Builder installs projected updates, including additional Nodes changed by target projections.
- `ConversationLocationIndex` accumulates changed Turns from boundaries and Location data writes. Assembler passes one drained batch to every updated target, including a Turn ending without Node upserts. Direct ungrouped Builder callers may omit this field.
- The target position index supplies `changedTurnOrders` for visible-key, owning-Turn, or immediate-neighbour changes, including both owners of a moved Node and Turns affected by adjacent unscoped Nodes. `readTurn` returns one Turn's visible keys; `readPosition` returns `GroupNodePosition` with the owning Turn and immediate previous/next keys. Turn-only reads omit interruptions by unscoped Nodes; neighbour facts let business segmentation retain them. The index provides positions, not grouping decisions.
- The generic GroupDataMap associates target and Data across registration, storage, and reading. Undeclared targets have no group payload type; business consumers do not reconstruct summary types with unknown assertions.
## Node/Group references and updates
@@ -80,7 +81,7 @@ GroupStore indexes records and sources by GroupKey, not repeated array searches.
2. The assembler runs existing Node Definition matching, state updates, and required Context replay.
3. Existing immediate/animation-frame/none cadence schedules publication; grouping adds no timer or event subscription.
4. Flush materializes Step then Turn Location data and target Nodes.
5. Builder.replace/apply installs projected Nodes, target order, and indexes, and retains GroupInput without notifying independent sources.
5. Builder.replace/apply installs projected Nodes, target order, and indexes, records position changes, and retains GroupInput with its indexed readers without notifying independent sources.
6. The assembler obtains the target's registered Group Definition context, calls update with builder.groupInput(), and adopts the returned State.
7. It calls buildGroups, validates and installs the result, and installs the target snapshot.
8. After every affected target is installed, it calls Builder.publish, publishes Group sources, and publishes Location data.
@@ -103,7 +104,7 @@ Grouping never replays raw events. Node Definitions handle replay first; groupin
| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | Common first-activation/flush dispatch, context ownership, installation, and publication. |
| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | Accumulate changed Turns, including lifecycle-only and Location-data changes. |
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. |
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas and stable order; defer source notification. No process grouping class. |
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas, target positions, and changed Turn orders; provide indexed readers and defer source notification. No process grouping class. |
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. |
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. |
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. |
@@ -141,12 +142,13 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
- [Group store tests](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts) cover atomic reference validation, root/member identity reuse, local publication, and removal without deleting source Nodes.
- [Grouping dispatch tests](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts) cover first activation, lifecycle-only input, registry replacement, View removal/recovery, and complete replacement output. [Assembler tests](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts) cover changed-Turn reporting and Location data sources.
- [Node source tests](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts) cover projected grouping inputs, indexed readers, and empty change batches.
- [Chat rendering tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) retain component state across modes, rebind replacement Node stores, pass independent parts, and omit unreferenced Nodes. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) cover grouped reading anchors, history prepend, and part-aware Turn navigation.
## Consequences
- This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback.
- Target-local State does not automatically make business updates local. The Definition must distinguish content growth from structural changes and index the affected ranges.
- Target-local State does not automatically make business updates local. Node changes and changed Turn orders let a Definition choose affected groups and ranges. Structural changes still rebuild the target position index over visible order; content-only updates do not rebuild it. Structural grouping output still replaces the complete root-reference array.
- Node order and visibility have one owner: the Builder. Grouping must not independently sort raw events or infer membership again in infrastructure.
- Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made.
- Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes.
@@ -46,9 +46,10 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
- 注册要求已有 View 目标,但不构造 Builder。首次激活取得 Builder 后检查 `groupInput()`,缺少输入方法时报错。未分组目标不要求该方法,也不分配 Group 状态。
- 每个 Session 为已注册 Definition 拥有一个派生上下文。`create()` 初始化 State,不是 `turn/start` 事件,也不是事件 Definition 的唯一 start Match。Definition 对象不保存跨 Session 的可变状态。
- `update(context, input)` 返回框架采纳的 State。`buildGroups(context)` 物化待输出结果,不重新解释事件,不因重复读取增加计数。替换输入要求输出完整根序列和组替换,避免已移除节点留下悬挂引用;apply 的 `null` 保留结果。
- `replace` 提供目标顺序、同步 `readNode` 和时间线;`apply` 还提供投影后的节点前后值及 `changedTurns`。读取函数仅在当前同步调用中有效,不保留到 State。
- `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器;`apply` 还提供投影后的节点前后值、`changedTurns` 和 `changedTurnOrders`。读取器仅在当前同步调用中有效,不保留到 State。
- 节点数据仍在目标 Node Store。仅正文更新保留顺序数组;旧值在 Builder 安装投影结果前记录,也覆盖目标投影额外改变的节点。
- `ConversationLocationIndex` 从边界事件和 Location 数据写入累计变化轮次。assembler 将同一批变化交给全部被更新目标,覆盖没有节点 upsert 的 Turn 结束。直接调用未分组 Builder 的调用方可省略该字段。
- 目标位置索引通过 `changedTurnOrders` 提供可见键、所属 Turn 或紧邻关系的变化,包含移动 Node 的新旧所属轮次,以及受相邻轮次外 Node 影响的轮次。`readTurn` 返回某个 Turn 的可见键,`readPosition` 返回包含所属 Turn 和前后紧邻键的 `GroupNodePosition`。仅按 Turn 读取会遗漏轮次外 Node 造成的分隔,业务分段可以通过相邻事实保留这些分隔。索引提供位置,不决定分组。
- 通用 GroupDataMap 在注册、存储及读取之间关联目标和 Data。未声明的目标没有组载荷类型,业务消费方不通过 unknown 断言恢复摘要类型。
## Node/Group 引用与更新
@@ -80,7 +81,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价
2. assembler 运行既有 Node Definition 匹配、状态更新及必要的 Context 重放。
3. 既有 immediate/animation-frame/none 节奏调度发布,分组不增加计时器或事件订阅。
4. flush 依次物化 Step、Turn Location 数据及目标 Node。
5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,保留 GroupInput,不通知独立来源。
5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,记录位置变化,保留包含索引读取器的 GroupInput,不通知独立来源。
6. assembler 取得目标已注册的 Group Definition 上下文,以 builder.groupInput() 调用 update,采纳返回 State。
7. 调用 buildGroups,校验并安装结果,安装目标快照。
8. 全部受影响目标安装完成后,调用 Builder.publish,发布 Group 来源和 Location 数据。
@@ -103,7 +104,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价
| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | 首次激活和 flush 共用调度,上下文归属、安装及发布。 |
| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | 累积变化轮次,覆盖仅生命周期和 Location 数据变化。 |
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 |
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量及稳定顺序,推迟来源通知,不持有过程分组类。 |
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量、目标位置及变化轮次顺序,提供索引读取器并推迟来源通知,不持有过程分组类。 |
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 |
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 |
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 |
@@ -141,12 +142,13 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
- [Group 存储测试](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts)覆盖原子引用校验、根与成员身份复用、局部发布,以及删除组但保留原 Node。
- [分组调度测试](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts)覆盖首次激活、仅生命周期输入、注册替换、View 移除与恢复,以及完整替换输出。[Assembler 测试](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts)覆盖变化轮次报告和 Location 数据来源。
- [Node 来源测试](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts)覆盖投影后的分组输入、索引读取器和空变化批次。
- [Chat 渲染测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)保留模式切换时的组件状态,重绑定替换后的 Node 存储,传递独立部分,并省略未引用 Node。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。
## Consequences
- 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。
- 目标内 State 不会自动让业务更新局部化,Definition 必须区分正文增长与结构变化,并索引受影响范围。
- 目标内 State 不会自动让业务更新局部化,Definition 可以根据节点变化和轮次顺序变化选择受影响的组及范围。结构变化仍按可见顺序重建目标位置索引,仅正文更新不重建该索引。结构性分组输出仍替换完整根引用数组。
- Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。
- 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。
- 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/conversation.md
conversation.md: 29ed4aa85ebdcc9061f33d620e26119fe7a9f927
conversation.zh.md: 2aba5ab97dd93a5561e4790da87aa72939ebe322
conversation.md: 9455858acac16584b4d300dc8cd2634eb2a20310
conversation.zh.md: 2e7c48ab5f65935c2fcb4de05c4484c965622746
+4 -1
View File
@@ -37,7 +37,8 @@ The [group types](../../packages/client/ui-conversation/src/client/contract/grou
| Type | Meaning |
|---|---|
| `ConversationGroupDefinition<Node, State, Data>` | `create()` initializes Session-local State; `update(context, input)` returns its next State; `buildGroups(context)` returns pending output or `null`. Replacement input requires entries and a complete group replacement. |
| `ConversationGroupInput<Node>` | `replace` supplies the ordered target Node keys, synchronous `readNode`, and timeline. `apply` also supplies projected `previous/current` Node changes and `changedTurns`, including lifecycle-only changes. Do not retain the reader in State. |
| `ConversationGroupInput<Node>` | `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers. `apply` adds projected `previous/current` Node changes, lifecycle `changedTurns`, and `changedTurnOrders`. Do not retain readers in State. |
| `GroupNodePosition` | Owning Turn, when present, and immediate previous/next visible Node keys. Neighbours preserve interruptions that a Turn-only key list omits. |
| `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. |
| `GroupSnapshot<Data>` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. |
| `GroupUpdate<Data>` | `entries` replaces the complete root sequence; only apply input may omit it to preserve the sequence. Replacement input requires both `entries` and `groups.replace`. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. |
@@ -46,6 +47,8 @@ The [group types](../../packages/client/ui-conversation/src/client/contract/grou
The Builder exposes `groupInput()` when grouping is registered; missing input fails at first activation. It records the Node values after its own projections and preserves content-only order identity. The assembler supplies changed Turns with every update; direct ungrouped Builder callers may omit them. Builders with independent sources defer their notifications to `publish()`. First activation and ordinary flush share the same sequence: materialize Location data and Nodes, update the Builder, call the Group Definition, validate and install grouping, then publish every affected target and Location source. The group phase reuses the existing publication cadence.
Target position indexes supply the readers and identify Turns whose visible keys or neighbours changed. `changedTurnOrders` includes both owners of a moved Node and Turns adjacent to inserted or removed unscoped Nodes; lifecycle-only changes remain in `changedTurns`. A business Definition can resegment those Turns and refresh only groups containing changed content. Structural output still replaces the complete root-reference array, without requiring unchanged Node contents to be reread.
The Group store validates the whole submitted result before installation: root Group references and records correspond one-to-one, all referenced Nodes exist, and each `(NodeKey, groupPart)` occupies at most one root or member position. A whole Node cannot coexist with one of its parts. Duplicate upserts, duplicate removals, and simultaneous upsert/removal of a group fail. Data-only upserts retain root and member arrays, do not read Nodes, and notify only changed group sources; full replacement revalidates every reference.
Renderers switch on the two reference kinds and choose components in the owning View, not through a renderer field in group data. Root Node references and referenced group members form the complete render list; unreferenced target Nodes remain stored but do not render. The group body reads members separately from summary data. Presentation modes must retain component types, keys, and member parents; changing data membership may legitimately remount a moved member. The framework does not interpret part completeness, segmentation, or presentation policies.
+4 -1
View File
@@ -37,7 +37,8 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro
| 类型 | 含义 |
|---|---|
| `ConversationGroupDefinition<Node, State, Data>` | `create()` 初始化 Session 内的 State;`update(context, input)` 返回下一份 State;`buildGroups(context)` 返回待发布输出或 `null`。替换输入要求输出 entries 与完整组替换。 |
| `ConversationGroupInput<Node>` | `replace` 提供目标节点的有序键、同步 `readNode` 及时间线。`apply` 还提供投影后的 `previous/current` 节点变化和 `changedTurns`,覆盖仅生命周期变化。不要把读取函数留在 State 中。 |
| `ConversationGroupInput<Node>` | `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器。`apply` 增加投影后的 `previous/current` 节点变化、生命周期 `changedTurns` 和 `changedTurnOrders`。不要把读取器留在 State 中。 |
| `GroupNodePosition` | 所属 Turn(如有)及前后紧邻的可见 Node 键。相邻关系保留仅按 Turn 取键时会遗漏的分隔。 |
| `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 |
| `GroupSnapshot<Data>` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 |
| `GroupUpdate<Data>` | `entries` 替换完整根序列,仅 apply 输入允许省略它以保留原序列。替换输入必须同时提供 `entries` 与 `groups.replace`。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 |
@@ -46,6 +47,8 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro
注册分组时 Builder 必须提供 `groupInput()`,缺少输入方法在首次激活时报错。它记录自身投影后的节点值,并在仅正文更新时保留顺序数组身份。assembler 每次更新都提供变化轮次,直接调用未分组 Builder 的调用方可以省略。拥有独立来源的 Builder 把通知推迟到 `publish()`。首次激活与普通 flush 共用同一顺序:物化 Location 数据和 Node,更新 Builder,调用 Group Definition,校验并安装分组,再发布全部受影响目标及 Location 来源。分组阶段复用既有发布节奏。
目标位置索引提供这些读取器,并标识可见键或相邻关系发生变化的 Turn。`changedTurnOrders` 包含移动 Node 的新旧所属轮次,以及相邻的轮次外 Node 被插入或移除所影响的轮次;仅生命周期变化仍由 `changedTurns` 提供。业务 Definition 可只重分这些轮次,并只刷新包含变化内容的组。结构输出仍替换完整根引用数组,但不要求重读未变化的 Node 内容。
Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。
渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。根 Node 引用与被引用组的成员构成完整渲染列表;未被引用的目标 Node 仍保留在存储中,但不渲染。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。
@@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type {
ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder,
ConversationViewDefinition, ConversationGroupInput, NodeChange, NodeKey, PartialAssistant, RunningToolCall,
ConversationViewDefinition, ConversationGroupInput, GroupNodePosition, NodeChange, NodeKey, PartialAssistant, RunningToolCall,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
@@ -164,6 +164,7 @@ class MutableChatNodeStore implements ChatNodeStore {
class MutableChatLocationIndex implements ChatLocationNodeIndex {
private turns = new Map<number, readonly string[]>()
private steps = new Map<string, readonly string[]>()
private positions = new Map<string, GroupNodePosition>()
getTurn(turn: number): readonly string[] {
return this.turns.get(turn) ?? EMPTY_KEYS
@@ -173,13 +174,32 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex {
return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS
}
rebuild(order: readonly string[], store: ChatNodeStore): void {
getPosition(key: string): GroupNodePosition | undefined {
return this.positions.get(key)
}
rebuild(order: readonly string[], store: ChatNodeStore): readonly number[] {
const turns = new Map<number, string[]>()
const steps = new Map<string, string[]>()
for (const key of order) {
const positions = new Map<string, GroupNodePosition>()
const changedTurns = new Set<number>()
for (const [index, key] of order.entries()) {
const location = store.get(key)?.location
if (location === undefined) continue
const coordinates = locationCoordinates(location)
const previous = this.positions.get(key)
const position: GroupNodePosition = {
turn: coordinates.turn,
previous: order[index - 1] as NodeKey | undefined,
next: order[index + 1] as NodeKey | undefined,
}
const unchanged = previous !== undefined && previous.turn === position.turn
&& previous.previous === position.previous && previous.next === position.next
positions.set(key, unchanged ? previous : position)
if (!unchanged) {
if (previous?.turn !== undefined) changedTurns.add(previous.turn)
if (position.turn !== undefined) changedTurns.add(position.turn)
}
if (coordinates.turn === undefined) continue
const turnKeys = turns.get(coordinates.turn) ?? []
turnKeys.push(key)
@@ -190,8 +210,13 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex {
stepKeys.push(key)
steps.set(step, stepKeys)
}
for (const [key, position] of this.positions) {
if (!positions.has(key) && position.turn !== undefined) changedTurns.add(position.turn)
}
this.positions = positions
this.turns = updateIndex(this.turns, turns)
this.steps = updateIndex(this.steps, steps)
return [...changedTurns]
}
/** Invalidate aggregate readers when member data changes without moving. */
@@ -968,6 +993,8 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
private order: readonly string[] = EMPTY_KEYS
private latestGroupInput: ConversationGroupInput<ChatConversationViewNode>
private readonly readGroupNode = (key: NodeKey): ChatConversationViewNode | undefined => this.store.get(key)
private readonly readGroupTurn = (turn: number): readonly NodeKey[] => this.locations.getTurn(turn) as readonly NodeKey[]
private readonly readGroupPosition = (key: NodeKey): GroupNodePosition | undefined => this.locations.getPosition(key)
/** Last published timeline: a Turn boundary can land without a new node. */
private timeline: ConversationTimelineSnapshot | null = null
readonly empty: ChatSnapshot
@@ -978,6 +1005,8 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
kind: 'replace',
order: this.order as readonly NodeKey[],
readNode: this.readGroupNode,
readTurn: this.readGroupTurn,
readPosition: this.readGroupPosition,
timeline: this.empty.timeline,
}
}
@@ -997,6 +1026,8 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
kind: 'replace',
order: this.order as readonly NodeKey[],
readNode: this.readGroupNode,
readTurn: this.readGroupTurn,
readPosition: this.readGroupPosition,
timeline: input.timeline,
}
const snapshot = this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
@@ -1031,10 +1062,11 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
}
}
this.store.upsert(upserts)
let changedTurnOrders: readonly number[] = EMPTY_TURNS
if (structural) {
const next = orderedVisibleChatNodes(this.store.values()).map(node => node.key)
this.order = sameReferences(this.order, next) ? this.order : next
this.locations.rebuild(this.order, this.store)
changedTurnOrders = this.locations.rebuild(this.order, this.store)
}
this.locations.touch(contentOnly)
this.store.updateProcesses(processTurns, this.locations)
@@ -1049,8 +1081,11 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
changes,
order: this.order as readonly NodeKey[],
readNode: this.readGroupNode,
readTurn: this.readGroupTurn,
readPosition: this.readGroupPosition,
timeline: input.timeline,
changedTurns: input.changedTurns ?? EMPTY_TURNS,
changedTurnOrders,
}
const snapshot = this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
return snapshot
@@ -36,6 +36,8 @@ describe('Chat Node keyed sources', () => {
expect(initial.kind).toBe('replace')
expect(initial.order).toBe(snapshot.order)
expect(initial.readNode(initial.order[0]!)).toBe(user)
expect(initial.readPosition(initial.order[0]!)).toEqual({ turn: undefined, previous: undefined, next: undefined })
expect(initial.readTurn(7)).toEqual([])
const context: ChatConversationViewNode = {
key: 'context:2', kind: 'context', id: '2', target: 'chat', anchorSeq: 2,
location: { kind: 'session' }, visibility: 'visible',
@@ -49,13 +51,18 @@ describe('Chat Node keyed sources', () => {
if (input.kind !== 'apply') throw new Error('expected apply')
expect(input.order).toBe(next.order)
expect(input.changedTurns).toEqual([7])
expect(input.changedTurnOrders).toEqual([])
expect(input.readPosition(context.key as typeof input.order[number])).toEqual({
turn: undefined, previous: user.key, next: undefined,
})
expect(input.readPosition('missing' as typeof input.order[number])).toBeUndefined()
expect(input.changes.find(change => change.current.key === user.key)).toEqual({
previous: user, current: next.nodes.get(user.key),
})
expect(next.nodes.get(user.key)?.data).toMatchObject({ skillNames: ['demo'] })
expect(input.changes.find(change => change.current.key === context.key)).toEqual({ previous: undefined, current: context })
builder.apply({ upserts: [], timeline })
expect(builder.groupInput()).toMatchObject({ kind: 'apply', changes: [], changedTurns: [] })
expect(builder.groupInput()).toMatchObject({ kind: 'apply', changes: [], changedTurns: [], changedTurnOrders: [] })
expect(builder.groupInput().order).toBe(next.order)
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 41a9baeb7cc370cdb4a9d6702808646a0df38fb3
README.zh.md: 1b5604f0c0c074ac64a28da61706be5d663fda85
README.md: 6f115521a090ca32686af417a7ceea0634324c7d
README.zh.md: babf6f5cfb0760b471988cf27017e20b46a85868
+1 -1
View File
@@ -31,7 +31,7 @@ The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its o
A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes the Builder's projected Node changes and changed Turns after Node materialization, including first activation, and owns all segmentation and group data. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities.
`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes projected Node changes, changed Turns, and indexed target positions after Node materialization, including first activation, and owns all segmentation and group data. Indexed Turn reads preserve interruptions from neighbouring Nodes, allowing business updates to stay within affected Turns and groups. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities.
Group registration rejects a missing View target. Removing an already registered View Definition pauses its grouping and clears its published results without removing the Group Definition; registering that View again rebuilds from the current loaded timeline through the existing replacement flow. Switching View tabs does not remove a View Definition.
+1 -1
View File
@@ -31,7 +31,7 @@ kind: "package-reference"
shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费 Builder 投影后的节点变化及变化轮次,覆盖首次激活,并拥有全部分段规则与组数据。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。
`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费投影后的节点变化、变化轮次及已索引的目标位置,覆盖首次激活,并拥有全部分段规则与组数据。按索引读取 Turn 时保留相邻 Node 造成的分隔,使业务更新可以限制在受影响轮次和组内。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。
Group 注册时缺少 View 目标会报错。已注册的 View Definition 被移除后,其分组计算暂停并清空已发布结果,但保留 Group Definition;重新注册该 View 后,沿现有替换流程从当前已加载时间线重建。切换 View 页签不会移除 View Definition。
@@ -55,10 +55,21 @@ export interface NodeChange<Node> {
readonly current: Node
}
/** Owning Turn and immediate neighbours in the target's visible Node order. */
export interface GroupNodePosition {
readonly turn: number | undefined
readonly previous: NodeKey | undefined
readonly next: NodeKey | undefined
}
interface GroupInputNodes<Node> {
readonly order: readonly NodeKey[]
/** @param key - target Node identity. @returns its current value during this synchronous update. */
readonly readNode: (key: NodeKey) => Node | undefined
/** @param turn - owning Turn. @returns its visible keys in target order; empty when absent. */
readonly readTurn: (turn: number) => readonly NodeKey[]
/** @param key - target Node identity. @returns its visible position, or absence outside the target order. */
readonly readPosition: (key: NodeKey) => GroupNodePosition | undefined
readonly timeline: ConversationTimelineSnapshot
}
@@ -69,6 +80,8 @@ export type ConversationGroupInput<Node> = GroupInputNodes<Node> & (
readonly kind: 'apply'
readonly changes: readonly NodeChange<Node>[]
readonly changedTurns: readonly number[]
/** Turns whose visible keys or immediate neighbours changed, including removal and cross-Turn moves. */
readonly changedTurnOrders: readonly number[]
}
)
@@ -90,7 +103,7 @@ export interface ConversationGroupDefinition<
/**
* Consume target Node changes, not raw Session events or presentation modes.
* @param context - current Session-local State.
* @param input - current target inputs; readNode is valid only during this call.
* @param input - current target inputs; its readers are valid only during this call.
* @returns State adopted by the framework.
*/
update(context: ConversationGroupContext<State>, input: ConversationGroupInput<Node>): State
@@ -7,7 +7,7 @@ export type { ConversationGroupRegistry } from './conversation/group-registry.ts
export type {
ConversationGroupContext, ConversationGroupData, ConversationGroupDataMap,
ConversationGroupDefinition, ConversationGroupedView, ConversationGroupInput,
GroupKey, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey,
GroupKey, GroupNodePosition, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey,
NodeReference, RenderEntry,
} from './contract/groups.ts'
export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
@@ -25,14 +25,27 @@ class GroupTestBuilder implements ConversationViewBuilder<ConversationViewNode,
readonly empty = new Map<string, ConversationViewNode>()
readonly nodes = new Map<string, ConversationViewNode>()
readonly publish = vi.fn()
private readonly readers = {
readNode: (key: NodeKey) => this.nodes.get(key),
readTurn: (_turn: number): readonly NodeKey[] => [],
readPosition: (key: NodeKey) => {
const index = this.input.order.indexOf(key)
if (index < 0) return undefined
return {
turn: undefined,
previous: this.input.order[index - 1],
next: this.input.order[index + 1],
}
},
}
private input: ConversationGroupInput<ConversationViewNode> = {
kind: 'replace', order: [], readNode: key => this.nodes.get(key), timeline: { turnOrder: [], turns: new Map() },
kind: 'replace', order: [], ...this.readers, timeline: { turnOrder: [], turns: new Map() },
}
replace({ nodes, timeline }: { nodes: readonly ConversationViewNode[]; timeline: ConversationTimelineSnapshot }) {
this.nodes.clear()
for (const node of nodes) this.nodes.set(node.key, node)
this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline }
this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline }
return this.nodes
}
@@ -43,7 +56,10 @@ class GroupTestBuilder implements ConversationViewBuilder<ConversationViewNode,
}) {
const changes = upserts.map(current => ({ previous: this.nodes.get(current.key), current }))
for (const node of upserts) this.nodes.set(node.key, node)
this.input = { kind: 'apply', changes, changedTurns, order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline }
this.input = {
kind: 'apply', changes, changedTurns, changedTurnOrders: [],
order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline,
}
return this.nodes
}