DSH / Atlas
2026-08-11implementedfeature

Web surface for message feedback

消息反馈的 Web 界面

[PR #2217](https://github.com/deepseek-harness/deepseek-harness/pull/2217) landed the durable message-feedback sidecar and its three Host Remote methods, but it was explicitly backend-only: no client package consumed `messageFeedback.list`, `put`, or `delete`, so the Web GUI had no way to record a rating. Its Agent Note deferred "client Remote aggregate mounting and UI" to a separate owner. Issue #1326 asks for the W

Source compatibility: Chinese title uses a compatible non-canonical header

English

Problem

PR #2217 landed the durable message-feedback sidecar and its three Host Remote methods, but it was explicitly backend-only: no client package consumed messageFeedback.list, put, or delete, so the Web GUI had no way to record a rating. Its Agent Note deferred "client Remote aggregate mounting and UI" to a separate owner. Issue #1326 asks for the Web surface and was closed by that backend merge without the user-visible half existing.

An earlier full-stack attempt, PR #1010, carried a UI layer but was built against its own backend with a different shape: one Session-wide revision for compare-and-swap and RPC named feedback.upsert. #2217 shipped per-item ifVersion and messageFeedback.put instead, so #1010's controller logic no longer matched the contract, and its branch had also drifted structurally (it edited packages/cordis/, renamed to packages/extensions/, and added a top-level packages/session-feedback/ that conflicts with the consolidated packages/feedback/). It was closed as superseded rather than rebased.

The blocking gap for any UI was that the browser could not name a feedback target. The Host accepts only an append-origin assistant/message addressed by MessageId, but AssistantMessageNode — the client's finalized-assistant node — carried seq, turn, and step and no message identity. Only SteeringMessageNode had a messageId.

Decision

Three seams, each owned where its authority already lives.

Message identity in the client node. AssistantMessageNode gains an optional messageId, copied from event.data.message.id where the node is materialized from a finalized assistant/message. It stays absent on interruption-frozen partials, which were never finalized and address no durable message, and on the synthetic sentinel the trajectory layout builds for an unfinalized partial. The field is optional precisely so those two cases remain unrepresentable as feedback targets rather than being papered over with a placeholder. ui-conversation and ui-trajectory each materialize their own copy of this node, so both finalized branches were updated; the interrupted branches were deliberately left alone. This mirrors the Host's own target rule, which filters on isAppendSurfaceEvent, so client and Host agree on what is addressable without sharing code.

A declared slot rather than a direct dependency. ui-conversation declares conversation.chat.assistant-actions (list kind, session scope, owner {messageId}) and authorizes it as a second child of the turn-tail node renderer, next to the existing conversation.chat.turnTail chain. TurnTailNodeView renders it and threads the result into MessageIconActions through a new extraActions prop, placed between copy and branch. The render site skips the slot entirely when messageId is absent, so an interrupted turn shows no controls. The feedback package therefore contributes an entry and never imports the conversation implementation; the strip renders nothing at zero cost when the plugin is composed out of cordis.yml.

extraActions is a ReactNode prop rather than a second render-slot hole because MessageIconActions is shared chrome for user and assistant messages: the assistant caller resolves the slot and passes the result down, so the user path stays unaware of a slot it must never render.

Per-item CAS in a per-session controller. @deepseek-ai/dsh-client-ui-message-feedback holds one MessageFeedbackController per Session, keyed by MessageId in a map. A single list seeds every control in that Session's transcript. Each mutation sends the version that controller last observed as ifVersionnull when it knows of no item, which is exactly the Host's "must not exist" precondition.

The conflict path is where this diverges most from #1010. MessageFeedbackVersionConflict carries the authoritative current item (or null), so a lost race reconciles from the reply itself; #1010 answered every conflict with a blind full refresh. A conflict reporting current: null deletes the local entry, which is how a rating removed in another tab disappears here. Mutations serialize on a per-Session tail so a queued operation always compares against the committed version rather than the version read when the click landed.

The list read is deferred to the first hover or focus, not fired on mount, because the controls mount once per settled message in the visible history; a transcript-wide read on mount would fan out one request per message strip. connection/reset refreshes only Sessions whose status is no longer cold, so a reconnect does not warm Sessions nobody has looked at.

Toggle semantics keep the two verbs honest: re-clicking the recorded rating calls delete, switching sides calls put and carries any existing note forward, and clearing a message with no known item returns success without a call because it is already in the requested state.

Remote mounting. @deepseek-ai/dsh-api-remotes now mounts messageFeedbackRemote alongside goalsRemote and composes both disposers in reverse order. The generated ./remote artifact already existed in #2217's package exports, so no codegen change was needed; the client calls ctx.remote.messageFeedback and never touches the transport. Business results cross this boundary as the ordinary tagged union — the gateway throws only on transport failure — so the controller pattern-matches ok and translates a throw into the same settled result shape the controls already render.

Alternatives considered

Reuse conversation.chat.turnTail instead of a new slot. Rejected: turnTail is a chain keyed on the Turn and carries TurnTailOwnerProps {turn, seq, openFile}, which addresses a Turn boundary rather than a message identity. Feedback needs MessageId, and a chain is selector-routed one-at-a-time where the action strip is genuinely a list of independent contributors.

Put messageId on the chat node's id field. Rejected: that id is "${turn}:${step}" and is load-bearing for keyed dispatch and stable React keys. Overloading it would couple node identity to model output identity, and a message id is not unique per node anyway once replacement-origin events exist.

Keep #1010's session-wide revision. Not available: the merged Host contract is per-item ifVersion. Even as a client-side simplification it would be worse — one Session revision makes unrelated per-message edits conflict, which is the precise problem #2217's Agent Note records as the reason for per-item versions.

Rebase #1010. Rejected after inspection: 102 files, mergeable: false, a duplicate backend and RPC layer that #2217 supersedes under different names, and two directory renames since. Only its ~1,400-line UI layer had residual value, and that layer called feedback.upsert with a revision it no longer has. Rewriting the UI against the merged contract was less work than reconciling the branch, and the closing comment on #1010 records that reasoning.

Consequences

The Web GUI records per-message ratings and notes. #1326's user-visible half now exists; the issue was reopened because the backend merge had closed it while no entry point existed.

AssistantMessageNode.messageId is optional, so every existing reader compiles unchanged, but any future consumer must handle absence rather than assume a finalized message. The two parallel materializers remain a duplication hazard: a third view that builds this node must remember to copy the id, and nothing enforces it. Only the chat view renders controls today, even though trajectory and waterfall nodes now carry the same id.

Feedback stays invisible to the model — the sidecar reaches neither the Session log, model context, nor telemetry — so the package's Model Experience is an audited none entry rather than a structured block.

The sidecar publishes no live frames, so a second tab's rating surfaces on reconnect or on the next conflict reply, not immediately. The note editor does not pre-check maxNoteBytes (8192 in the Web bundle), so an oversized note fails on save with note-too-large rather than while typing.

Twenty-four existing Web UI snapshots gained the two rating buttons across 27 assistant messages, confirming the strip reaches every settled assistant message in the shipped composition rather than only the fixture under test.

A dedicated Web E2E covers rate, note, reload restore, and retract against the shipped bundle. It has to hover the unrated control after a reload before asserting the restored state, because the deferred list read is what makes the sidecar value appear — the test documents that ordering rather than working around it. Sabotaging the controller's list restore makes the spec fail, so the durability assertion has teeth.

中文

问题

PR #2217 交付了持久化的消息反馈 sidecar 及其三个 Host Remote 方法,但它明确只做后端:没有任何客户端包消费 messageFeedback.listputdelete,因此 Web GUI 无法记录评价。它的 Agent Note 把「客户端 Remote aggregate 挂载与 UI」留给了另一个负责人。Issue #1326 要求的正是 Web 界面,却在该后端合并时被关闭,而用户可见的那一半并不存在。

更早的全栈尝试 PR #1010 带有 UI 层,但它基于自己的后端、形状不同:整个 Session 一个 revision 做 compare-and-swap,RPC 名为 feedback.upsert。#2217 最终交付的是逐条 ifVersionmessageFeedback.put,因此 #1010 的 controller 逻辑不再匹配契约;它的分支在结构上也已漂移(改动了 packages/cordis/,该目录已重命名为 packages/extensions/;新增的顶层 packages/session-feedback/ 与整合后的 packages/feedback/ 冲突)。它作为 superseded 关闭,而不是 rebase。

任何 UI 的阻塞缺口在于浏览器无法指名一个反馈目标。Host 只接受以 MessageId 寻址的 append 来源 assistant/message,但 AssistantMessageNode——客户端表示已完成 assistant 输出的节点——只携带 seqturnstep,没有消息身份。只有 SteeringMessageNodemessageId

决策

三个接缝,各自归属于其权威已经所在的位置。

客户端节点中的消息身份。 AssistantMessageNode 增加可选的 messageId,在该节点由已完成的 assistant/message 物化时从 event.data.message.id 复制。它在被中断冻结的部分输出上保持缺失——那些从未完成、不指向任何持久消息——在 trajectory 布局为未完成部分输出构造的合成哨兵上同样缺失。该字段之所以可选,正是为了让这两种情况无法被表示为反馈目标,而不是用占位值掩盖过去。ui-conversationui-trajectory 各自物化自己的该节点副本,因此两条「已完成」分支都做了更新;「被中断」分支被有意保留原样。这与 Host 自身的目标规则一致——它按 isAppendSurfaceEvent 过滤——因此客户端与 Host 在「什么是可寻址的」上取得一致,而不需要共享代码。

声明式槽位而非直接依赖。 ui-conversation 声明 conversation.chat.assistant-actions(list 类型、session 作用域、owner 为 {messageId}),并把它授权为 turn-tail 节点渲染器的第二个子项,与既有的 conversation.chat.turnTail 链并列。TurnTailNodeView 渲染它,并通过新的 extraActions prop 把结果传入 MessageIconActions,位置在复制与分支之间。当 messageId 缺失时渲染点整体跳过该槽位,因此被中断的 Turn 不显示任何控件。反馈包因此只贡献一个 entry,从不引入 conversation 的实现;当该插件从 cordis.yml 组装中移除时,这条操作栏以零成本渲染为空。

extraActions 是一个 ReactNode prop 而不是第二个 render-slot 洞,因为 MessageIconActions 是用户消息与 assistant 消息共享的外壳:由 assistant 一侧解析槽位并把结果向下传递,用户路径则对这个它永远不该渲染的槽位保持无感。

per-session controller 中的逐条 CAS。 @deepseek-ai/dsh-client-ui-message-feedback 为每个 Session 持有一个 MessageFeedbackController,以 MessageId 为键存入 map。一次 list 为该 Session 转录中的所有控件播种。每次 mutation 发送该 controller 最后观察到的版本作为 ifVersion——当它不知道任何条目时为 null,这正是 Host 的「必须不存在」前置条件。

冲突路径是与 #1010 分歧最大的地方。MessageFeedbackVersionConflict 携带权威的 current 条目(或 null),因此竞争失败方直接从回复本身收敛;#1010 对每次冲突都以一次盲目的全量刷新作答。报告 current: null 的冲突会删除本地条目,这就是在另一个标签页中被移除的评价在此处消失的方式。mutation 在 per-Session 的尾部串行化,因此排队中的操作总是与已提交的版本比较,而不是与点击落下那一刻读到的版本比较。

list 读取被推迟到首次 hover 或 focus,而不是在 mount 时触发,因为控件会为可见历史中每条已结算消息各 mount 一次;在 mount 时做全转录读取会导致每条消息栏各发一个请求。connection/reset 只刷新状态不再是 cold 的 Session,因此重连不会预热没人看过的 Session。

切换语义让两个动词保持诚实:再次点击已记录的评价调用 delete,切换到另一侧调用 put 并携带已有备注,而对没有已知条目的消息执行清除会直接返回成功且不发起调用,因为它已处于被请求的状态。

Remote 挂载。 @deepseek-ai/dsh-api-remotes 现在把 messageFeedbackRemotegoalsRemote 并列挂载,并以相反顺序组合两个 disposer。生成的 ./remote 产物在 #2217 的包导出中已存在,因此不需要 codegen 改动;客户端调用 ctx.remote.messageFeedback,从不接触传输层。业务结果以普通的 tagged union 穿过该边界——gateway 只在传输失败时抛出——因此 controller 对 ok 做模式匹配,并把抛出翻译为控件已经在渲染的同一种结算结果形状。

考虑过的替代方案

复用 conversation.chat.turnTail 而不新增槽位。 否决:turnTail 是以 Turn 为键的链,携带 TurnTailOwnerProps {turn, seq, openFile},寻址的是 Turn 边界而非消息身份。反馈需要 MessageId,而链是选择器路由的一次一个,操作栏则确实是一组互相独立的贡献者的列表。

messageId 放到 chat 节点的 id 字段上。 否决:该 id 是 "${turn}:${step}",且承载着 keyed dispatch 与稳定 React key 的作用。重载它会把节点身份与模型输出身份耦合起来,而且一旦存在 replacement 来源的事件,消息 id 本身在每个节点上也并非唯一。

保留 #1010 的 session 级 revision。 不可行:已合并的 Host 契约是逐条 ifVersion。即便作为客户端侧的简化也更糟——单一 Session revision 会让互不相关的逐条编辑相互冲突,而这正是 #2217 的 Agent Note 记录的采用逐条版本的原因。

Rebase #1010。 经检查后否决:102 个文件、mergeable: false、一个被 #2217 以不同名称取代的重复后端与 RPC 层,以及此后的两次目录重命名。只有其约 1400 行的 UI 层有残余价值,而该层调用的 feedback.upsert 及其 revision 已不复存在。基于已合并的契约重写 UI 比调和该分支更省力,#1010 的关闭评论记录了这一理由。

结果

Web GUI 可以记录逐条消息的评价与备注。#1326 中用户可见的那一半现在存在了;该 Issue 之所以被重开,是因为后端合并在没有任何入口存在的情况下关闭了它。

AssistantMessageNode.messageId 是可选的,因此所有既有读取方无需改动即可编译,但任何将来的消费方都必须处理缺失,而不能假定消息已完成。两个并行的物化点仍是重复隐患:第三个构造该节点的视图必须记得复制该 id,而没有任何机制强制这一点。今天只有 chat 视图渲染控件,尽管 trajectory 与 waterfall 节点现在携带同一个 id。

反馈对模型保持不可见——该 sidecar 既不进入 Session 日志、也不进入模型上下文与 telemetry——因此该包的 Model Experience 是一条经审计的 none 条目,而不是结构化区块。

该 sidecar 不发布实时帧,因此第二个标签页的评价会在重连时或下一次冲突回复时才浮现,而不是立即。备注编辑器不预先校验 maxNoteBytes(Web bundle 中为 8192),因此过大的备注会在保存时以 note-too-large 失败,而不是在输入过程中。

24 个既有 Web UI 快照在 27 条 assistant 消息上获得了这两个评价按钮,确认这条操作栏在已发布的组装中触达每条已结算的 assistant 消息,而不仅是被测试的那个 fixture。

一个专门的 Web E2E 针对已发布 bundle 覆盖评分、备注、重载恢复与撤回。它必须在重载后先 hover 未评分的控件,再断言恢复后的状态,因为正是那次延迟的 list 读取让 sidecar 的值出现——该测试记录了这一顺序,而不是绕开它。把 controller 的 list 恢复逻辑破坏掉会让该规格失败,因此这条持久性断言是有效的。