From 28a6fd97f8fcae6556c7924f5eb9c19fbd05662d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 13:11:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=B6=A6=E8=89=B2?= =?UTF-8?q?=E5=9B=9E=E5=86=99=E4=B8=8E=E7=B2=98=E8=B4=B4=E5=85=B1=E7=94=A8?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=9D=A1=E8=90=BD=E7=82=B9=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=8C=E8=AE=A4=E4=B8=8D=E5=87=BA=E7=9A=84=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E8=90=BD=E6=96=87=E6=9C=AC=E8=80=8C=E4=B8=8D=E6=98=AF=E6=B6=88?= =?UTF-8?q?=E5=A4=B1=20ResourceReferenceInput=20=E6=96=B0=E5=A2=9E=20menti?= =?UTF-8?q?onTokenOrText=EF=BC=9Aprovider=20=E7=9A=84=20mentionToken?= =?UTF-8?q?=EF=BC=8C=E7=AD=94=E4=B8=8D=E5=87=BA=E6=9D=A5=EF=BC=88=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E8=A2=AB=E7=A0=B4=E5=9D=8F=EF=BC=89=E6=97=B6=E9=80=80?= =?UTF-8?q?=20contentPartText=20=E7=9A=84=E9=80=9A=E7=94=A8=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=BD=A2=E6=80=81=EF=BC=9B=E7=B2=98=E8=B4=B4=E6=8F=92?= =?UTF-8?q?=E5=85=A5=E3=80=81=E6=B6=A6=E8=89=B2=E5=9B=9E=E5=86=99=E7=9A=84?= =?UTF-8?q?=E5=80=99=E9=80=89=E6=89=AB=E6=8F=8F=E3=80=81=E6=95=B4=E6=A0=B9?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E7=9A=84=E8=90=BD=E7=82=B9=E5=85=B1=E7=94=A8?= =?UTF-8?q?=E8=BF=99=E4=B8=80=E6=9D=A1=20=E6=B6=A6=E8=89=B2=E5=9B=9E?= =?UTF-8?q?=E5=86=99=E7=9A=84=E5=80=99=E9=80=89=E4=B8=8D=E5=86=8D=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=E3=80=8C=E7=AD=94=E4=B8=8D=E5=87=BA=20token=20?= =?UTF-8?q?=E7=9A=84=20part=E3=80=8D=EF=BC=9A=E8=BF=99=E7=B1=BB=20part=20?= =?UTF-8?q?=E5=9C=A8=E5=9B=9E=E5=8C=85=E9=87=8C=E6=B2=A1=E8=A2=AB=E6=8F=90?= =?UTF-8?q?=E5=88=B0=E6=97=B6=E4=BD=9C=E4=B8=BA=E6=9C=AB=E5=B0=BE=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E8=A1=A5=E5=9B=9E=E6=9D=A5=EF=BC=8C=E8=80=8C=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=E5=9C=A8=E7=BF=BB=E8=AF=91=E9=98=B6=E6=AE=B5=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E6=B6=88=E5=A4=B1=20applyContentToRoot=EF=BC=88?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E8=8D=89=E7=A8=BF=E4=B8=8E=E6=B6=A6=E8=89=B2?= =?UTF-8?q?=E5=9B=9E=E5=86=99=E5=85=B1=E7=94=A8=EF=BC=89=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E4=B8=8D=E5=87=BA=E5=BC=95=E7=94=A8=E6=97=B6=E8=90=BD=E6=96=87?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=98=AF=E6=95=B4=E6=9D=A1?= =?UTF-8?q?=E5=90=83=E6=8E=89=20=E6=96=B0=E5=A2=9E=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8C=BA=E7=94=A8=E4=BE=8B=EF=BC=9Aprovider?= =?UTF-8?q?=20=E5=8F=AA=E7=AD=94=E5=BE=97=E5=87=BA=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E7=AD=94=E4=B8=8D=E5=87=BA=20token=20=E6=97=B6=E6=B6=A6?= =?UTF-8?q?=E8=89=B2=E5=9B=9E=E5=86=99=E4=BB=8D=E4=BF=9D=E4=BD=8F=E5=BC=95?= =?UTF-8?q?=E7=94=A8=EF=BC=9B=E5=88=9D=E5=A7=8B=E8=8D=89=E7=A8=BF=E9=87=8C?= =?UTF-8?q?=E5=B7=B2=E8=A7=A3=E6=9E=90=E4=B8=8D=E5=87=BA=E7=9A=84=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=90=BD=E6=88=90=20@resourceId=20=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E3=80=82=E4=B8=A4=E5=A4=84=E5=8E=BB=E6=8E=89=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E5=8D=B3=E5=8F=98=E7=BA=A2=20=E5=90=8C=E6=AD=A5=20AGC=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E7=B4=A0=E6=9D=90=E5=BC=95=E7=94=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E8=AF=B4=E6=98=8E=E3=80=81=E5=BC=95=E7=94=A8=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E8=A7=A3=E6=9E=90=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E4=B8=8E=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95=EF=BC=88=E8=90=BD?= =?UTF-8?q?=E7=82=B9=E5=85=9C=E5=BA=95=E4=B8=80=E6=9D=A1=EF=BC=8C=E5=90=AB?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E8=8D=89=E7=A8=BF=E8=A1=8C=E4=B8=BA=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E7=9A=84=E8=AE=B0=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResourceReferenceInput.tsx | 26 ++++++-- .../tests/resourceReferenceInput.test.tsx | 62 +++++++++++++++++++ .../【实施计划】引用粘贴解析-2026-09-22.md | 1 + .../shared-memory/decision-log.md | 1 + .../【功能说明】AGC聊天素材引用-2026-09-08.md | 2 +- 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 877747834..9abe553e0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -237,6 +237,20 @@ function mentionTokenFromPart( return null; } +/** + * part 落进正文时用的文本:provider 的 `mentionToken`,它答不出来(provider 契约被破坏)时退到 + * `contentPartText` 的通用文本形态。文本 part 自己就是文本,返回 `null`。 + * + * 粘贴插入、润色回写的候选扫描与落点都走这一条:只要 part 不是文本,就一定有一段正常文本可落, + * 「认不出的引用走文本、绝不静默丢」在几个入口是同一份实现,不是各写一遍兜底。 + */ +function mentionTokenOrText( + providers: readonly ReferenceProvider[], + part: DirectCodexUserContentPart, +): string | null { + return mentionTokenFromPart(providers, part) ?? contentPartText(part); +} + /** * 草稿的展示文本:把每个引用 part 按注入的 provider 展开成它的 token,其余文本逐字保留。 * @@ -267,8 +281,10 @@ function applyPolishedTextToRoot( ) { // 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描, // 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。 + // provider 答不出 token 的 part 也照样进候选(token 退到 `mentionTokenOrText` 的通用文本形态): + // 它在回包里没被提到时会作为末尾孤儿补回来,而不是从这门翻译里直接消失。 const candidates = current.content.flatMap((part) => { - const token = mentionTokenFromPart(providers, part); + const token = mentionTokenOrText(providers, part); return token ? [{ token, part }] : []; }); applyContentToRoot(buildContentFromTextTokens(value, candidates), providers); @@ -297,7 +313,11 @@ function applyContentToRoot( const reference = referenceFromPart(providers, part); if (reference) { paragraph.append($createResourceReferenceNode(reference)); + return; } + // 解析不出引用的 part 落它的文本(与粘贴同一条兜底链),整根替换同样不许把内容吃掉。 + const token = mentionTokenOrText(providers, part); + if (token) paragraph.append($createTextNode(token)); }); } @@ -361,9 +381,7 @@ function $insertContentAtSelection( return; } // 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。 - // provider 连 token 都答不出来(契约被破坏)时落它的通用文本形态,绝不留空。 - const token = - mentionTokenFromPart(providers, part) ?? contentPartText(part); + const token = mentionTokenOrText(providers, part); if (token) { $selectionOrRootEnd()?.insertText(token); inserted = true; diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 0a62f8041..83b43c78d 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -864,6 +864,68 @@ describe('ResourceReferenceInput', () => { ]); }); + test('润色回写时 provider 答不出 token 的 part 仍留在草稿里,不在这门翻译里消失', async () => { + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); + // provider 契约被破坏:解析得出引用,但答不出 token。它仍然必须活过这一轮润色回写。 + const tokenlessProvider: ReferenceProvider = { + trigger: '@', + fuzzy_lookup: () => [], + lookup: () => [reference], + toReference: (part) => + part.type === 'agc_resource_reference' ? reference : null, + refresh: (part) => part, + mentionToken: () => null, + }; + const composerRef = createRef(); + render( + <> + + + , + ); + await settleComposer(); + + fireEvent.click(screen.getByRole('button', { name: '模拟润色' })); + await settleComposer(); + expect(draftResourceIds(composerRef.current?.getDraft())).toEqual(['hero']); + expect(draftText(composerRef.current?.getDraft())).toBe( + '润色后的需求 @hero ', + ); + }); + + test('初始草稿里解析不出的引用落文本形态,不从草稿里消失', async () => { + const composerRef = createRef(); + render( + , + ); + await settleComposer(); + expect(composerRef.current?.getDraft().content).toEqual([ + { type: 'input_text', text: '@missing-asset' }, + ]); + }); + test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md index bb8f0c2c1..c90010981 100644 --- a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -60,3 +60,4 @@ - 粘贴接管改成「插入真的发生之后」才 `preventDefault`:`$insertContentAtSelection` 返回「插进去没有」,插入为空时放行默认粘贴;编辑器已在更新中(回调被排队)时按原口径先接管(提交 `3391ecf7d`)。 - 插入兜底不再留空:provider 的 `toReference` 与 `mentionToken` 都答不出来的 part 退到新增的 `contentPartText`(通用文本形态,资源落 `@resourceId`)。此前这一支会什么都不插,是「粘贴内容逐字保留」唯一的例外分支;现在没有例外。新增单测覆盖四类 part 的文本形态,新增输入区集成用例证明被破坏的 provider 契约下这段粘贴仍按文本落下,去掉兜底即变红。 - 归一化撞名(`hero v2` 与 `hero-v2` 折成同一个 token)只在解析侧兜住、候选菜单不提示冲突,这一条涉及「哪些素材能被 @ 到」的产品取舍,未改代码,留给下一轮决定。 +- 润色回写与粘贴共用同一条落点兜底:新增 `mentionTokenOrText`(provider 的 `mentionToken`,拿不到就退 `contentPartText` 的通用文本形态),润色回写的候选扫描、整根替换的落点与粘贴插入的兜底都走它。由此润色回写不再有「provider 答不出 token 的 part 直接消失」和「整根替换时 part 解析不出引用就整条吃掉」两条静默丢弃路径;初始草稿(`applyContent`)与润色回写(`applyPolishedTextToRoot`)共用 `applyContentToRoot`,所以恢复出来的草稿里已解析不出的引用现在落成 `@resourceId` 文本而不是被吃掉。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 494a01517..0fd7d8357 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6,6 +6,7 @@ - 决策:把不变量前移到引用名——`@显示名` / `$名称` / `@附件名` 的名字内部不允许空白,统一经共享 `normalizeMentionName(value)`(内部空白折成 `-`、裁掉首尾)处理。落点是名字的产生处:`resourceDisplayName()`、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`,外加两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)——token 层幂等再折一次,「token 里没有空白」就是不变量本身的性质,不依赖上游数据干净。 - 决策(不兜底):引用名假定非空,不做 `resourceId` 之类的兜底;`resourceDisplayName` 原来的 `|| asset.id` 一并去掉。 - 校正(2026-09-23,评审项):`resourceDisplayName` 的空名字兜底不能一并去掉——整名就是扩展名时(`.env` / `.gitignore`)去掉扩展名得到空串,显示名成了空串,token 退化成只有触发符的裸 `@`(候选菜单里是空芯片,粘贴解析还会认领正文里任何一处裸 `@`)。改为词干为空时回退 `asset.id`(仍过 `normalizeMentionName`);`normalizeMentionName` 自己没有兜底、只做归一化这条不变。 +- 落点兜底(2026-09-23,评审项):part 落进正文的兜底链统一为 `mentionTokenOrText`(provider 的 `mentionToken`,拿不到就退 `contentPartText` 的通用文本形态,即 `@resourceId` / `$名称` / `@附件名` / `@区域标签`)。粘贴插入、润色回写的候选扫描与整根替换共用它,删掉两处静默丢弃路径(provider 答不出 token 的 part 在润色翻译里消失;整根替换时解析不出引用的 part 被吃掉)。副作用是恢复出来的初始草稿里已解析不出的引用落成 `@resourceId` 文本而不是消失——宁可留文本,也不让内容凭空少一段。 - 原因:不改反解析是因为粘贴解析与润色回包共用 `buildContentFromTextTokens`,改匹配算法要冒回归润色的风险;而「名字里带空白的 token」本来就无法手敲(候选触发器 `allowWhitespace: false`,空格处菜单就关),显示口径与输入口径早就不一致。折成 `-` 之后 token 自带边界:`@hero` 不会命中 `@hero-v2`(后一个字符是 `-`,不是空白),前缀重叠不可能再发生。 - 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/{resourceReferences.ts,reference-source/{skillReferenceProvider.ts,attachmentReferenceProvider.ts}}`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts`、`apps/ai-game-creator-shell/tests/{resourceReferences.test.ts,referenceSourceProviders.test.ts}`、`CONTEXT.md`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md`。 - 代价(已接受):归一化可能撞名(`hero v2` 与 `hero-v2` 同名),走既有的「同名多候选一律按文本保留」——不认错,但两者都成不了芯片;改动前生成的旧文本(历史回合 prompt、旧气泡)里的 `@hero v2` 不再解析,重试 / 润色回填时那条引用会退化成末尾孤儿(内容不丢、位置可能不对)。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index ea9048a42..fcff22137 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -40,7 +40,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。 - 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。 - 只有真的解析出引用、并且这一整段真的插进了正文时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。取不到选区时插入为空,这时也放行默认粘贴,粘贴的文字不会「两边都不管」。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 -- 插入那一刻解析不出引用时不留空:先退回 provider 的 token(`@显示名` / `$名称`),provider 连 token 都答不出来(契约被破坏)时退到 `contentPartText` 的通用文本形态——资源拿不到显示名就落 `@resourceId`,Skill / 附件 / 运行区域各落自己的名字或标签。所以「粘贴进来的内容一个字符都不会凭空消失」在这条链路上没有例外分支。 +- part 落进正文时解析不出引用也不留空:先退回 provider 的 `mentionToken`(`@显示名` / `$名称`),provider 连 token 都答不出来(契约被破坏)时退到 `contentPartText` 的通用文本形态——资源拿不到显示名就落 `@resourceId`,Skill / 附件 / 运行区域各落自己的名字或标签。粘贴就地插入与润色回写的整根替换共用这一条兜底链(`mentionTokenOrText`),所以「认不出的引用走文本、绝不静默丢」没有例外分支。 - 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。 - Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。