# 实现说明 这份文档记录 `dsh-payload` 的实现细节与踩坑记录,供想改代码的人参考。使用者只需要读 [README](../README.md)。 ## 位置:为什么挂在 assistant 操作行 挂载点是 `conversation.chat.assistant-actions`(每条**已定稿**的 assistant 消息下方的操作行,list slot,`replaceRisk: none`)。入参只有 `messageId`(外加 `sessionId` 等标准 props),所以按钮天然和官方那几个操作图标排在同一行。 点击时的解析链路: 1. `messageId` → 日志里 `assistant/message.data.message.id` 相等的那条事件(窗口里还没有就靠 `loadOlder()` 往前翻) 2. 该事件的 `{turn, step}` —— `step` 就是「第几次调用」 3. 重建整个 Turn(`buildTurnCalls`),选中 `meta.step === step` 的那个 call 因此每条回复的按钮指向的正是**产生这条回复的那一次调用**,而不是这一轮的最后一次。 > 早期版本挂在 `conversation.chat.turnTail`(Turn 底部的扩展位),只能给出「这一轮最后一次调用」,无法定位到某条具体回复,所以改到了 assistant 操作行。 之所以不贴在那条**用户**消息下方:用户消息那一行(`conversation.chat.node` 的 `user` key)和 `conversation.chat.user.content` 都是**替换型**插槽,占用即替换官方渲染。官方 `ui-chat` 的文档明确写着 `UserStyleBubble` 与 source-specific wrappers 承载了用户行的时间戳、copy 控件、pending/本地 echo 标记、消息操作,而 `conversation.chat.user.content` Factory 只渲染内容栈、不是全局用户内容覆盖入口,直接调用者要自己提供 row wrapper。影子替换 `user` 节点 = 从头重写整行用户气泡(内置实现不随包发布,只有打包后的 `lib/client.js`),每条用户消息的观感都会退化。会话内也不存在 `conversation.chat.user.actions` 这类可扩展的 list slot(已用 Slots inspect 全树确认)。 ## 读取方式 只有点击时才读: 1. `sessions.binding(sessionId)` 拿到 `session` + `eventSource` 2. **走完整窗口**:只要 `hasMore === true` 就 `session.loadOlder()`(上限 200 页)。不能「找到目标消息就停」——请求体含此前的全部对话,窗口缺前缀会静默丢消息,Turn 的 `turn/start` 也可能还没翻出来 3. 用命中的事件拿到 `{turn, step}`,再用本轮的 `turn/start` / `turn/end`(或下一个 `turn/start`)确定整轮边界 4. 上界内每个 `step/start` 切一份:取该 step 的**首个响应事件**(`assistant/attempt` / `assistant/message`)作为切点,折叠它之前的所有 surface 事件形成 `messages`,并沿用该 step 之前最近的一条 `request/header` 作为调用参数 5. 用 `meta.step === step` 选中那一次调用(弹框直接停在这条回复对应的调用上) 6. 结果缓存在模块级 Map,键是 `sessionId + turn + 窗口末端 seq`——带上窗口长度,Turn 还在增长时不会命中过期结果;Map 超过 40 条整体清空 ## 首开耗时:一次折叠,而不是每步重扫 第一版每个切点都调一次 `buildMessages(events, cutSeq, …)`,每次都从窗口第一个事件重新折叠到该切点,于是 169 次调用的 Turn 变成了 169 次全量回放,Node 里 11.4 s。现在: - `FoldState` 顺序把事件吃进「已折叠消息」和每条消息的 `items[]`(同一条消息的事件组),`snapshotAt(cutSeq)` 只让游标前进到切点,然后返回 `messages.slice(0, n)` 加一份长度记录。第 k 步的数组是上一份的前缀,**引用共享**——所以 `diffRequests` 的公共前缀可以用 `===` 判断完,不必逐条 stringify。 - 来源分类(`summarizeSources`)同理改成前缀累加:`newSourceAcc` / `foldSourceInto` / `snapshotSourceAcc`,整轮一份、本轮再从 `historyEnd` 起一份。 - `diffRequests` 里 `messages` 之外的字段(model、采样参数、`tools`)先比引用:同一 Turn 内 `tools` 是同一个数组,直接相等就跳过 stringify。 - 真实会话实测(Node):Turn 3(61 次调用)11 ms、Turn 55(120 次)57 ms、Turn 71(169 次)101 ms、Turn 76(111 次)83 ms;整份日志的 `JSON.parse` 约 583 ms 是 `loadOlder()` 那一步的一次性成本,与调用次数无关。 ## 分组的几个细节 - 切点是该 step 的**首个响应事件**,不是 `step/start`:本轮的运行时上下文注入(`Time sampled while preparing turn N, step M…` 这类 `user/message`)是在 `step/start` **之后**才写进日志的,用 `step/start` 当切点会把它们漏掉,`messages` 甚至可能为空。 - 因为切点是逐次推进的,第 2 次调用天然包含第 1 次的 assistant 消息和 `tool/result`——**逐次累积**,与真实请求一致。 - 标签页的 `title` 与面板摘要都带 `messages` 条数、`tools` 个数和 `reason`;`reason` 来自继承到的 header,只说明这份配置是何时写的,不代表本次调用的类型(`initial` / `change` / `resume` / `series`)。 - 只有 1 次调用时不显示卡片,退化成单个浏览视图(`请求` / `响应` 这一对仍保留);超过 12 次换成左侧 step 列表(一个 Turn 可能有几十次调用)——下拉框只能塞下一行 `#k`,看不到 step 号、消息条数和 token,列表都能看到,且右侧 JSON 仍占满剩余宽度。 - 上界不依赖插槽的 `seq`(那只是兜底),避免 `seq` 恰好落在某个中间事件时把后面的调用截掉。 ## 踩过的坑 **不要在 Slot 组件里调用 `props.useSessions()` 之类的“看起来像 hook”的注入属性。** 首次实现时在组件顶层调用了它,渲染期抛错后框架会**剥夺(abdicate)**该条目:按钮完全不渲染,而 `Slots` inspect 里该条目的 `active` 变成 `false`。诊断顺序是:先挂两个零依赖的静态探针(`conversation.composer.dock` / `turnTail`)二分定位——两个探针都出现即说明插件加载正常,问题在组件自身;然后把服务获取改成**点击时**再 `ctx.get('sessions')`,渲染期不依赖任何注入服务。 **不要用 `request/header` 当作调用边界。** 第一版就是这么写的,结果一次调用都分不出来(卡片永远不出现)。`request/header` 只在调用配置变化时写一条,密集工具调用的 Turn 里可能一次都没有(实测一个 1148 条记录的会话里只有 5 条,而 `step/start` 有 186 条)。分组锚点必须是 `step/start`,配置则从不超过该 step 的最近一条 header 继承。 **`system/message` / `user/message` 是增量记录。** 空的 `content: []` 表示“这一轮没有新增”,不是“发送一条空消息”,重建时必须跳过,否则会把模型根本没收到过的空 system 塞进 payload。 **内联样式做不出 `:hover`。** 图标按钮要在悬停时浮出文字气泡,而 `style={}` 无法表达伪类。做法是在 `apply()` 里往 `document.head` 注入一份 `