消息记录里的资源引用退化为纯文本(AGC 工作台对话) #641

Open
opened 2026-10-05 18:49:29 +08:00 by suzmii · 1 comment
Member

消息记录里的资源引用退化为纯文本(AGC 工作台对话)

  • 关联:#612(同一条链路:点选 → 插入引用 → 发送)。本 issue 是消息记录的展示/投影缺陷,独立于 #612 的 PR,不在 #612 里修。
  • 关联历史:#360「AGC DirectProject 引用无法持久化」已把用户消息落成结构化 canonical item;本 issue 是它的下游残留——引用在磁盘上留住了,但在消息记录里看不到。#443「纯文本反解析无法重建引用」与本 issue 的方案 B 是同一类取舍。
  • 影响面:所有把引用插进对话的入口(@ 候选、资源卡「引用」、拖拽批量引用、运行画面点选、附件导入)发出的消息,在**消息记录(用户气泡)**里的呈现。

一、现象

在 AGC 工作台对话里插入一枚引用芯片(如 @hero、点选生成的区域引用、$skill、附件)后发送,消息记录里这条消息变成普通文本气泡:

  • 引用芯片的样式 / 图标(@、$)/ 悬浮信息(title)都没有了,和用户手打的字没有区别;
  • 更差的一档:引用连名字都不出现(资源 / Skill / 附件引用整段没进气泡);
  • 只有运行画面区域引用会漏出命中元素的裸 innerText(DOM 档)——这正是「显示为普通文本」最直观的来源;
  • 边界:若这条消息只有引用、没有输入文字,投影出来的文本是空串,该条目在运行态与历史里都投影不出来(按投影代码推断,见下「结论」第 6 条,需真机复现确认)。

对照组:待发消息队列 chip 是对的——它按 directCodexContentToPromptText 展开成 @hero。也就是说同一句话在「还没发出去」时是 @hero 文本、发出去之后连 @hero 都没有,两处口径不一致。

(2026-10-05 核实)DirectProject 面的气泡里也不会出现 @hero:出站 content[] 里没有 token 文本(见「根因链 1」的 (b) 条),气泡文本只由 item_text 拼出——资源 / Skill / 附件引用一个字符都不进,运行区域只剩命中元素的 innerText。若在 DirectProject 面看到字面 @hero,来源只可能是策划链路(见「根因链 7」最后两行)。

二、根因链(逐段,带文件:行)

1. 输入区:引用怎么被插入 composer

  • 引用是 Lexical 的 DecoratorNode:ResourceReferenceNode extends DecoratorNode<ReactNode>(apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceNode.tsx:28),type = 'resource-reference'(:31-33),节点里保存整份 ChatReference(:48-54 的 exportJSON)。
  • 节点外观:decorate() 渲染 ResourceReferenceChip / AttachmentReferenceChip(ResourceReferenceNode.tsx:92-105)。芯片 DOM = @/$ 图标 + 显示名 + title 悬浮信息 + data-resource-reference-id / data-runtime-region-reference / data-skill-reference-name + 删除按钮(ResourceReferenceChip.tsx:29-70)。
  • 注意:节点把纯文本形态收成占位符——getTextContent() → OBJECT_REPLACEMENT(ResourceReferenceNode.tsx:76-78,注释明说「复制成纯文本时一个 chip 只留一个占位符,不把 @显示名 混进正文」)。
  • 插入入口(三条都落成同一个节点):
    • 候选菜单选中:selection.insertNodes([$createResourceReferenceNode(...), $createTextNode(' ')])(ResourceReferenceInput.tsx:1063-1066);
    • 跨面板「引用」/ 拖拽批量:insertReferences → $createResourceReferenceNode(reference) + $createTextNode(' ')(ResourceReferenceInput.tsx:462-475),事件由 App.tsx 收口、经 activeChatComposer.ts 的活跃输入区注册表转给当前挂载的输入区(见 docs/【功能说明】AGC聊天素材引用-2026-09-08.md 首节);
    • 粘贴 / 润色回填:$insertContentAtSelection / buildContentFromTextTokens(ResourceReferenceInput.tsx:356-395、:288-321)。
  • 草稿 → canonical content:collectDraftParts(ResourceReferenceInput.tsx:153-177)+ readResourceReferenceDraft(:209-215)。
  • (2026-10-05 核实,结论 = (b))出站 content[] 里没有任何 @名字 文本 part。 chip 节点只落 chatReferenceToContentPart(node.__reference)(ResourceReferenceInput.tsx:175-179),readResourceReferenceDraft 只回 projection.content(:209-215);前端用例断言的草稿 content 同样只有「引用 part + 分隔空格」(tests/resourceReferenceInput.test.tsx:780-786、:1008-1016)。
  • @hero 这份 token 文本确实存在,但它是读取时派生的投影(directCodexContentToPromptText / chatReferenceMentionToken,resourceReferences.ts:226-232、:341-349),只服务:待发队列 chip(pendingTurnChipLabel.ts:22-27)、润色出站文本、粘贴反解析、策划链路气泡文本、画布生成面板的字数与参考判据——不落进 content[]。
  • 因此 ResourceReferenceNode.tsx:73-74 那句「草稿文本由 collectDraftParts 用 @${reference.label} 拼装」已过期、不成立:它是 getTextContent() 从 @${label} 改成 OBJECT_REPLACEMENT 的同一次改动(commit cb76aadb4,2026-09-10)写下的,现行为与该句不符;同句后半「结构化引用直接取 __reference」是对的。该注释只能读成「描述 chip 的纯文本占位与结构化取值的出处」,不代表出站 payload 里有 token 文本。

2. 发送路径:消息怎么被构造

  • submit(content):userItemFromContent(content, 'direct-codex:<clientTurnId>:user') → invoke('enqueue_direct_codex_turn')(src/view/project-development/chat/controller/useDirectProjectChatController.ts:424-434,命令注释 :135;Rust 侧 src-tauri/src/agent/direct_runtime/user_input.rs:47-59)。
  • 出站 payload(最终字段名):UserItem = { type: 'message', role: 'user', content: UserContentPart[], id }(src/view/project-development/chat/generated/UserItem.ts、generated/UserMessageItem.ts;构造见 features/project-workspace/resourceReferences.ts:574-580)。
  • content[] 成员(Rust 是唯一 schema source,src-tauri/src/agent/direct_codex_user_item/model.rs:26-56,ts-rs 生成到 generated/UserContentPart.ts):
    • {type:'input_text', text}
    • {type:'agc_resource_reference', resourceId, resolvedText?}
    • {type:'agc_skill_reference', name}
    • {type:'agc_runtime_region_reference', label, runId, versionId, elementTag, elementRole, text, width, height, resourceIds}
    • {type:'agc_attachment_reference', name, mediaType, size, localPath, status}
  • 没有单独的 references / attachments / parts 字段:引用就是 content[] 里的 part(这是既有结构,不要新建平行系统)。发送前由 Rust freeze_direct_codex_user_item 给资源引用补一份 resolvedText 摘要(src-tauri/src/agent/direct_codex_user_item/wire.rs:23-63)。

3. 持久化 / 传输:消息落到哪

  • 放行时同时做两件事(src-tauri/src/agent/thread_manager/dispatch.rs:216-240):
    1. append_direct_project_user_message_at(&root, &canonical_user_item) —— 落盘;
    2. emit_direct_thread_user_item(&root, &canonical_user_item) —— 下发运行态条目。
  • 落盘形态:.agent/conversations/project.jsonl,一行一个信封 {"type":"response_item","payload":<canonical item>}(src-tauri/src/agent/direct_project_history.rs:20-21、:348-361)。payload 就是上面那份 canonical item,引用身份(resourceId / label / name / mediaType / status / 运行标识)原样在磁盘上。
  • 传给前端时被压成纯文本(这是丢东西的那一步):
    • 运行态:emit_direct_thread_user_item(src-tauri/src/agent/codex_app_server/mod.rs:830-845)→ direct_thread_event_item(:800-802)→ thread_item_from_value(src-tauri/src/agent/thread_manager/wire/items.rs:354)→ item_text(items.rs:199-222);
    • 历史:read_direct_project_history_slice(src-tauri/src/commands/desktop.rs:1072-1104)→ thread_items_from_history(items.rs:471-479)→ 同一个 thread_item_from_value;
    • 结果形状:ThreadItem 的 message 成员只有 text: string(generated/ThreadItem.ts,Rust 侧 items.rs:374-385);HistorySlice = { items: ThreadItem[], hasMore, firstItemId }(generated/HistorySlice.ts)——线上 DTO 里没有任何字段能装引用。
  • item_text 的具体口径(items.rs:199-222):先看条目顶层 text(canonical item 没有),否则把 content[] 里带字符串 text 键的 part 的 text 直接 join("")。于是:
    • input_text → 进(正文保留);
    • agc_resource_reference → 不进(它的字段叫 resolvedText,不叫 text);
    • agc_skill_reference → 不进(只有 name);
    • agc_attachment_reference → 不进(只有 name / mediaType / size / localPath / status);
    • agc_runtime_region_reference → 只有它的 text 字段进,而这个字段是「命中元素的 innerText(≤120 字)」(DOM 档,src-tauri/resources/preview/local-preview-fit.js:439-443、:465-469),引擎/画布档恒为 ''(:531-535);芯片真正的名字 label 不在里面。
  • agent / 后端不会回写用户消息:app-server 回显的用户条目由 direct_thread_visible_item + is_direct_project_codex_user_item 过滤(codex_app_server/mod.rs:813-817、direct_project_history.rs:497-508),用户气泡的唯一来源就是上面那条投影。

4. 历史渲染

  • 条目投影:projectDirectThreadItem 的 case 'message' 只读 item.text,且 !item.text.trim() 直接返回 null(不显示)(src/view/project-development/chat/conversation/directThreadItemProjection.ts:299-324)。
  • 回合/块模型:DirectChatBlock = { kind:'user'; key; text: string; at }(directTurnPresentation.ts:31-40;blockFromEntry 用 entry.text,:116-135)。
  • 渲染:DirectProjectTurn.renderBlock → <ChatMarkdownMessage role={role} text={block.text} />(components/DirectProjectConversation/DirectProjectTurn.tsx:138-165)。
  • 渲染器:ChatMarkdownMessage 对 role === 'user' 直接返回纯文本 <span className="whitespace-pre-wrap break-words">{text}</span>(src/components/ChatMarkdownMessage/index.tsx:359-360);props 只有 text: string(:16-21)——没有任何自定义节点 / 组件插槽。

5. 结论:丢在哪一步

  1. 丢在「Rust 条目 → 前端线上 DTO」的投影边界:items.rs:199-222 的 item_text 把 content[] 压成纯文本,判定键是 text;引用 part 的身份字段不叫 text,所以引用信息在跨到 UI 之前就没了。往下 ThreadItem.text(只有 string)与 ChatMarkdownMessage(role='user')(纯文本)再没有能力把它恢复。
  2. 引用身份本身没丢:磁盘上的 canonical item 保留 resourceId / label / name / status 等;给 agent 的 wire input 也按设计投影成安全摘要(wire.rs:127-152 运行区域摘要、:113-125 素材摘要、:170-260 折成 input)。所以这不是数据丢失,是 UI 呈现(与传输形状)缺陷;显示名还能用现有 resourceLabelResolver(manifest.assets) 按 resourceId 解析回来。
  3. 口径不一致的实证:前端注释写「chip 与用户气泡逐字一致」(components/DirectProjectComposer/pendingTurnChipLabel.ts:1-12),但气泡并不走这条派生,实际是 item_text。
  4. 边界后果(按投影代码推断,需真机复现确认):纯引用消息(content 只有引用 part + 分隔空格)→ item_text 得到空串 → items.rs:385 的 text? 让 thread_item_from_value 返回 None → 运行态不下发半条、历史 filter_map 直接丢掉 → 这条消息在消息记录里整条消失(顺带:失败说明按「本轮开口用户条目」归位,开口条目缺失时归属不成立)。
  5. 覆盖面空白:现有用例都用「已经压平好的 text」造条目(chat/controller/useDirectProjectChatController.test.tsx:17-19 直接给 text),Rust 侧也没有「AGC canonical user item → ThreadItem」的用例(thread_manager/wire/tests.rs:6-27 用的是纯 input_text),所以这条投影路径没有被任何用例守住。

6. 重载 / 切会话后的表现

一样,而且不是「重载后才退化」——运行态与历史走的是同一个 thread_item_from_value / item_text,所以发出那一刻就已经是纯文本;重新加载、切会话、切项目再回来读到的是同一份 project.jsonl,表现逐字相同。旧历史(canonical 化之前的文本拼接时期)在磁盘上本来就只有文本,任何修法都重建不出引用。

7. 同一问题在各引用类型上的结论

引用类型 / 入口 结论
@ 候选插入、资源卡「引用」、拖拽批量引用、画布引用(同一个 agc_resource_reference part) 气泡里整个不出现(不是 @名字 文本,是彻底没有);身份在磁盘上,UI 拿不到
$ Skill(agc_skill_reference) 气泡里整个不出现;给 agent 的 wire input 才投影成 $name + Codex 原生 skill 项,那条通道不受影响
附件(agc_attachment_reference) 气泡里整个不出现;芯片的 status / 失败态也随之不可见
运行画面区域点选(agc_runtime_region_reference) 只漏出 text(命中元素 innerText,引擎/画布档为空),芯片名 label 丢失——「显示为普通文本」最直接的来源
策划链路输入盒(PlanningChatView) 另一条路径、同样是纯文本:App.tsx:1791-1820 把 directCodexContentToLegacyContentDto(...).text(token 文本 @素材名)直接当用户气泡文本 setMessages({role:'user', text: canonicalPrompt}),再经 PlanningChatView.tsx:229-233 交给同一个纯文本渲染器——字面就是 @hero … 普通文本
画布生成面板 / 资源卡快速编辑 不是消息记录(是生成任务入参),输入区芯片正常;其出站文本走 directCodexContentToLegacyContentDto(resourceReferences.ts:609-680),不在本 issue 范围

三、方案(本轮只出方案,不动手实现)

前置事实:引用的唯一事实源已经是磁盘上的 canonical item(content[] 里的 AGC part),且 ThreadItem 是 ts-rs 生成的公开线上契约、前端是唯一消费方;聊天历史在项目本地 project.jsonl,不在 SpacetimeDB。

方案 A(推荐):让结构化引用随条目一起下发,消息记录渲染真芯片

  • Rust:thread_item_from_value 在 message 分支增加一个可选的 parts 投影(直接复用 content[] 里的 part,不新增映射语义),text 保留(复制、搜索、降级、无障碍都用它)。
  • 前端:DirectChatEntry / DirectChatBlock(user) 带上 parts;DirectProjectTurn.renderBlock 把 parts 渲染成「文本段 + 芯片」的行内序列(文本段仍走现有渲染);芯片复用 ResourceReferenceChip 的展示外观(@/$ 图标、显示名、title 悬浮信息),去掉编辑/删除按钮与 Lexical 依赖(拆成「展示壳」+「编辑壳」两层,输入区继续用后者)。
  • 显示名按现有口径解析:resourceLabelResolver(manifest.assets),解析不到退 resourceId(既有 unresolvedResourceReference 口径,resourceReferences.ts:588-606)。
  • 取舍:不需要回填历史;不新增平行 DTO;身份与名称都能恢复;但要动 ThreadItem(公开 DTO)并保证运行态与历史两条路同一投影。
  • 触及:ThreadItem(Rust + ts-rs 绑定的公开契约),属 DTO 变更 → 按仓库规范走规范驱动流程(跨模块 + 契约变化);不触及 SpacetimeDB schema。

方案 B:保留纯文本,但把文本换成可解析标记,由渲染器反解析成芯片

  • 做法:把 item_text 对引用 part 的投影换成与显示口径一致的 token(@显示名 / $名称 / @区域标签),渲染层识别 token 渲染芯片。
  • 取舍(硬伤多):用户手打的 @hero 与真引用无法区分(误判只能靠「是否命中当前候选」缓解,而候选缺失时又退化成文本);素材已删 / 改名 / 同名多候选时成不了芯片(仓库既有口径是「同名多候选一律按文本保留」);附件与运行区域没有候选、重建不出身份(文档已明确这两类不参与粘贴反解析);ChatMarkdownMessage 的 user 分支是纯文本渲染,仍要改渲染口径。
  • 结论:不宜作为唯一方案;可作为方案 A 的降级显示(只渲染芯片外观、不可点击、不带交互)。

方案 C:前端自己另存一份本轮 content,历史按 id 复原(不推荐)

  • 缺点:这是第二份事实源(违反仓库红线「正式业务状态以后端投影为准」「不新建平行系统」),重载 / 切会话 / 另一个窗口都拿不到,直接过不了验收判据。

旧消息兼容(三个方案都要回答)

不要求回填、不要求迁移。方案 A 下没有 parts 的历史条目退回现状(纯文本,不报错、不显示空芯片);若该引用的素材已从 manifest 删除,渲染成不可点击的占位芯片(显示 resourceId / 「已不在当前项目」),而不是静默丢掉。

四、验收判据

  1. 发送后(同一进程内):气泡里出现芯片——@/$ 图标 + 显示名 + title 悬浮信息,位置与编辑区一致,与正文混排不吞空格 / 换行;纯引用消息不再消失。
  2. 重新加载 / 切会话 / 切项目再回来:同一条消息仍渲染同样的芯片(从 project.jsonl 读回)。
  3. 另一端读到:同一项目目录被另一个窗口 / 客户端打开时同样渲染芯片(同一份 project.jsonl);同时给 agent 的 wire input 不回归(仍是 [素材引用 resourceId=…] / $name + skill 项 / 运行区域摘要)。
  4. 兼容:老历史(无 parts)退回纯文本,不报错、不显示空芯片;引用素材已删除时是占位芯片并给出原因。
  5. 复制 / 粘贴往返:从气泡复制出来的文本粘回输入区仍能重建芯片(现有解析口径不回归)。
  6. 无回归:resourceReferenceInput.test.tsx(43 例)、useDirectProjectChatController.test.tsx、tests/appSurface.test.ts、thread_manager / direct_codex_user_item 定向 Rust 用例。
  7. 补测试空白(本条是修复的前置条件):新增「canonical user item 带四类引用 → ThreadItem / 历史切片」的 Rust 用例,和「带引用的历史条目 → 气泡」的前端用例——现在这条路径一个用例都没有。

五、非目标

  • 不要求回填 / 迁移历史数据(旧消息保持纯文本);
  • 不把输入区换成富文本编辑器、不改 Lexical 节点模型与粘贴解析口径;
  • 不改给 agent 的 wire input 口径、不改 prompt 折叠(direct_codex_user_item_to_prompt);
  • 不改各类引用的契约字段与 Rust 校验口径(resourceIds 归属、数量上限、字段边界);
  • 本轮不做引用可点击跳转 / 悬浮卡 / 出处面板,只要求「样式 + 图标 + 悬浮信息」与输入区一致。

六、风险

  • 渲染安全:芯片文本(素材名 / 元素 innerText / 附件名)来源是文件系统与页面,必须当文本渲染;沿用它现有 {text} 路径,不引入 dangerouslySetInnerHTML。
  • 性能:长历史 + 流式回复会频繁重建块,parts 要在条目级派生并进入既有 memo 判据,不要给消息列表新增每帧依赖。
  • 向后兼容:ThreadItem 是 ts-rs 生成的公开契约,新增字段必须是可选且旧形状(无字段)不崩;旧前端 / 旧事件同源。
  • 一致性:运行态与历史必须同一投影,否则会出现「发出时有芯片、重载后变纯文本」的新不一致(比现在更难查)。
  • 规范:触及 DTO / 契约的改动按仓库规范走规范驱动流程;若后续决定把引用做成持久化结构(而不是投影字段),那属于 schema 变更,必须先问用户。
# 消息记录里的资源引用退化为纯文本(AGC 工作台对话) - 关联:#612(同一条链路:点选 → 插入引用 → 发送)。本 issue 是**消息记录的展示/投影**缺陷,独立于 #612 的 PR,**不在 #612 里修**。 - 关联历史:#360「AGC DirectProject 引用无法持久化」已把用户消息落成结构化 canonical item;本 issue 是它的下游残留——**引用在磁盘上留住了,但在消息记录里看不到**。#443「纯文本反解析无法重建引用」与本 issue 的方案 B 是同一类取舍。 - 影响面:所有把引用插进对话的入口(`@` 候选、资源卡「引用」、拖拽批量引用、运行画面点选、附件导入)发出的消息,在**消息记录(用户气泡)**里的呈现。 ## 一、现象 在 AGC 工作台对话里插入一枚引用芯片(如 `@hero`、点选生成的区域引用、`$skill`、附件)后发送,**消息记录里这条消息变成普通文本气泡**: - 引用芯片的样式 / 图标(`@`、`$`)/ 悬浮信息(`title`)都没有了,和用户手打的字没有区别; - 更差的一档:引用**连名字都不出现**(资源 / Skill / 附件引用整段没进气泡); - 只有运行画面区域引用会漏出**命中元素的裸 innerText**(DOM 档)——这正是「显示为普通文本」最直观的来源; - 边界:若这条消息**只有引用、没有输入文字**,投影出来的文本是空串,该条目在运行态与历史里都投影不出来(按投影代码推断,见下「结论」第 6 条,需真机复现确认)。 对照组:**待发消息队列 chip 是对的**——它按 `directCodexContentToPromptText` 展开成 `@hero`。也就是说同一句话在「还没发出去」时是 `@hero` 文本、发出去之后连 `@hero` 都没有,两处口径不一致。 **(2026-10-05 核实)DirectProject 面的气泡里也不会出现 `@hero`**:出站 `content[]` 里没有 token 文本(见「根因链 1」的 (b) 条),气泡文本只由 `item_text` 拼出——资源 / Skill / 附件引用一个字符都不进,运行区域只剩命中元素的 innerText。若在 DirectProject 面看到字面 `@hero`,来源只可能是策划链路(见「根因链 7」最后两行)。 ## 二、根因链(逐段,带文件:行) ### 1. 输入区:引用怎么被插入 composer - 引用是 Lexical 的 **DecoratorNode**:`ResourceReferenceNode extends DecoratorNode<ReactNode>`(`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceNode.tsx:28`),`type = 'resource-reference'`(`:31-33`),节点里保存**整份** `ChatReference`(`:48-54` 的 `exportJSON`)。 - 节点外观:`decorate()` 渲染 `ResourceReferenceChip` / `AttachmentReferenceChip`(`ResourceReferenceNode.tsx:92-105`)。芯片 DOM = `@`/`$` 图标 + 显示名 + `title` 悬浮信息 + `data-resource-reference-id` / `data-runtime-region-reference` / `data-skill-reference-name` + 删除按钮(`ResourceReferenceChip.tsx:29-70`)。 - **注意:节点把纯文本形态收成占位符**——`getTextContent() → OBJECT_REPLACEMENT`(`ResourceReferenceNode.tsx:76-78`,注释明说「复制成纯文本时一个 chip 只留一个占位符,不把 `@显示名` 混进正文」)。 - 插入入口(三条都落成同一个节点): - 候选菜单选中:`selection.insertNodes([$createResourceReferenceNode(...), $createTextNode(' ')])`(`ResourceReferenceInput.tsx:1063-1066`); - 跨面板「引用」/ 拖拽批量:`insertReferences` → `$createResourceReferenceNode(reference) + $createTextNode(' ')`(`ResourceReferenceInput.tsx:462-475`),事件由 `App.tsx` 收口、经 `activeChatComposer.ts` 的活跃输入区注册表转给当前挂载的输入区(见 `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` 首节); - 粘贴 / 润色回填:`$insertContentAtSelection` / `buildContentFromTextTokens`(`ResourceReferenceInput.tsx:356-395`、`:288-321`)。 - 草稿 → canonical content:`collectDraftParts`(`ResourceReferenceInput.tsx:153-177`)+ `readResourceReferenceDraft`(`:209-215`)。 - **(2026-10-05 核实,结论 = (b))出站 `content[]` 里没有任何 `@名字` 文本 part。** chip 节点只落 `chatReferenceToContentPart(node.__reference)`(`ResourceReferenceInput.tsx:175-179`),`readResourceReferenceDraft` 只回 `projection.content`(`:209-215`);前端用例断言的草稿 content 同样只有「引用 part + 分隔空格」(`tests/resourceReferenceInput.test.tsx:780-786`、`:1008-1016`)。 - `@hero` 这份 token 文本确实存在,但它是**读取时派生**的投影(`directCodexContentToPromptText` / `chatReferenceMentionToken`,`resourceReferences.ts:226-232`、`:341-349`),只服务:待发队列 chip(`pendingTurnChipLabel.ts:22-27`)、润色出站文本、粘贴反解析、策划链路气泡文本、画布生成面板的字数与参考判据——**不落进 `content[]`**。 - 因此 `ResourceReferenceNode.tsx:73-74` 那句「草稿文本由 `collectDraftParts` 用 `@${reference.label}` 拼装」**已过期、不成立**:它是 `getTextContent()` 从 `@${label}` 改成 `OBJECT_REPLACEMENT` 的同一次改动(commit `cb76aadb4`,2026-09-10)写下的,现行为与该句不符;同句后半「结构化引用直接取 `__reference`」是对的。该注释只能读成「描述 chip 的纯文本占位与结构化取值的出处」,**不代表出站 payload 里有 token 文本**。 ### 2. 发送路径:消息怎么被构造 - `submit(content)`:`userItemFromContent(content, 'direct-codex:<clientTurnId>:user')` → `invoke('enqueue_direct_codex_turn')`(`src/view/project-development/chat/controller/useDirectProjectChatController.ts:424-434`,命令注释 `:135`;Rust 侧 `src-tauri/src/agent/direct_runtime/user_input.rs:47-59`)。 - 出站 payload(**最终字段名**):`UserItem = { type: 'message', role: 'user', content: UserContentPart[], id }`(`src/view/project-development/chat/generated/UserItem.ts`、`generated/UserMessageItem.ts`;构造见 `features/project-workspace/resourceReferences.ts:574-580`)。 - `content[]` 成员(Rust 是唯一 schema source,`src-tauri/src/agent/direct_codex_user_item/model.rs:26-56`,ts-rs 生成到 `generated/UserContentPart.ts`): - `{type:'input_text', text}` - `{type:'agc_resource_reference', resourceId, resolvedText?}` - `{type:'agc_skill_reference', name}` - `{type:'agc_runtime_region_reference', label, runId, versionId, elementTag, elementRole, text, width, height, resourceIds}` - `{type:'agc_attachment_reference', name, mediaType, size, localPath, status}` - **没有**单独的 `references` / `attachments` / `parts` 字段:引用就是 `content[]` 里的 part(这是既有结构,不要新建平行系统)。发送前由 Rust `freeze_direct_codex_user_item` 给资源引用补一份 `resolvedText` 摘要(`src-tauri/src/agent/direct_codex_user_item/wire.rs:23-63`)。 ### 3. 持久化 / 传输:消息落到哪 - 放行时同时做两件事(`src-tauri/src/agent/thread_manager/dispatch.rs:216-240`): 1. `append_direct_project_user_message_at(&root, &canonical_user_item)` —— 落盘; 2. `emit_direct_thread_user_item(&root, &canonical_user_item)` —— 下发**运行态**条目。 - 落盘形态:`.agent/conversations/project.jsonl`,一行一个信封 `{"type":"response_item","payload":<canonical item>}`(`src-tauri/src/agent/direct_project_history.rs:20-21`、`:348-361`)。payload 就是上面那份 canonical item,**引用身份(`resourceId` / `label` / `name` / `mediaType` / `status` / 运行标识)原样在磁盘上**。 - 传给前端时被压成纯文本(这是丢东西的那一步): - 运行态:`emit_direct_thread_user_item`(`src-tauri/src/agent/codex_app_server/mod.rs:830-845`)→ `direct_thread_event_item`(`:800-802`)→ `thread_item_from_value`(`src-tauri/src/agent/thread_manager/wire/items.rs:354`)→ **`item_text`(`items.rs:199-222`)**; - 历史:`read_direct_project_history_slice`(`src-tauri/src/commands/desktop.rs:1072-1104`)→ `thread_items_from_history`(`items.rs:471-479`)→ 同一个 `thread_item_from_value`; - 结果形状:`ThreadItem` 的 message 成员**只有 `text: string`**(`generated/ThreadItem.ts`,Rust 侧 `items.rs:374-385`);`HistorySlice = { items: ThreadItem[], hasMore, firstItemId }`(`generated/HistorySlice.ts`)——**线上 DTO 里没有任何字段能装引用**。 - `item_text` 的具体口径(`items.rs:199-222`):先看条目顶层 `text`(canonical item 没有),否则把 `content[]` 里**带字符串 `text` 键**的 part 的 `text` 直接 `join("")`。于是: - `input_text` → 进(正文保留); - `agc_resource_reference` → **不进**(它的字段叫 `resolvedText`,不叫 `text`); - `agc_skill_reference` → **不进**(只有 `name`); - `agc_attachment_reference` → **不进**(只有 `name` / `mediaType` / `size` / `localPath` / `status`); - `agc_runtime_region_reference` → **只有它的 `text` 字段进**,而这个字段是「命中元素的 innerText(≤120 字)」(DOM 档,`src-tauri/resources/preview/local-preview-fit.js:439-443`、`:465-469`),引擎/画布档恒为 `''`(`:531-535`);芯片真正的名字 `label` **不在**里面。 - agent / 后端不会回写用户消息:app-server 回显的用户条目由 `direct_thread_visible_item` + `is_direct_project_codex_user_item` 过滤(`codex_app_server/mod.rs:813-817`、`direct_project_history.rs:497-508`),用户气泡的唯一来源就是上面那条投影。 ### 4. 历史渲染 - 条目投影:`projectDirectThreadItem` 的 `case 'message'` 只读 `item.text`,且 `!item.text.trim()` 直接返回 `null`(不显示)(`src/view/project-development/chat/conversation/directThreadItemProjection.ts:299-324`)。 - 回合/块模型:`DirectChatBlock = { kind:'user'; key; text: string; at }`(`directTurnPresentation.ts:31-40`;`blockFromEntry` 用 `entry.text`,`:116-135`)。 - 渲染:`DirectProjectTurn.renderBlock` → `<ChatMarkdownMessage role={role} text={block.text} />`(`components/DirectProjectConversation/DirectProjectTurn.tsx:138-165`)。 - 渲染器:`ChatMarkdownMessage` 对 `role === 'user'` **直接返回纯文本** `<span className="whitespace-pre-wrap break-words">{text}</span>`(`src/components/ChatMarkdownMessage/index.tsx:359-360`);props 只有 `text: string`(`:16-21`)——**没有任何自定义节点 / 组件插槽**。 ### 5. 结论:丢在哪一步 1. **丢在「Rust 条目 → 前端线上 DTO」的投影边界**:`items.rs:199-222` 的 `item_text` 把 `content[]` 压成纯文本,判定键是 `text`;引用 part 的身份字段不叫 `text`,所以引用信息在跨到 UI 之前就没了。往下 `ThreadItem.text`(只有 `string`)与 `ChatMarkdownMessage(role='user')`(纯文本)再没有能力把它恢复。 2. **引用身份本身没丢**:磁盘上的 canonical item 保留 `resourceId` / `label` / `name` / `status` 等;给 agent 的 wire input 也按设计投影成安全摘要(`wire.rs:127-152` 运行区域摘要、`:113-125` 素材摘要、`:170-260` 折成 `input`)。所以这**不是数据丢失,是 UI 呈现(与传输形状)缺陷**;显示名还能用现有 `resourceLabelResolver(manifest.assets)` 按 `resourceId` 解析回来。 3. 口径不一致的实证:前端注释写「chip 与用户气泡逐字一致」(`components/DirectProjectComposer/pendingTurnChipLabel.ts:1-12`),但气泡并不走这条派生,实际是 `item_text`。 4. 边界后果(**按投影代码推断,需真机复现确认**):纯引用消息(`content` 只有引用 part + 分隔空格)→ `item_text` 得到空串 → `items.rs:385` 的 `text?` 让 `thread_item_from_value` 返回 `None` → 运行态不下发半条、历史 `filter_map` 直接丢掉 → **这条消息在消息记录里整条消失**(顺带:失败说明按「本轮开口用户条目」归位,开口条目缺失时归属不成立)。 5. 覆盖面空白:现有用例都用「已经压平好的 text」造条目(`chat/controller/useDirectProjectChatController.test.tsx:17-19` 直接给 `text`),Rust 侧也没有「AGC canonical user item → ThreadItem」的用例(`thread_manager/wire/tests.rs:6-27` 用的是纯 `input_text`),所以这条投影路径**没有被任何用例守住**。 ### 6. 重载 / 切会话后的表现 **一样**,而且不是「重载后才退化」——运行态与历史走的是同一个 `thread_item_from_value` / `item_text`,所以**发出那一刻就已经是纯文本**;重新加载、切会话、切项目再回来读到的是同一份 `project.jsonl`,表现逐字相同。旧历史(canonical 化之前的文本拼接时期)在磁盘上本来就只有文本,任何修法都重建不出引用。 ### 7. 同一问题在各引用类型上的结论 | 引用类型 / 入口 | 结论 | | --- | --- | | `@` 候选插入、资源卡「引用」、拖拽批量引用、画布引用(同一个 `agc_resource_reference` part) | 气泡里**整个不出现**(不是 `@名字` 文本,是彻底没有);身份在磁盘上,UI 拿不到 | | `$` Skill(`agc_skill_reference`) | 气泡里**整个不出现**;给 agent 的 wire input 才投影成 `$name` + Codex 原生 `skill` 项,那条通道不受影响 | | 附件(`agc_attachment_reference`) | 气泡里**整个不出现**;芯片的 status / 失败态也随之不可见 | | 运行画面区域点选(`agc_runtime_region_reference`) | **只漏出 `text`(命中元素 innerText,引擎/画布档为空)**,芯片名 `label` 丢失——「显示为普通文本」最直接的来源 | | 策划链路输入盒(`PlanningChatView`) | 另一条路径、同样是纯文本:`App.tsx:1791-1820` 把 `directCodexContentToLegacyContentDto(...).text`(token 文本 `@素材名`)直接当用户气泡文本 `setMessages({role:'user', text: canonicalPrompt})`,再经 `PlanningChatView.tsx:229-233` 交给同一个纯文本渲染器——**字面就是 `@hero …` 普通文本** | | 画布生成面板 / 资源卡快速编辑 | 不是消息记录(是生成任务入参),输入区芯片正常;其出站文本走 `directCodexContentToLegacyContentDto`(`resourceReferences.ts:609-680`),不在本 issue 范围 | ## 三、方案(本轮只出方案,不动手实现) 前置事实:引用的**唯一事实源**已经是磁盘上的 canonical item(`content[]` 里的 AGC part),且 `ThreadItem` 是 ts-rs 生成的**公开线上契约**、前端是唯一消费方;聊天历史在项目本地 `project.jsonl`,**不在 SpacetimeDB**。 ### 方案 A(推荐):让结构化引用随条目一起下发,消息记录渲染真芯片 - Rust:`thread_item_from_value` 在 message 分支增加一个**可选**的 parts 投影(直接复用 `content[]` 里的 part,不新增映射语义),`text` 保留(复制、搜索、降级、无障碍都用它)。 - 前端:`DirectChatEntry` / `DirectChatBlock(user)` 带上 parts;`DirectProjectTurn.renderBlock` 把 parts 渲染成「文本段 + 芯片」的行内序列(文本段仍走现有渲染);芯片复用 `ResourceReferenceChip` 的展示外观(`@`/`$` 图标、显示名、`title` 悬浮信息),**去掉编辑/删除按钮与 Lexical 依赖**(拆成「展示壳」+「编辑壳」两层,输入区继续用后者)。 - 显示名按现有口径解析:`resourceLabelResolver(manifest.assets)`,解析不到退 `resourceId`(既有 `unresolvedResourceReference` 口径,`resourceReferences.ts:588-606`)。 - 取舍:不需要回填历史;不新增平行 DTO;身份与名称都能恢复;但要动 `ThreadItem`(公开 DTO)并保证运行态与历史**两条路同一投影**。 - 触及:**`ThreadItem`(Rust + ts-rs 绑定的公开契约)**,属 DTO 变更 → 按仓库规范走规范驱动流程(跨模块 + 契约变化);**不触及 SpacetimeDB schema**。 ### 方案 B:保留纯文本,但把文本换成可解析标记,由渲染器反解析成芯片 - 做法:把 `item_text` 对引用 part 的投影换成与显示口径一致的 token(`@显示名` / `$名称` / `@区域标签`),渲染层识别 token 渲染芯片。 - 取舍(硬伤多):用户手打的 `@hero` 与真引用**无法区分**(误判只能靠「是否命中当前候选」缓解,而候选缺失时又退化成文本);素材已删 / 改名 / 同名多候选时成不了芯片(仓库既有口径是「同名多候选一律按文本保留」);附件与运行区域**没有候选、重建不出身份**(文档已明确这两类不参与粘贴反解析);`ChatMarkdownMessage` 的 user 分支是纯文本渲染,仍要改渲染口径。 - 结论:**不宜作为唯一方案**;可作为方案 A 的降级显示(只渲染芯片外观、不可点击、不带交互)。 ### 方案 C:前端自己另存一份本轮 content,历史按 id 复原(不推荐) - 缺点:这是**第二份事实源**(违反仓库红线「正式业务状态以后端投影为准」「不新建平行系统」),重载 / 切会话 / 另一个窗口都拿不到,直接过不了验收判据。 ### 旧消息兼容(三个方案都要回答) 不要求回填、不要求迁移。方案 A 下没有 parts 的历史条目退回现状(纯文本,不报错、不显示空芯片);若该引用的素材已从 manifest 删除,渲染成不可点击的占位芯片(显示 `resourceId` / 「已不在当前项目」),而不是静默丢掉。 ## 四、验收判据 1. **发送后**(同一进程内):气泡里出现芯片——`@`/`$` 图标 + 显示名 + `title` 悬浮信息,位置与编辑区一致,与正文混排不吞空格 / 换行;**纯引用消息不再消失**。 2. **重新加载 / 切会话 / 切项目再回来**:同一条消息仍渲染同样的芯片(从 `project.jsonl` 读回)。 3. **另一端读到**:同一项目目录被另一个窗口 / 客户端打开时同样渲染芯片(同一份 `project.jsonl`);同时**给 agent 的 wire input 不回归**(仍是 `[素材引用 resourceId=…]` / `$name` + `skill` 项 / 运行区域摘要)。 4. **兼容**:老历史(无 parts)退回纯文本,不报错、不显示空芯片;引用素材已删除时是占位芯片并给出原因。 5. **复制 / 粘贴往返**:从气泡复制出来的文本粘回输入区仍能重建芯片(现有解析口径不回归)。 6. **无回归**:`resourceReferenceInput.test.tsx`(43 例)、`useDirectProjectChatController.test.tsx`、`tests/appSurface.test.ts`、`thread_manager` / `direct_codex_user_item` 定向 Rust 用例。 7. **补测试空白**(本条是修复的前置条件):新增「canonical user item 带四类引用 → ThreadItem / 历史切片」的 Rust 用例,和「带引用的历史条目 → 气泡」的前端用例——现在这条路径一个用例都没有。 ## 五、非目标 - 不要求回填 / 迁移历史数据(旧消息保持纯文本); - 不把输入区换成富文本编辑器、不改 Lexical 节点模型与粘贴解析口径; - 不改给 agent 的 wire input 口径、不改 prompt 折叠(`direct_codex_user_item_to_prompt`); - 不改各类引用的契约字段与 Rust 校验口径(`resourceIds` 归属、数量上限、字段边界); - 本轮不做引用可点击跳转 / 悬浮卡 / 出处面板,只要求「样式 + 图标 + 悬浮信息」与输入区一致。 ## 六、风险 - **渲染安全**:芯片文本(素材名 / 元素 innerText / 附件名)来源是文件系统与页面,必须当文本渲染;沿用它现有 `{text}` 路径,不引入 `dangerouslySetInnerHTML`。 - **性能**:长历史 + 流式回复会频繁重建块,parts 要在条目级派生并进入既有 memo 判据,不要给消息列表新增每帧依赖。 - **向后兼容**:`ThreadItem` 是 ts-rs 生成的公开契约,新增字段必须是可选且旧形状(无字段)不崩;旧前端 / 旧事件同源。 - **一致性**:运行态与历史**必须同一投影**,否则会出现「发出时有芯片、重载后变纯文本」的新不一致(比现在更难查)。 - **规范**:触及 DTO / 契约的改动按仓库规范走规范驱动流程;若后续决定把引用做成持久化结构(而不是投影字段),那属于 schema 变更,**必须先问用户**。
suzmii added the Kind/Bug
Priority
Medium
3
labels 2026-10-05 18:49:29 +08:00
suzmii self-assigned this 2026-10-05 18:49:29 +08:00
Author
Member

核实结论(2026-10-05,只读代码):(b) —— 出站 content[] 里没有 @名字 文本 part。

  • ResourceReferenceInput.tsx:175-179:chip 节点只 content.push(chatReferenceToContentPart(node.__reference)),不写 token;:209-215 readResourceReferenceDraft 只回 projection.content。
  • 前端用例断言的草稿 content 也只有「引用 part + 分隔空格」:tests/resourceReferenceInput.test.tsx:780-786、:1008-1016。
  • @hero 是读取时派生的投影(resourceReferences.ts:226-232、:341-349),只用于待发队列 chip(pendingTurnChipLabel.ts:22-27)、润色出站文本、粘贴反解析、策划链路气泡文本、画布生成面板判据。
  • ResourceReferenceNode.tsx:73-74 那句「草稿文本由 collectDraftParts 用 @${reference.label} 拼装」已过期:来自 getTextContent() 由 @${label} 改为 OBJECT_REPLACEMENT 的同一次改动(cb76aadb4,2026-09-10),只描述占位与结构化取值出处,不代表出站 payload。

已据此修正正文「根因链 1」并补一条现象说明:DirectProject 气泡里不会出现 @hero(item_text 只拼带 text 键的 part;items.rs:199-222),字面 @hero 只可能来自策划链路。正文「结论 4」的「纯引用消息可能整条不显示」保持标注为需真机复现确认。

核实结论(2026-10-05,只读代码):**(b)** —— 出站 `content[]` 里**没有** `@名字` 文本 part。 - `ResourceReferenceInput.tsx:175-179`:chip 节点只 `content.push(chatReferenceToContentPart(node.__reference))`,不写 token;`:209-215` `readResourceReferenceDraft` 只回 `projection.content`。 - 前端用例断言的草稿 content 也只有「引用 part + 分隔空格」:`tests/resourceReferenceInput.test.tsx:780-786`、`:1008-1016`。 - `@hero` 是**读取时派生**的投影(`resourceReferences.ts:226-232`、`:341-349`),只用于待发队列 chip(`pendingTurnChipLabel.ts:22-27`)、润色出站文本、粘贴反解析、策划链路气泡文本、画布生成面板判据。 - `ResourceReferenceNode.tsx:73-74` 那句「草稿文本由 `collectDraftParts` 用 `@${reference.label}` 拼装」**已过期**:来自 `getTextContent()` 由 `@${label}` 改为 `OBJECT_REPLACEMENT` 的同一次改动(`cb76aadb4`,2026-09-10),只描述占位与结构化取值出处,不代表出站 payload。 已据此修正正文「根因链 1」并补一条现象说明:DirectProject 气泡里不会出现 `@hero`(`item_text` 只拼带 `text` 键的 part;`items.rs:199-222`),字面 `@hero` 只可能来自策划链路。正文「结论 4」的「纯引用消息可能整条不显示」保持标注为需真机复现确认。
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: GenarrativeAI/Genarrative#641