消息记录里的资源引用退化为纯文本(AGC 工作台对话) #641
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
消息记录里的资源引用退化为纯文本(AGC 工作台对话)
@候选、资源卡「引用」、拖拽批量引用、运行画面点选、附件导入)发出的消息,在**消息记录(用户气泡)**里的呈现。一、现象
在 AGC 工作台对话里插入一枚引用芯片(如
@hero、点选生成的区域引用、$skill、附件)后发送,消息记录里这条消息变成普通文本气泡:@、$)/ 悬浮信息(title)都没有了,和用户手打的字没有区别;对照组:待发消息队列 chip 是对的——它按
directCodexContentToPromptText展开成@hero。也就是说同一句话在「还没发出去」时是@hero文本、发出去之后连@hero都没有,两处口径不一致。(2026-10-05 核实)DirectProject 面的气泡里也不会出现
@hero:出站content[]里没有 token 文本(见「根因链 1」的 (b) 条),气泡文本只由item_text拼出——资源 / Skill / 附件引用一个字符都不进,运行区域只剩命中元素的 innerText。若在 DirectProject 面看到字面@hero,来源只可能是策划链路(见「根因链 7」最后两行)。二、根因链(逐段,带文件:行)
1. 输入区:引用怎么被插入 composer
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)。collectDraftParts(ResourceReferenceInput.tsx:153-177)+readResourceReferenceDraft(:209-215)。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的同一次改动(commitcb76aadb4,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)。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(这是既有结构,不要新建平行系统)。发送前由 Rustfreeze_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):append_direct_project_user_message_at(&root, &canonical_user_item)—— 落盘;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不在里面。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. 结论:丢在哪一步
items.rs:199-222的item_text把content[]压成纯文本,判定键是text;引用 part 的身份字段不叫text,所以引用信息在跨到 UI 之前就没了。往下ThreadItem.text(只有string)与ChatMarkdownMessage(role='user')(纯文本)再没有能力把它恢复。resourceId/label/name/status等;给 agent 的 wire input 也按设计投影成安全摘要(wire.rs:127-152运行区域摘要、:113-125素材摘要、:170-260折成input)。所以这不是数据丢失,是 UI 呈现(与传输形状)缺陷;显示名还能用现有resourceLabelResolver(manifest.assets)按resourceId解析回来。components/DirectProjectComposer/pendingTurnChipLabel.ts:1-12),但气泡并不走这条派生,实际是item_text。content只有引用 part + 分隔空格)→item_text得到空串 →items.rs:385的text?让thread_item_from_value返回None→ 运行态不下发半条、历史filter_map直接丢掉 → 这条消息在消息记录里整条消失(顺带:失败说明按「本轮开口用户条目」归位,开口条目缺失时归属不成立)。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_referencepart)@名字文本,是彻底没有);身份在磁盘上,UI 拿不到$Skill(agc_skill_reference)$name+ Codex 原生skill项,那条通道不受影响agc_attachment_reference)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(推荐):让结构化引用随条目一起下发,消息记录渲染真芯片
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)。ThreadItem(公开 DTO)并保证运行态与历史两条路同一投影。ThreadItem(Rust + ts-rs 绑定的公开契约),属 DTO 变更 → 按仓库规范走规范驱动流程(跨模块 + 契约变化);不触及 SpacetimeDB schema。方案 B:保留纯文本,但把文本换成可解析标记,由渲染器反解析成芯片
item_text对引用 part 的投影换成与显示口径一致的 token(@显示名/$名称/@区域标签),渲染层识别 token 渲染芯片。@hero与真引用无法区分(误判只能靠「是否命中当前候选」缓解,而候选缺失时又退化成文本);素材已删 / 改名 / 同名多候选时成不了芯片(仓库既有口径是「同名多候选一律按文本保留」);附件与运行区域没有候选、重建不出身份(文档已明确这两类不参与粘贴反解析);ChatMarkdownMessage的 user 分支是纯文本渲染,仍要改渲染口径。方案 C:前端自己另存一份本轮 content,历史按 id 复原(不推荐)
旧消息兼容(三个方案都要回答)
不要求回填、不要求迁移。方案 A 下没有 parts 的历史条目退回现状(纯文本,不报错、不显示空芯片);若该引用的素材已从 manifest 删除,渲染成不可点击的占位芯片(显示
resourceId/ 「已不在当前项目」),而不是静默丢掉。四、验收判据
@/$图标 + 显示名 +title悬浮信息,位置与编辑区一致,与正文混排不吞空格 / 换行;纯引用消息不再消失。project.jsonl读回)。project.jsonl);同时给 agent 的 wire input 不回归(仍是[素材引用 resourceId=…]/$name+skill项 / 运行区域摘要)。resourceReferenceInput.test.tsx(43 例)、useDirectProjectChatController.test.tsx、tests/appSurface.test.ts、thread_manager/direct_codex_user_item定向 Rust 用例。五、非目标
direct_codex_user_item_to_prompt);resourceIds归属、数量上限、字段边界);六、风险
{text}路径,不引入dangerouslySetInnerHTML。ThreadItem是 ts-rs 生成的公开契约,新增字段必须是可选且旧形状(无字段)不崩;旧前端 / 旧事件同源。核实结论(2026-10-05,只读代码):(b) —— 出站
content[]里没有@名字文本 part。ResourceReferenceInput.tsx:175-179:chip 节点只content.push(chatReferenceToContentPart(node.__reference)),不写 token;:209-215readResourceReferenceDraft只回projection.content。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」的「纯引用消息可能整条不显示」保持标注为需真机复现确认。