Hermes WebUI 稳定助手回合锚点:统一流式渲染与终态结算的前端归属模型
2026/9/13 1:20:36 网站建设 项目流程

Hermes WebUI 稳定助手回合锚点:统一流式渲染与终态结算的前端归属模型

【免费下载链接】hermes-webuiHermes WebUI: The best way to use Hermes Agent from the web or from your phone!项目地址: https://gitcode.com/GitHub_Trending/he/hermes-webui

本篇围绕 Hermes WebUI 的设计 RFC《Stable Assistant Turn Anchors for Live-to-Final Rendering》展开,解析它如何用"助手回合锚点(Assistant Turn Anchor)"这一前端呈现/协调原语,把流式输出、回放重放、结算终态与两种显示模式统一到同一个事件模型之下。读完你能掌握该锚点的身份模型、事件归一化与去重机制、结算/重建流程,以及当前仓库中static/assistant_turn_anchors.js对 RFC 各条款的落地方式。

问题背景:流式态与结算态之间的"接缝"

Live-to-Final(流式到终态)的产品模型已经确立:

  • 一个 live 的助手回合展示进行中的工作;
  • 支撑性活动(工具、Thinking、生命周期状态)归入 Worklog(工作日志);
  • 最终答案成为主要的、已结算(settled)的结果;
  • 当没有产出正常最终答案时,终态应被显式表达。

但这个产品模型之下隐藏着一个架构问题:live 助手回合与结算后的转录(transcript)由不同的层表示。在浏览器路径中,live 输出由流局部变量、INFLIGHT快照、live DOM 节点和 SSE 回调拼装而成;当流完成时,浏览器用服务端会话数据替换或协调S.messages并调用renderMessages()重建转录。也就是说,UI 必须在结算时刻跨越这条边界:

live stream state + DOM + INFLIGHT snapshot != settled transcript messages

围绕回放、重连、会话切换、空白恢复回合、thinking 占位符、流结束恢复、流中重建等问题,项目已经做过多轮针对性修复(RFC 中列举了 #3401/#3741、#3707/#3763、#3869/#3876、#3875、#3877、#3885 等修复轨迹)。这些修复有用,但都指向同一个缺失的可用原语:一个在最终答案存在之前就已存在、能够拥有 live 活动、结算、回放、恢复与显示模式渲染的稳定助手回合锚点

RFC 特别强调,这个锚点首先是呈现层归属(presentation ownership)原语,其次才是身份或回放原语——持久事件身份与基于游标的回放其实已经存在(见下文 Event Envelope)。真正的故障模式是:当 live 状态与结算转录相遇时,"哪一层拥有一张工具卡片、一行 thinking、一个已结算答案"没有答案。同时这也承接了 WebUI Run State Consistency Contract(见 webui-run-state-consistency-contract.md):那份契约用不变式清单防止 N 个每回合状态层漂移,而本 RFC 的目标是把不变式收敛到一个拥有者对象上,让规则有"家"可归。

相应的风险也写得很直白:只有当锚点把其他每回合存储(S.messagesINFLIGHT、流局部闭包状态、live DOM)降级为缓存和渲染器时,锚点才有价值。如果它只是并排加在它们旁边而不接管归属,就会变成第 N+1 个存储层,让接缝更糟。RFC 要求每一个实现切片都按"是否移动了归属权"而不是"是否新增了结构"来评判。

当前架构形态:六个重叠的状态层

RFC 对当前浏览器中同一个活跃回合的存储层做了盘点:

当前角色对 Live-to-Final 的问题
S.messages已结算渲染的规范可见转录live 助手回复没有在回合开始时作为稳定规范锚点插入
INFLIGHT活跃工作与恢复的浏览器快照是有用的恢复缓存,但不是结算转录模型
流局部闭包状态跟踪assistantTextreasoningText、live 工具卡片、分段计数器、解析器目标适合热路径流式写入,但不能是唯一排序来源
live DOM持有当前 live 助手回合、Worklog 行、解析器目标、瞬态卡片渲染快,但在renderMessages()重建转录时很脆弱
run journal持久化的已发事件源,用于回放有用,但前端仍需在其上建立归一化呈现模型
settled session messages服务端返回的最终转录到达得晚,当前会强制一次从 live 表达到结算表达式的桥接

最脆弱的时刻就是终态结算:流发出done→ 浏览器收到结算会话载荷 →S.messages被服务端事实替换/协调 →renderMessages()重建转录 → 辅助代码尝试把仅 live 的字段、工具元数据、滚动状态、Thinking 元数据和 DOM 连续性搬运过去。

需要澄清的是,renderMessages()不只是终态函数,它是当前转录面板的通用渲染器(会话加载、会话切换、用户消息回显、命令输出、错误/取消恢复、最终结算都要走它);热路径流式时 token/reasoning/tool 监听器大多是增量更新 live DOM。但任何流中重建都可能擦除并重建消息面板,所以现有代码对 live 助手 DOM 节点有专门保护。RFC 的结论是:目标不是删除renderMessages(),而是让它从"live 与 settled 之间的语义边界"退化为"稳定呈现状态之上的渲染器"

真相源分层

锚点必须坐在正确的层上。RFC 给出的权限表(也是实现中STATE_LAYERS常量的设计依据):

权限
服务端会话与结算转录持久最终消息、最终答案、持久元数据、累计用量
Run journal / 可回放事件live 流事件及其顺序的持久证据,发出 Artifact 1 信封(run_id:seq);注意与记录"已提交回合生命周期"的 turn journal 区分
SSE live 流最低延迟的活跃工作观测路径
Assistant Turn Anchor单个助手回合的前端呈现/协调拥有者
INFLIGHT、localStorage、HTML 快照仅用于浏览器恢复加速与容错
DOM一次性渲染输出
RuntimeAdapter未来的执行/事件源边界,不由本 RFC 拥有

关键约束:DOM 不得被当作语义事实——live DOM 节点可以为了连续性保留,但事实必须来自 SSE、journal 事件、结算转录载荷和锚点状态;INFLIGHT可以加速恢复,但绝不能凌驾于 journal 或结算转录证据之上。

核心提案:AssistantTurnAnchor 概念模型

引入一个内部 WebUI 呈现原语——稳定助手回合锚点,概念结构如下:

AssistantTurnAnchor identity session_id turn_id run_id stream_id source_message_refs lifecycle status terminal_state started_at completed_at content final_answer final_message_ref activity_events[] process_prose reasoning tool_started tool_updated tool_completed lifecycle_status control_boundary artifact_reference terminal_status artifacts[] side_effects[] usage

锚点不是后端 schema 要求,而是让浏览器能够回答这些问题的前端模型:这条 live 事件属于哪个助手回合?它应该出现在哪里?这条事件是否已经被重放过?这个回合的最终答案是什么?它到达了什么终态?哪些工件、副作用和用量元数据属于这个回合?哪种显示模式应该渲染这同一串事件序列?

目标状态可以概括为:

Current: live DOM != settled data model Target: live events attach to an assistant-turn anchor settlement updates that same anchor renderers consume the anchor

在目标模型中,流done不再需要"制造另一个回合",它只是完成已有回合。同时,仅渲染器的偏好(Compact Worklog 展开、Transparent Stream 展开、复制按钮可见性、滚动跟随偏好)被刻意排除在语义锚点之外——它们可以放在渲染器状态或每会话 UI 偏好存储里,但回放与结算不得把这些选择持久化为助手回合事实。

锚点创建与身份模型

正常创建路径有四步:

  1. 用户提交消息;
  2. /api/chat/start成功;
  3. 响应返回stream_id
  4. WebUI 创建一个绑定到活跃session_id、已提交用户回合和stream_id的锚点。

这个时机早于第一条助手拥有的 SSE 事件——第一条事件可能是 token、中间进度、reasoning、工具开始、压缩生命周期行或控制边界,等待其中某类事件会让早期排序和归属取决于传输时序。

重建路径是另一回事:在刷新、重连、会话切换或本地锚点丢失时,WebUI 可以从 run journal 事件、结算转录消息和INFLIGHT快照重建锚点,但这不应被描述为正常创建策略。

Event Envelope:身份来源不重新发明

锚点消费的是 RuntimeAdapter 契约(hermes-run-adapter-contract.md)的Artifact 1 Event Envelope作为身份来源:event_id = "run_id:seq"、单调seqrun_idLast-Event-ID/after_seq重连、按run_id + seqevent_id去重。这一信封当前已经由 WebUI run journal 真实发出——可以在 api/run_journal.py 中看到写入逻辑"event_id": f"{run_id}:{assigned_seq}",回放侧的after_seq过滤(api/run_journal.py)和after_event_id游标解析(api/run_journal.py)也一一对应。run_id是持久键;stream_idrun_id预期会超越它的遗留传输键。未来若出现持久run.started事件,只是升级这些键的来源,锚点的身份模型不变。

身份优先级(从高到低):

  1. Event Envelope 的event_id/run_id + seq(当前:run journal;将来:runner/runtime);
  2. 有持久 turn 键时的turn_id
  3. 传输层回退:session_id + stream_id + 本地回合序号
  4. 重建已完成回合时的结算助手消息引用;
  5. 老数据没有更强身份时的浏览器本地回退 ID。

锚点不得把身份硬绑到stream_id上,因为适配层迁移会替换 WebUI 拥有的流路径而保持 Event Envelope 稳定。可见文本与时间戳不是身份来源,只能是载荷与诊断数据。

活动事件模型与源事件映射

归一化事件模型刻意做得小而稳定,不需要镜像每个后端字段,但必须保留源载荷或源元数据以便调试和未来迁移:

{ "event_id": "optional stable event id", "local_id": "browser fallback id", "session_id": "session id", "turn_id": "assistant turn id", "run_id": "optional runtime run id", "stream_id": "optional stream id", "seq": 12, "kind": "tool_started", "source_event_type": "tool", "created_at": 1778750000.0, "status": "running", "payload": {} }

必须满足的语义:event_idrun_id + seq是可得的时优先作为去重键;local_id只能作为浏览器回退存在;turn_id必须把事件绑定到一个助手回合;kind控制渲染策略而非运行时执行;source_event_type保留传输或派生来源;payload渲染前必须消毒。

事件 kind 与两种显示模式的渲染策略

Kind含义默认 Compact Worklog 渲染Transparent Stream 渲染
process_prose用户可见的助手进度文本Worklog 主散文项时间线转录项
reasoning提供方的 reasoning/thinking 载荷折叠的 Thinking 卡片时间线 Thinking 事件
tool_started工具调用开始工具卡片或分组工具行一等工具行
tool_updated工具输出/进度更新更新现有卡片/分组更新同一工具行
tool_completed工具调用结束定稿卡片/分组定稿同一工具行
lifecycle_status压缩、重连、恢复中、降级、警告等用户可见时的安静生命周期行时间线生命周期行
control_boundary停止、中断、排队/steer、clarify/approval 或延续边界可见时的专用控制/状态行时间线控制行
artifact_reference产出的文件、工作区变更、保存输出或交接引用实现后的工件/引用条目时间线工件事件
terminal_status完成、取消、中断、无响应、限额、连接丢失或错误终态卡片或最终状态时间线终态行

当前源事件到锚点归属的映射

现有事件名应被映射进归一化模型,而不是自己成为长期模型:

当前源锚点归属
tokenprocess_prose活动事件
interim_assistantprocess_prose活动事件,常带活动边界
reasoningreasoning活动事件
toolevent_type=tool.startedtool_started活动事件
tool_completetool_completed活动事件
未来部分工具输出有稳定源时tool_updated活动事件
compressinglive 时lifecycle_status
compressedlive 时lifecycle_status;结算渲染可丢弃或折叠
approval回合内可见时control_boundary;审批 UI 仍是控制面
clarify回合内可见时control_boundary;clarify UI 仍是控制面
pending_steer_leftovercontrol_boundary和/或下一回合队列元数据
goal_continue调度延续时control_boundary加下一回合队列元数据
done结算触发加terminal_status/ 用量 / 最终转录合并
stream_end传输关闭与恢复触发;不等同于completed
cancelcancelledinterrupted语义的terminal_status
error/apperror带错误元数据的terminal_status
warning用户可见时lifecycle_status,否则诊断元数据
state_saved视是否用户有意义持久输出而定,artifact_reference或副作用
bg_task_complete会话/后台任务副作用;仅在启动或解释可见助手回合时可成为control_boundary

非活动源的分类

并非每个源事件都应成为可读活动事件:metering是锚点/会话上的用量与实时吞吐元数据;todo_state是侧面板状态快照;title是会话元数据;title_status是诊断/会话元数据;context_status是 composer/会话上下文元数据;goal是 composer/状态元数据(除非它产生可见回合边界);会话列表刷新是会话元数据;live DOM 快照只是恢复缓存。这套分类是模型的一部分:新增任何 SSE 或回放事件都必须把它归类为 activity、artifact、side effect、metadata、transport 或显式排除。

这份分类在实现中被固化为 static/assistant_turn_anchors.js 的SOURCE_EVENT_CLASSIFICATION常量表,分类顺序为activity / artifact / side_effect / metadata / transport / excludedCLASSIFICATION_ORDER,static/assistant_turn_anchors.js),未知源类型默认归入excluded(见classifyAssistantTurnAnchorSourceEvent,static/assistant_turn_anchors.js)——"新事件必须被分类"从文档约定变成了代码默认行为。

身份规则

身份是"把 Event Envelope 应用到事件粒度"。RFC 要求身份必须保守:

  • 优先使用 run journal 或 runtime 事件的稳定事件 ID;
  • 两者都有时优先run_id + seq
  • 同一工具卡片更新优先tool_call_id/tid
  • 不得仅按可见文本去重;
  • 不得仅按时间戳去重;
  • 不得用模糊或语义相似度丢弃事件;
  • 身份缺失时,优先追加一个归属清晰的事件,而不是丢弃一个可能是真实的重复事件。

这很重要,因为真实 agent 会发出重复文本、重复工具调用、重复状态消息;稳健的呈现层不应把它们当作意外重复,除非身份能证明。

实现核对:归一化、去重与事件路由

RFC 的状态标记为 Implemented。仓库中的 static/assistant_turn_anchors.js(1700+ 行,暴露为window.HermesAssistantTurnAnchors)与 RFC 条款逐一对应:

1. 事件归一化。normalizeAssistantTurnAnchorSourceEvent(static/assistant_turn_anchors.js)把任意输入源事件转成 RFC 中定义的归一化结构:从event_id/lastEventId提取稳定 ID,从event_id = "run_id:seq"反解run_idseq_eventIdRunId/_eventIdSeq,static/assistant_turn_anchors.js),按SOURCE_EVENT_CLASSIFICATION分类,payload_sanitizePayload消毒(限制深度为 6、剔除__proto__/constructor/prototype等危险键,static/assistant_turn_anchors.js)。

2. 去重键。assistantTurnAnchorEventDedupeKey(static/assistant_turn_anchors.js)严格实现了身份优先级:优先event_id,其次run_id + seq,最后是session_id + local_id + source_type + seq的本地回退;注意本地回退明确要求seq !== 'pending',即"身份弱时宁可追加"。

3. 注册表与事件应用。createAssistantTurnAnchorSeed(static/assistant_turn_anchors.js)创建的种子结构与 RFC 概念模型完全同形:identitysession_id/turn_id/run_id/stream_id/source_message_refs)、lifecyclestatus/terminal_state/started_at/completed_at)、contentfinal_answer/final_message_ref)、activity_events/artifacts/side_effects/metadata_events/transport_eventsusage;缺少turn_id时自动合成local:<session>:<run|stream|pending>:<local_id>作为回退身份。applyAssistantTurnAnchorNormalizedEvent(static/assistant_turn_anchors.js)在应用每个事件时依次执行四道闸门:无事件 →excluded;事件与锚点身份不匹配(session/turn/run 任一冲突)→mismatched_anchor;去重键已见 →duplicate;通过则按分类路由到activity_events/artifacts/side_effects/metadata_events/transport_events_routeAnchorEvent,static/assistant_turn_anchors.js),并维护applied/skipped_duplicate/skipped_excluded/skipped_mismatched统计——统计字段本身就是回放幂等性的可观测证据。

4. 状态层权威序。static/assistant_turn_anchors.js 的STATE_LAYERS把 RFC 的真相源分层表编码为带authorityRank(1=Event Envelope 最权威,7=live DOM 最末位)与anchorPolicy策略文本的常量,例如 DOM 层策略是 "DOM continuity is useful, but DOM is never semantic truth"。

5. 影子快照。createAssistantTurnAnchorShadowSnapshot(static/assistant_turn_anchors.js)按 live → replay → settled → inflight 顺序把四路事件灌进同一注册表,是"回放与结算顺序无关(order-invariance)"验收标准的直接支撑:同一 run 先 live 后 replay、或先 replay 后 settled,去重索引保证最终锚点一致。

6. 活动场景投影。projectAssistantTurnAnchorActivityScene(static/assistant_turn_anchors.js)把锚点投影为activity_scene_v1:每行活动事件生成row_id(优先event_id,其次run_id:seq,再退local_id:source:idx)、role(prose/thinking/tool/lifecycle/control/terminal)、以及compact_worklogtransparent_stream两套display_hints(static/assistant_turn_anchors.js)——这正是 RFC"两种显示模式读同一归一化输入"的实现形态。配套的reconcileAssistantTurnAnchorActivityScene(static/assistant_turn_anchors.js)与reconcileAssistantTurnAnchorRendererSnapshot则负责逐行核对"锚点期望的行"与"渲染器实际输出的行"(行数、顺序、重复、字段级 mismatch),使"呈现层与锚点是否一致"成为可测试断言。前端消费点可见于 static/messages.js 与 static/ui.js(会话恢复路径)、static/ui.js、static/ui.js,配套测试包括 test_anchor_scene_persistence.py、test_anchor_fallback_ownership.py、test_5749_anchor_scene_client_prefix_dedupe.py、test_5552_viewport_anchor_surrogate.py。

结算(Settlement):done 是更新,不是新回合

结算是运行中助手回合获得持久最终事实的时刻,不是第二个助手回合。当done到达时,WebUI 应把最终会话载荷协调进现有锚点:

  1. 校验session_idrun_idstream_id仍属于该锚点;
  2. anchor.content.final_message_ref绑定到结算助手消息,并把anchor.content.final_answer写成派生的渲染快照;
  3. 附加终态与用量元数据;
  4. 把结算的 reasoning/tool/artifact 元数据合并进既有活动事件;
  5. 把仅 live 的事件标记为已结算,或丢弃纯瞬态事件;
  6. 从锚点渲染所选显示模式。

renderMessages()在早期实现阶段仍可做整转录重建,重要的语义变化是:重建应渲染一个已经拥有 live 活动与结算答案的锚点,done不应依赖"另造一个仅结算的 Worklog"来解释同一个回合。

终态与最终答案文本是分离的:一个回合可以带着部分过程散文、工具卡片、reasoning、工件或用量元数据,却依然没有产出正常最终答案。content.final_message_ref在有结算消息身份时是持久的转录引用;content.final_answer不是独立语义拥有者,它是从结算助手消息拷贝的投影缓存,让早期渲染器不必每次绘制都追S.messages——若结算之后转录消息被改写,锚点必须从那条消息刷新,而不是允许两份拷贝静默漂移。

这一侧在实现中由projectAssistantTurnAnchorSettledMessageFinalAnswer(static/assistant_turn_anchors.js)完成:非 assistant 角色直接拒绝(non_assistant),缺 session 拒绝(missing_session),成功时经注册表应用settled_message事件并回填final_answerfinal_message_ref_updateContentFromMetadata(static/assistant_turn_anchors.js)同时处理usage_turnUsage元数据。

回放、刷新与重建

回放/刷新应当从持久证据重建同一个锚点,而不是另建一条仅回放的 UI 路径。来源优先级:

  1. run journal / 回放事件——live 活动顺序;
  2. 结算转录——最终答案与持久化消息元数据;
  3. INFLIGHT/ 本地恢复快照——快速本地恢复与回退。

概念步骤:判断会话是否有活跃或近期终态的流 → 按session_id加流/回合身份找现有锚点或创建重建锚点 → 把持久事件重放进锚点 → 合并结算转录元数据 → 用INFLIGHT填补兼容缺口 → 从重建锚点渲染 Compact Worklog 或 Transparent Stream → 证据不全时显示restoringdegraded

迁移期INFLIGHT仍是恢复缓存,直到锚点拥有的字段接管。RFC 给出了逐族的移交顺序:

当前INFLIGHT字段族锚点目的地回退规则
lastRunJournalSeq/ 回放游标锚点事件去重索引加 run journal 游标元数据优先 run journal 回放;INFLIGHT只用于恢复缺失的浏览器游标
activityBurstAnchors/ live 行锚点activity_events[]身份与分组元数据DOM 提示保留为渲染器缓存,不得凌驾归一化事件
currentLiveSegmentSeq/ 本地 live 顺序无 Event Envelope 时的锚点本地源顺序仅作浏览器回退身份;绝不按可见文本去重
streamId/ 活跃传输键identity.stream_id,位于run_id之下的回退知道run_id后不得把归属硬绑到stream_id
缓存的助手文本/reasoning/工具状态activity_events[]与派生渲染行仅在 journal 与结算转录证据之后用于重建缺口

stream_end需要特别小心:它是传输关闭信号,可能触发恢复,绝不能被当作回合已完成的证明。

渲染策略:一个锚点,两个投影

锚点把事件存储与显示策略分开:

AssistantTurnAnchor -> Compact Worklog renderer -> Transparent Stream renderer

Compact Worklog(默认模式):过程散文是主阅读面;Thinking 与工具是辅助 Worklog 项;工具运行可分组摘要;结算后的 Worklog 详情默认折叠或跟随用户偏好;仅 live 的生命周期行不污染最终答案;最终答案保持视觉主位。

Transparent Stream(可选开启):过程散文、Thinking、工具、结果、生命周期行、控制边界与终态行都是按时间线排列的一等活动事件;工具调用不被聚合摘要掩盖;最终答案与执行轨迹保持分离;live、settled、reload、replay 使用同一事件顺序。Transparent Stream 不取代 Compact Worklog,它是同一锚点与活动事件的另一个投影。两个渲染器都必须保持:助手回合归属、事件顺序、终态诚实性、回放幂等、最终答案分离、用户展开状态(在可行范围内)、刷新或会话切换后的稳定行为。切换显示模式不应触发后端回放、重跑 agent 或改变哪些事实属于这个回合。

与 Transparent Stream RFC(transparent-stream-activity-mode.md)的关系被显式对账过:那边的normalizeToEvents()与本 RFC 的anchor.activity_events同一条归一化接缝在三个来源路径(live、journal 回放、settled)上的同一个东西,renderActivityEvent()只是锚点的渲染器之一——两侧不允许出现两个独立归一器,谁先落地谁定义共享事件形状,另一方消费它。

终态:显式常量而非散落的字符串

锚点应显式承载终态:

终态含义
completed产出了最终助手答案并正常结算
cancelled用户停止了该回合
interrupted运行时/控制流在正常完成前打断了回合
no_response没有产出可用的助手最终内容
tool_limit_reached工具/重试/迭代上限结束了回合
compression_exhausted压缩无法腾出足够空间安全继续
connection_lost浏览器传输丢失,WebUI 无法确认结算状态
degradedWebUI 有部分证据但无法完整重建回合
error提供方、后端或回退失败状态

终态与最终答案文本分离:tool_limit_reached回合不得通过渲染一个合成的控制提示当作用户消息来表达;被取消的回合不同于提供方错误;stream_end帧不同于completed。Compact Worklog 可以在没有最终答案时把终态显示为状态卡片或最终答案的替代;Transparent Stream 应把同一终态真相显示为最后的时间线事件——两种模式不得对结果不一致。

实现上,终态被固化为常量TERMINAL_STATES(9 态齐全,static/assistant_turn_anchors.js),源别名经TERMINAL_STATE_ALIASES归一(done/completecompletedcancel/canceledcancelledapperror/failederrormax_iterationstool_limit_reached等,static/assistant_turn_anchors.js),normalizeAssistantTurnAnchorTerminalState负责归一化——正是 RFC 要求的"源别名在渲染器消费前归一到枚举"。

工件与副作用

agent 回合产出的不止散文和工具行:它可能创建文件、变更工作区、保存持久状态、更新侧面板、改变用量元数据或调度延续。这些结果即使不该全部出现在可读活动时间线里,也需要归属:

不是每个副作用都是活动事件,但每个副作用都必须有 Assistant Turn Anchor 拥有者,或一个刻意的会话级拥有者。

  • 工件与输出引用:生成的文件、工作区变更、导出、截图、报告、保存状态引用与交接输出,在归属已知时应挂到产出它们的助手回合;元数据缺失时渲染器应降级为链接、文件路径引用或工作区引用,而不是空白输出。
  • 侧面板与会话状态todo_state更新 Todos 面板(默认保持侧面板状态快照);metering更新用量与实时吞吐;title更新会话元数据;context_status更新 composer/上下文状态。它们不应为了"证明完整性"被塞进 Worklog,而应作为副作用或元数据被拥有,并通过各自的自然 UI 面恢复。
  • 控制与安全边界:approval、clarify、steer、interrupt、Stop-and-send 与延续投递会影响用户对回合的理解,可见时可挂为control_boundary事件。Pending-intent 控制 RFC(webui-pending-intent-controls.md)仍拥有这些用户意图的语义;本 RFC 只拥有它们可见边界的挂载、结算与回放位置。

与 RuntimeAdapter 的关系:缓冲层而非运行时

RuntimeAdapter 回答"谁拥有活跃执行、控制、持久运行时状态与可回放运行时事件";本 RFC 回答"WebUI 观测或派生出助手活动后,浏览器如何把它挂到一个助手回合上、渲染、结算、回放并切换显示模式,而不改变产品模型"。锚点不能决定是否让 agent 继续运行、不能成为审批/取消权威、不能凭空发明运行时状态——它只表达 WebUI 已观测、已派生、可展示、可重建的事实。

这种隔离是"现在就建锚点"的积极理由,而不只是要尊重的边界:live 渲染的底层基座正在通过默认关闭的 adapter 与 runner 接缝活跃迁移(#1925)。如果呈现层持续直接读基座,每一步迁移都冒着再来一轮 live/settled 修复的风险;如果它读锚点、锚点读 Artifact 1 信封,那么从 WebUI 运行路径切换到 runner 或 Hermes 运行时只改变锚点的输入来源,渲染器保持不动。锚点就是让运行时迁移能在 UI 之下落地而不重写 UI 的缓冲。

灰度计划与验收标准

RFC 把实施拆成七个阶段(顺序约束,而非每阶段一个 PR;结算、重建与显示模式变更应保持独立可评审):

  • Phase 0(RFC 与盘点):落地本 RFC 作为设计指引;盘点static/messages.js消费的 SSE 事件形状、static/ui.js消费的结算消息元数据、run journal 回放数据与既有event_id/seq信封;把每个源事件分类。
  • Phase 1(内部锚点脚手架):为活跃会话建立内部锚点注册表;在/api/chat/start成功并返回stream_id后创建锚点;从当前流/会话信号注水;可见 UI 保持不变;为身份、归属、陈旧流忽略、重连不重复建锚加测试。
  • Phase 2(归一化活动事件):加 live 事件归一化助手与结算消息元数据派生助手;把回放注水进同一锚点模型;Compact Worklog 保持唯一可见渲染器;加覆盖证明所有当前事件名都有显式分类。
  • Phase 2.5(锚点契约加固):渲染器呈现状态不进入语义锚点种子;导出终态常量并在 Phase 3 写入结算结果前归一源别名;文档化哪些INFLIGHT字段转为锚点所有、哪些保持回退缓存、哪些只是渲染器提示;定义final_message_ref为结算转录权威、final_answer为派生渲染快照;加 live + replay + settlement 对同一 run 的顺序无关覆盖。
  • Phase 3(经锚点结算):把done协调进现有锚点;在锚点上保留最终答案、reasoning、工具元数据、终态与用量;减少对整转录重建作为活跃回合语义边界的依赖;保留安全的回退全渲染路径。
  • Phase 4(回放/刷新重建):从 run journal、结算转录与INFLIGHT重建锚点;让会话切换、硬刷新与重连收敛到同一回合模型;证据不全时显示restoring/degraded而非空壳运行界面。
  • Phase 5(共享渲染策略接缝):Compact Worklog 与 Transparent Stream 都从归一化活动事件渲染;保证 live、settled、reload、replay 路径使用同一事件顺序。
  • Phase 6(工件与副作用):工件引用挂到锚点;state_saved、工作区变更、todo、用量元数据获得刻意归属;加固当前需要特判渲染的终态;仅当锚点/事件模型稳定后再考虑超长透明流的性能工作。

RFC 明确记录:截至 2026-07-16,核心 Phase 0–5 路径已落地,Phase 6 与下列加固项是后续工作(仍追踪于父 issue):用出处或事件身份替换呈现层的文本/前缀去回声启发式(同时不删除合法重复的模型输出);让更新的 journal/回放证据显式超越陈旧的INFLIGHT首绘状态;完成稳定run_id与传输stream_id的分离;完成 Phase 6 工件/副作用归属路径;只有在历史转录形状能够可靠注水锚点之后才移除每个遗留回退。

实现侧应当最终满足的验收标准:同一助手回合在 live、settled、reload、会话切换与重连中拥有相同归属;done更新现有回合而不是另造仅结算回合;两种显示模式读同一归一化输入;最终答案与终态分离;仅 live 的生命周期行不污染结算最终答案;工具/reasoning/控制/工件归属不依赖 DOM 存活;不完整重建显示restoring/degraded而非空运行壳;每个新事件都被分类;同一 run 的 replay/reconnect 在既有(session_id, event_id)去重环上幂等——live 与 replay 先后观测同一 run 时活动事件不丢不重;replay/reconnect 与结算对同一 run 顺序无关——无论回放事件先于还是后于结算助手消息到达,锚点收敛到同一最终答案引用、终态、用量元数据与活动事件列表。

实现 PR 评审清单

RFC 最后给实现 PR 留了一份评审清单,本质上把"归属权是否移动了"这一判据变成了可执行的检查项:

  • 这个 PR 改动了哪个状态层:事件源、锚点注册表、活动事件列表、S.messagesINFLIGHT、live DOM、run journal、结算会话元数据、侧面板还是渲染器?
  • 变更后的真相源是什么?
  • 是否引入或消费了新事件?若是,它如何分类?进入活动时间线、工件归属、副作用归属、元数据、传输处理还是显式排除?
  • 刷新/重连/会话切换能否重建同一个助手回合?
  • 回放会不会复制或丢失真实事件?陈旧流能否更新更新的可见回合?
  • 变更是否依赖 DOM 存在、可见文本、时间戳或模糊相似度作为语义事实?
  • done合并最终答案、用量与终态时是否避免了 Worklog/工具行重复?
  • Compact Worklog 与 Transparent Stream 对最终答案、终态、工具结果与控制边界是否仍然一致?
  • 工件与副作用即使不出现在 Worklog 里是否也有拥有者?
  • 是否影响 RuntimeAdapter 归属?
  • 什么测试或手工不变式能证明该行为?

结语

这份 RFC 的价值不在于新增一个数据结构,而在于把"live 与 settled 之间的接缝"从一个反复修补的 UI 问题,改写为一个有明确拥有者、可测试、可灰度的呈现层契约:Event Envelope 提供身份,run journal 提供回放证据,结算转录提供最终事实,INFLIGHT与 DOM 降级为缓存与渲染器,而锚点是让这五者不再互相打架的那个对象。仓库中 static/assistant_turn_anchors.js 的常量表、归一化函数、activity_scene_v1投影与对账器,以及 api/run_journal.py 的run_id:seq事件信封,共同构成了从 RFC 条款到可运行代码的完整证据链,也留下了 Phase 6 与身份/工件加固这些明确的后续切面。

【免费下载链接】hermes-webuiHermes WebUI: The best way to use Hermes Agent from the web or from your phone!项目地址: https://gitcode.com/GitHub_Trending/he/hermes-webui

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询