From 6950e08506da4f8d7f29bc9a2ae18473db7f23b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 18:52:24 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=BE=93=E5=85=A5=E5=8C=BA=E6=94=AF=E6=8C=81=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E8=A7=A3=E6=9E=90=20@=E6=98=BE=E7=A4=BA=E5=90=8D=20/?= =?UTF-8?q?=20$=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 buildContentFromPastedText:粘贴文本按显示口径反解析,同名多候选与未命中 token 一律按文本保留 ReferenceProvider 增加可选 candidates 与 onPasteText:候选枚举保持纯函数,Skill 目录冷启动时补读一次 资源与 Skill provider 落地新缝,静默 provider(附件 / 运行画面区域)不参与粘贴解析 输入区在 CRITICAL 优先级接管 PASTE_COMMAND,仅解析出引用时插入并打 PASTE_TAG,一次 Ctrl+Z 整体回退 补充粘贴解析规则矩阵、provider 候选枚举与补读、输入区粘贴集成用例 同步 ADR 修订、实施计划、术语表、功能说明与决策记录 --- CONTEXT.md | 9 + .../ResourceReferenceInput.tsx | 92 ++++++++ .../resourceReferenceProvider.ts | 2 + .../skillReferenceProvider.ts | 48 +++-- .../reference-source/types.ts | 18 ++ .../project-workspace/resourceReferences.ts | 62 ++++++ .../tests/referenceSourceProviders.test.ts | 82 ++++++++ .../tests/resourceReferenceInput.test.tsx | 199 ++++++++++++++++++ .../tests/resourceReferences.test.ts | 106 ++++++++++ .../【ADR】引用候选由宿主注入-2026-09-22.md | 11 + .../【实施计划】引用粘贴解析-2026-09-22.md | 43 ++++ .../shared-memory/decision-log.md | 12 ++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 10 + 13 files changed, 678 insertions(+), 16 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md diff --git a/CONTEXT.md b/CONTEXT.md index 59acab2ff..8755820a6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -210,6 +210,14 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内 引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。 _Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段 +**引用候选枚举**: +一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。 +_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单 + +**引用粘贴解析**: +把粘贴进来的纯文本按引用文本语法反解析回正文引用;只有身份唯一且逐字确认的 token 才成为引用,其余按原文保留。 +_Avoid_: 猜文件名或路径、为不确定的 token 挑一个候选、改写用户粘贴的其余文字 + **引用输入区**: 只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。 _Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部 @@ -240,6 +248,7 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附 - **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。 - **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。 - 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。 +- **引用粘贴解析** 与出站显示是同一条 **引用文本语法** 的两端;**静默 provider** 的 token(`@附件名` / `@区域标签`)没有候选,因此粘贴时不重建。 - **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。 ## Example dialogue 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 ad0cb2396..1ca5ded23 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 @@ -16,10 +16,13 @@ import { $isLineBreakNode, $isRangeSelection, $isTextNode, + COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_HIGH, type EditorState, KEY_ENTER_COMMAND, type LexicalNode, + PASTE_COMMAND, + PASTE_TAG, type TextNode, } from 'lexical'; import { Loader2, RotateCcw, Sparkles } from 'lucide-react'; @@ -53,6 +56,7 @@ import { ResourceReferenceNode, } from './ResourceReferenceNode'; import { + buildContentFromPastedText, buildContentFromTextTokens, type ChatComposerDraft, type ChatReference, @@ -65,6 +69,12 @@ import { } from './resourceReferences'; import { usePromptPolish } from './usePromptPolish'; +/** + * Lexical 自己的剪贴板负载(导入优先级最高的一条):带着它复制粘贴时,真 chip 会被原样还原, + * 所以粘贴解析要让位给默认导入。 + */ +const LEXICAL_EDITOR_CLIPBOARD_TYPE = 'application/x-lexical-editor'; + type ResourceReferenceInputProps = { onChange?: (draft: ChatComposerDraft) => void; onEditorStateChange?: (editorState: EditorState) => void; @@ -290,6 +300,48 @@ function applyContentToRoot( }); } +/** + * 粘贴插入:在光标处就地插入 content 对应的节点,正文其余部分逐字不动。 + * + * 与 `applyContentToRoot`(整根替换,供初始草稿与润色回写使用)的区别只在替换范围: + * 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成 + * chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。 + */ +function $insertContentAtSelection( + content: readonly DirectCodexUserContentPart[], + providers: readonly ReferenceProvider[], +) { + // 与 insertReferences 同一处置:选区可能指向已被重建掉的节点,统一回落到草稿末尾。 + // 每插一段都重新取一次选区:插入会移动光标,缓存下来的那个 RangeSelection 会过期。 + const currentSelection = () => { + let selection = $getSelection(); + if ( + !$isRangeSelection(selection) || + !selection.anchor.getNode().isAttached() + ) { + $getRoot().selectEnd(); + selection = $getSelection(); + } + return $isRangeSelection(selection) ? selection : null; + }; + if (!currentSelection()) return; + content.forEach((part) => { + if (part.type === 'input_text') { + part.text.split('\n').forEach((line, index) => { + if (index > 0) currentSelection()?.insertParagraph(); + if (line) currentSelection()?.insertText(line); + }); + return; + } + const reference = referenceFromPart(providers, part); + if (reference) { + currentSelection()?.insertNodes([ + $createResourceReferenceNode(reference), + ]); + } + }); +} + /** * 引用身份刷新(资源改名等):每个节点问第一个认得它的 provider 要新引用, * 拿到不同实例就换成新节点。providers 原样返回表示无需改写。 @@ -522,6 +574,46 @@ function ResourceReferenceEditor({ ); }, [editor, multiline]); + // 粘贴解析:只有「粘贴文本里真的解析出了引用 token」时才接管,其余一律返回 false 放行 + // Lexical 的默认导入——同 namespace 复制出来的 Lexical payload 本来就能还原真 chip, + // 不含 token 的纯文本、图片文件粘贴也都保持原行为。 + // + // 接管时整段文本在一次 update 内插入(与默认粘贴同样打 PASTE_TAG),所以一次 Ctrl+Z 就整体 + // 回退;token 之外的每个字符逐字保留(换行落成段落分隔,与默认纯文本粘贴同形状)。 + useEffect(() => { + return editor.registerCommand( + PASTE_COMMAND, + (event) => { + const clipboardData = (event as ClipboardEvent | null)?.clipboardData; + if (!clipboardData || typeof clipboardData.getData !== 'function') { + return false; + } + // 同 namespace 的 Lexical payload 自带真 chip,不抢它的默认导入。 + if (clipboardData.getData(LEXICAL_EDITOR_CLIPBOARD_TYPE)) return false; + const text = clipboardData.getData('text/plain'); + if (!text.trim()) return false; + const providers = providersRef.current; + // 不等待、不依赖:只让 provider 有机会按需补读,好让下一次粘贴能解析。 + for (const provider of providers) provider.onPasteText?.(text); + const references = providers.flatMap( + (provider) => provider.candidates?.() ?? [], + ); + const content = buildContentFromPastedText(text, references); + if (!content) return false; + event.preventDefault(); + editor.update( + () => { + $insertContentAtSelection(content, providersRef.current); + }, + // 与编辑器默认粘贴同一口径:粘贴是它自己的一条撤销记录。 + { tag: PASTE_TAG }, + ); + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor]); + // —— C8 AI 润色与发送前提醒 —— // 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下 // 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts index cf7c9b5be..9914ef29a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts @@ -55,6 +55,8 @@ export function createResourceReferenceProvider({ resourceReferenceMatchesQuery(reference, query), ) .slice(0, MENTION_OPTION_LIMIT), + // 粘贴解析用的全量候选:与菜单同一份「可提及」清单,只去掉 query 过滤与截断。 + candidates: () => resourceProviderData(assets).references, toReference: (part: DirectCodexUserContentPart): ChatReference | null => { if (part.type !== 'agc_resource_reference') return null; const asset = resourceProviderData(assets).byId.get(part.resourceId); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts index e3c34da47..3804d2ba2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts @@ -49,7 +49,10 @@ function loadSkillCatalog(): Promise { ]).then(([builtin, client]) => [...builtin, ...client]); } -function matchesSkillQuery(skill: SkillCatalogItem, query: string) { +function matchesSkillQuery( + skill: { name: string; description?: string }, + query: string, +) { const normalized = query.trim().toLowerCase(); if (!normalized) return true; return ( @@ -58,6 +61,22 @@ function matchesSkillQuery(skill: SkillCatalogItem, query: string) { ); } +/** 目录项 → 引用:同名只留第一条(与 `match` 同一份去重口径)。 */ +function skillReferences(skills: readonly SkillCatalogItem[]) { + const seen = new Set(); + const references: ChatReference[] = []; + for (const skill of skills) { + if (seen.has(skill.name)) continue; + seen.add(skill.name); + references.push({ + type: 'skill', + name: skill.name, + description: skill.description, + }); + } + return references; +} + /** * Skill 引用的 provider(宿主 hook)。 * @@ -96,22 +115,19 @@ export function useSkillReferenceProvider(): ReferenceProvider { onMenuQueryChange: (query) => { if (query !== null) ensureCatalog(); }, + // 粘贴解析:目录还没就绪时返回空数组,本次 `$名称` 逐字保留; + // 同时用 onPasteText 补发一次懒加载,让下一次粘贴能解析。 + onPasteText: (text) => { + if (text.includes('$')) ensureCatalog(); + }, + candidates: () => skillReferences(skills), match: (query) => { - const seen = new Set(); - return skills - .filter((skill) => { - if (seen.has(skill.name)) return false; - seen.add(skill.name); - return matchesSkillQuery(skill, query); - }) - .slice(0, MENTION_OPTION_LIMIT) - .map( - (skill): ChatReference => ({ - type: 'skill', - name: skill.name, - description: skill.description, - }), - ); + return skillReferences(skills) + .filter( + (reference) => + reference.type === 'skill' && matchesSkillQuery(reference, query), + ) + .slice(0, MENTION_OPTION_LIMIT); }, toReference: (part: DirectCodexUserContentPart): ChatReference | null => part.type === 'agc_skill_reference' diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts index ca138d75f..9321dace4 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts @@ -25,6 +25,16 @@ export type ReferenceProvider = { * 在渲染期生效。需要为「菜单打开」拉一次数据时,用下面的 `onMenuQueryChange`。 */ match?: (query: string) => ChatReference[]; + /** + * 全量候选枚举:粘贴解析用「文本 + 候选 → canonical content」的反解析在这里取候选。 + * + * 与 `match` 的区别只有「过滤与截断」:`match` 是菜单形状(按 query 过滤、截断到候选上限), + * 这里给的是**当前就能解析出的全部引用**,不截断。没有触发符的静默 provider 不实现。 + * + * **同样是纯函数**:输入区在粘贴事件里同步调用它;只返回已就绪的快照,不发起任何读取。 + * 数据还没到时返回空数组(或省略不实现),本次粘贴未命中的 token 逐字保留。 + */ + candidates?: () => ChatReference[]; /** * 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在 * 带触发符的 provider 上调用**。 @@ -33,6 +43,14 @@ export type ReferenceProvider = { * 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。 */ onMenuQueryChange?: (query: string | null) => void; + /** + * 粘贴文本的补读钩子(可选):正文里可能含有本 provider 的 token 时由输入区调用一次。 + * + * 输入区**不等待也不依赖**它的结果——本次粘贴只用 `candidates()` 已经就绪的候选;这里只是让 + * provider 有机会按需补读,好让下一次粘贴能解析(例如 Skill 目录是应用级异步读取,冷启动时 + * 还没有候选)。判断「这段文本里有没有我的触发符」是 provider 自己的事。 + */ + onPasteText?: (text: string) => void; /** canonical part → 引用;不属于本 provider 或暂时无法解析时返回 `null`。 */ toReference: (part: DirectCodexUserContentPart) => ChatReference | null; /** 引用身份刷新(资源改名等);不属于本 provider 时返回 `null`,原样返回表示无需改写。 */ diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 85f814c3f..ed1d22f45 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -417,6 +417,68 @@ export function buildContentFromTextTokens( return content; } +/** token 在整段文本里出现的次数:与 `findMentionToken` 同一套边界规则,按行统计。 */ +function countMentionTokenOccurrences(value: string, token: string) { + let count = 0; + for (const line of value.split(/\r?\n/u)) { + let from = 0; + for (;;) { + const index = findMentionToken(line, token, from); + if (index < 0) break; + count += 1; + from = index + token.length; + } + } + return count; +} + +/** + * 粘贴文本 → canonical content(粘贴侧的唯一解析口径)。 + * + * 候选由宿主注入的 provider 枚举(`ReferenceProvider.candidates`),token 就是 + * `chatReferenceMentionToken`——与显示口径逐字同一个字符串,所以「从气泡复制再粘贴」不需要 + * 任何兼容别名:`@显示名` / `$名称` 认得出,`@hero.png`、resourceId、大小写变体一律不认。 + * + * 两条保守规则: + * + * - 同名多候选(同一个 token 对应多条引用身份)一律按文本保留——宁可不成 chip,也不能认错引用。 + * - 只把正文里真的出现过的 token 交给 `buildContentFromTextTokens`,不做「未命中候选补到末尾」 + * (那是润色回包的语义,照搬会把整份清单追加到粘贴文本后面)。同一 token 出现几次就展开几条 + * 候选,所以重复出现的引用会各自原位成 chip。 + * + * 返回 `null` 表示这段文本里没有任何可解析的 token,调用方应放行编辑器的默认粘贴。 + */ +export function buildContentFromPastedText( + value: string, + references: readonly ChatReference[], +): DirectCodexUserContentPart[] | null { + const referencesByToken = new Map(); + for (const reference of references) { + const token = chatReferenceMentionToken(reference); + const bucket = referencesByToken.get(token); + if (!bucket) { + referencesByToken.set(token, [reference]); + continue; + } + const key = chatReferenceKey(reference); + if (!bucket.some((item) => chatReferenceKey(item) === key)) { + bucket.push(reference); + } + } + const candidates: ContentTokenCandidate[] = []; + for (const [token, bucket] of referencesByToken) { + if (bucket.length !== 1) continue; + const reference = bucket[0]!; + const part = chatReferenceToContentPart(reference); + const occurrences = countMentionTokenOccurrences(value, token); + for (let index = 0; index < occurrences; index += 1) { + candidates.push({ token, part }); + } + } + if (candidates.length === 0) return null; + return buildContentFromTextTokens(value, candidates); +} + /** * legacy「text + references + attachments」DTO → canonical content:旧调用方继续吐双轨 * 形状,这里按 token 扫回真 part,不另立第二份事实源。 diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index d7e6bf23c..231f3cbf2 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -127,6 +127,45 @@ describe('资源引用 provider', () => { ); }); + it('`candidates` 给粘贴解析用的全量清单:与菜单同一份可提及素材,但不受菜单截断影响', () => { + const assets = [ + heroAsset, + asset('asset-orphan', 'image', 'image/png', ''), + ...Array.from({ length: 10 }, (_, index) => + asset( + `asset-${index}`, + 'image', + 'image/png', + `assets/pic-${index}.png`, + ), + ), + ]; + const scoped = createResourceReferenceProvider({ assets }); + + // 不可提及素材(没有 localPath)不在候选里,条目数与展示名口径与菜单一致。 + expect(scoped.match?.('')).toHaveLength(8); + expect(scoped.candidates?.()).toHaveLength(11); + expect(scoped.candidates?.().map(chatReferenceMentionToken)).toEqual( + expect.arrayContaining(['@hero-idle', '@pic-0', '@pic-9']), + ); + // 粘贴解析只认「显示名逐字一致」的 token,所以候选的 token 必须是显示名形态。 + expect( + scoped + .candidates?.() + .some( + (item) => 'resourceId' in item && item.resourceId === 'asset-orphan', + ), + ).toBe(false); + // 菜单就是「同一份候选 + query 过滤 + 截断」,所以截断后的前缀逐字一致。 + expect(scoped.candidates?.().slice(0, 8)).toEqual(scoped.match?.('')); + }); + + it('清单为空时 `candidates` 是空数组:粘贴不会把任何 token 当成引用', () => { + expect( + createResourceReferenceProvider({ assets: [] }).candidates?.(), + ).toEqual([]); + }); + it('`toReference` 只认资源 part:资产已删除时不合成引用', () => { expect(provider.toReference({ type: 'input_text', text: '看素材' })).toBe( null, @@ -348,6 +387,49 @@ describe('Skill provider', () => { }); }); + it('粘贴补读:`candidates` 保持纯函数(目录没到就是空数组),`onPasteText` 只在文本含 `$` 时补读一次', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'list_agc_skill_catalog') { + return [{ name: 'agc-test-skill', description: '测试 Skill' }]; + } + if (command === 'list_client_extensions') return []; + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke: invoke as never } }; + + const { result } = renderHook(() => useSkillReferenceProvider()); + + // 冷启动:粘贴解析只能看到「当前就绪」的候选,目录没读过就是空数组,本次 `$名称` 逐字保留。 + act(() => { + expect(result.current.candidates?.()).toEqual([]); + }); + expect(invoke).not.toHaveBeenCalled(); + + // 不含触发符的粘贴不触发任何读取。 + act(() => { + result.current.onPasteText?.('把这一版改成夜景'); + }); + expect(invoke).not.toHaveBeenCalled(); + + // 含 `$` 的粘贴补发一次懒加载:本次仍解析不出,下一次粘贴起才有候选。 + act(() => { + result.current.onPasteText?.('用 $agc-test-skill 出图'); + }); + await waitFor(() => { + expect(result.current.candidates?.()).toHaveLength(1); + }); + expect(invoke).toHaveBeenCalledTimes(2); + expect( + result.current.candidates?.().map(chatReferenceMentionToken), + ).toEqual(['$agc-test-skill']); + + // 已就绪后再粘贴同一个 token,不会再读一次目录。 + act(() => { + result.current.onPasteText?.('用 $agc-test-skill 出图'); + }); + expect(invoke).toHaveBeenCalledTimes(2); + }); + it('内置目录与已启用客户端 Skill 合并后按名字去重,并截断到 8 条', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'list_agc_skill_catalog') { diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 37a5bc8a7..0b8cd75bf 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -67,6 +67,26 @@ import { type RuntimeRegionReference, } from '../src/features/project-workspace/resourceReferences'; +/** + * jsdom 没有 `DragEvent` / `ClipboardEvent` 构造器,而 Lexical 的默认粘贴路径按构造器名字判断 + * 事件类型(`objectKlassEquals`);真实客户端(Tauri / Chromium)两者都在。这里只补齐测试环境 + * 缺的那部分,名字必须与真实构造器逐字一致,否则 Lexical 会把粘贴数据源当成 `null`。 + */ +function defineEventClassShim(name: 'DragEvent' | 'ClipboardEvent') { + if (typeof (globalThis as Record)[name] === 'function') + return; + const shim = class extends Event {}; + Object.defineProperty(shim, 'name', { value: name }); + Object.defineProperty(globalThis, name, { + value: shim, + configurable: true, + writable: true, + }); +} + +defineEventClassShim('DragEvent'); +defineEventClassShim('ClipboardEvent'); + function asset( id: string, kind: string, @@ -231,6 +251,48 @@ function composerEditor(): { return editor; } +/** + * jsdom 里合成一次原生粘贴:Lexical 的 DOM 监听器把 `paste` 事件转成 PASTE_COMMAND, + * 所以这条链路和真实粘贴一致(含 `clipboardData` 读取与 `preventDefault`)。 + */ +function pasteComposerText( + text: string, + payload: { html?: string; lexical?: string } = {}, +) { + // 真实粘贴发生在有焦点的编辑器里;jsdom 不会自己给编辑器设选区,这里先落到草稿末尾, + // 否则 Lexical 默认粘贴处理器拿不到 selection,会直接放弃这次粘贴。 + act(() => { + composerEditor().update(() => { + $getRoot().selectEnd(); + }); + }); + const element = screen.getByLabelText('聊天'); + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { + value: { + getData: (type: string) => { + if (type === 'text/plain') return text; + if (type === 'text/html') return payload.html ?? ''; + if (type === 'application/x-lexical-editor') { + return payload.lexical ?? ''; + } + return ''; + }, + }, + }); + act(() => { + element.dispatchEvent(event); + }); + return event; +} + +/** 草稿里的 Skill 引用名,按 content 顺序。 */ +function draftSkillNames(draft: ChatComposerDraft | undefined) { + return (draft?.content ?? []).flatMap((part) => + part.type === 'agc_skill_reference' ? [part.name] : [], + ); +} + /** * jsdom 里键盘输入不会进入 Lexical,所以直接走编辑器 API 写文本; * 它触发的是和真实输入同一条更新链路,typeahead 监听器同样会被唤醒。 @@ -332,6 +394,143 @@ describe('ResourceReferenceInput', () => { } }); + test('粘贴含引用的显示文本:原位重建 chip,其余字符逐字保留', async () => { + const ref = createRef(); + render( + , + ); + + pasteComposerText('看 @hero 这一版'); + + await waitFor(() => { + expect(draftResourceIds(ref.current?.getDraft())).toEqual(['hero']); + }); + expect(ref.current?.getDraft().content).toEqual([ + { type: 'input_text', text: '看 ' }, + { type: 'agc_resource_reference', resourceId: 'hero' }, + { type: 'input_text', text: ' 这一版' }, + ]); + // 一次粘贴是一条撤销记录、一次文本更新:chip 在文本模型里只占 1 个字符。 + expect(editorTextSize()).toBe('看 '.length + 1 + ' 这一版'.length); + }); + + test('粘贴未命中的 token:整段按字面落进正文,不接管、不提示', async () => { + const ref = createRef(); + render( + , + ); + + // 项目里没有名为 `hero.png` 的显示名(`@hero` 才是当前清单的口径),所以这条不是引用。 + pasteComposerText('@hero.png 换成夜景'); + + await waitFor(() => { + expect(draftText(ref.current?.getDraft())).toBe('@hero.png 换成夜景'); + }); + expect(draftResourceIds(ref.current?.getDraft())).toEqual([]); + }); + + test('粘贴不含引用的纯文本仍走编辑器默认导入(换行成段,不经过解析)', async () => { + const ref = createRef(); + render( + , + ); + + pasteComposerText('第一行\n第二行'); + + await waitFor(() => { + expect(ref.current?.getDraft().content).toEqual([ + { type: 'input_text', text: '第一行' }, + { type: 'input_text', text: '\n' }, + { type: 'input_text', text: '第二行' }, + ]); + }); + }); + + test('剪贴板里带 Lexical 负载时让位给默认导入:文本里的 @显示名 不被解析', async () => { + const ref = createRef(); + render( + , + ); + + pasteComposerText('看 @hero 这一版', { lexical: '{"namespace":"other"}' }); + + await waitFor(() => { + expect(draftText(ref.current?.getDraft())).toBe('看 @hero 这一版'); + }); + expect(draftResourceIds(ref.current?.getDraft())).toEqual([]); + }); + + test('Skill 目录冷启动:第一次粘贴 `$名称` 保持字面并补读目录,第二次粘贴才成 chip', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'list_agc_skill_catalog') { + return [{ name: 'agc-test-skill', description: '测试 Skill' }]; + } + if (command === 'list_client_extensions') return []; + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke: invoke as never } }; + + try { + const ref = createRef(); + render( + , + ); + expect(invoke).not.toHaveBeenCalled(); + + // 冷目录:候选还没到,`$名称` 逐字保留,同时补读一次目录。 + pasteComposerText('用 $agc-test-skill 出图'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); + }); + expect(draftText(ref.current?.getDraft())).toBe( + '用 $agc-test-skill 出图', + ); + + act(() => { + ref.current?.clear(); + }); + pasteComposerText('用 $agc-test-skill 出图'); + await waitFor(() => { + expect(draftSkillNames(ref.current?.getDraft())).toEqual([ + 'agc-test-skill', + ]); + }); + expect(ref.current?.getDraft().content).toEqual([ + { type: 'input_text', text: '用 ' }, + { type: 'agc_skill_reference', name: 'agc-test-skill' }, + { type: 'input_text', text: ' 出图' }, + ]); + } finally { + delete window.__TAURI__; + } + }); + test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => { const base: RuntimeRegionReference = { type: 'runtime-region', diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index ed38f7d4a..fe78e5bc3 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it } from 'vitest'; +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; import { + buildContentFromPastedText, type ChatComposerDraft, chatComposerDraftToDirectCodexUserItem, + type ChatReference, directCodexContentToPromptText, hasMeaningfulDirectCodexContent, resourceLabelResolver, + resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; +import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; describe('DirectProject user Response item', () => { it('保留 Lexical content 的文本与引用交错顺序', () => { @@ -108,3 +113,104 @@ describe('DirectProject user Response item', () => { ).toBe('用 @asset-hero 做主视觉'); }); }); + +describe('粘贴文本反解析', () => { + const assetEntry = ( + id: string, + localPath: string, + ): GameCreationAppAssetManifestEntry => ({ + id, + kind: 'character', + mediaType: 'image/png', + localPath, + source: { kind: 'uploaded' }, + }); + const heroAsset = assetEntry('hero', 'assets/hero.png'); + const enemyAsset = assetEntry('enemy', 'assets/enemy.png'); + const hero = resourceReferenceFromAsset(heroAsset, 'asset-picker'); + const enemy = resourceReferenceFromAsset(enemyAsset, 'asset-picker'); + const skill: ChatReference = { type: 'skill', name: 'image-gen' }; + + it('命中显示名 token 时原位换成引用,其余字符逐字保留', () => { + expect(buildContentFromPastedText('看 @hero 这一版', [hero])).toEqual([ + { type: 'input_text', text: '看 ' }, + { type: 'agc_resource_reference', resourceId: 'hero' }, + { type: 'input_text', text: ' 这一版' }, + ]); + expect(buildContentFromPastedText('用 $image-gen 出图', [skill])).toEqual([ + { type: 'input_text', text: '用 ' }, + { type: 'agc_skill_reference', name: 'image-gen' }, + { type: 'input_text', text: ' 出图' }, + ]); + }); + + it('不做兼容别名:只认显示名,且 token 前后必须是行首 / 行尾或空白', () => { + // 带扩展名的文件名、紧贴中文、全角 `@`、大小写变体都不解析——宁可不成 chip,也不能认错。 + expect(buildContentFromPastedText('@hero.png', [hero])).toBeNull(); + expect(buildContentFromPastedText('看@hero这一版', [hero])).toBeNull(); + expect(buildContentFromPastedText('@hero', [hero])).toBeNull(); + expect(buildContentFromPastedText('@HERO', [hero])).toBeNull(); + // 行首 / 行尾同样是边界。 + expect(buildContentFromPastedText('@hero', [hero])).toEqual([ + { type: 'agc_resource_reference', resourceId: 'hero' }, + ]); + }); + + it('未命中的 token 逐字保留,混在正文里也只换认出那几条', () => { + expect( + buildContentFromPastedText('@hero 与 @unknown 都在', [hero]), + ).toEqual([ + { type: 'agc_resource_reference', resourceId: 'hero' }, + { type: 'input_text', text: ' 与 @unknown 都在' }, + ]); + // 一条都没命中时返回 null:调用方据此放行编辑器默认粘贴。 + expect(buildContentFromPastedText('纯文本 @unknown', [hero])).toBeNull(); + }); + + it('同名多候选一律按文本保留:宁可不成 chip,也不能认错引用', () => { + const sameName = [ + resourceReferenceFromAsset( + assetEntry('asset-hero-a', 'characters/hero.png'), + 'asset-picker', + ), + resourceReferenceFromAsset( + assetEntry('asset-hero-b', 'enemies/hero.png'), + 'asset-picker', + ), + ]; + expect(buildContentFromPastedText('@hero', sameName)).toBeNull(); + // 其余可判定的 token 照常解析。 + expect( + buildContentFromPastedText('@hero @enemy', [...sameName, enemy]), + ).toEqual([ + { type: 'input_text', text: '@hero ' }, + { type: 'agc_resource_reference', resourceId: 'enemy' }, + ]); + }); + + it('同一 token 出现几次就成几个 chip,换行保留为独立的文本 part', () => { + expect(buildContentFromPastedText('@hero\n@hero', [hero])).toEqual([ + { type: 'agc_resource_reference', resourceId: 'hero' }, + { type: 'input_text', text: '\n' }, + { type: 'agc_resource_reference', resourceId: 'hero' }, + ]); + }); + + it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => { + const content: DirectCodexUserContentPart[] = [ + { type: 'input_text', text: '看 ' }, + { type: 'agc_resource_reference', resourceId: 'hero' }, + { type: 'input_text', text: ' 这一版,再用 ' }, + { type: 'agc_skill_reference', name: 'image-gen' }, + { type: 'input_text', text: ' 出图' }, + ]; + const displayed = directCodexContentToPromptText( + content, + resourceLabelResolver([heroAsset]), + ); + expect(displayed).toBe('看 @hero 这一版,再用 $image-gen 出图'); + expect(buildContentFromPastedText(displayed, [hero, skill])).toEqual( + content, + ); + }); +}); diff --git a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md index bd50d9767..7b098a22a 100644 --- a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md +++ b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md @@ -19,3 +19,14 @@ - `provider.match` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。 - 附件并入 `ChatReference`,编辑器收敛为单一引用节点类型,附件 chip 的 DOM 契约逐字保留;附件导入成功后以芯片进入正文,失败不插入;控制器不再持有附件数组,`MAX_CHAT_COMPOSER_ATTACHMENTS` 改为按草稿中的附件芯片数计算,导入进行中禁止发送。 - 用户可见行为保持不变,唯一例外是已裁决的缺陷修复:非 DirectProject 宿主不再出现 `$` Skill 候选。 + +## 修订(2026-09-22):粘贴解析需要第二道只读缝 + +粘贴进来的纯文本要按同一套引用文本语法反解析回正文芯片,因此 provider 在 `match(query)`(菜单形状:按 query 过滤并截断)之外,再暴露两项可选能力: + +- `candidates()`:当前就绪的全部候选,与菜单共用同一份对象集合,不截断、不按 query 过滤;仍是纯函数,由输入区在粘贴事件里同步调用,数据没到就是空数组。 +- `onPasteText(text)`:粘贴文本里可能出现本 provider 的 token 时的补读钩子;输入区不等待也不依赖它的结果(Skill 目录是应用级异步读取,冷启动时本次粘贴解析不出,下一次才有候选)。 + +这没有推翻本 ADR 的懒加载结论:`onMenuQueryChange` 仍是菜单那条懒加载路径,`onPasteText` 只是让「用户把 `$名称` 贴进来」等价于「他自己敲了 `$`」,且**不改变**「挂载即查询会让工作区路径非法时也产生后端访问」这条边界。附件与运行画面区域仍是静默 provider:它们没有候选,所以粘贴解析不认 `@附件名` / `@区域标签`,这两类 token 粘贴时逐字保留(附件与运行区域的身份来自文件与 run,纯文本重建不出来)。 + +歧义口径:同一个 token 对应多条引用身份(同名素材)时一律按文本保留;解析只认显示名逐字一致(不认扩展名、resourceId、大小写变体),未命中的 token 与其余文字逐字保留。 diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md new file mode 100644 index 000000000..d48ed7993 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -0,0 +1,43 @@ +# 引用粘贴解析实施计划 + +对应:[引用候选由宿主注入](../../adr/【ADR】引用候选由宿主注入-2026-09-22.md)(含 2026-09-22 修订节);决策记录见 `docs/project-memory/shared-memory/decision-log.md` 的 2026-09-22 条目。上一步的重构计划见 [引用输入区重构与宿主注入](【实施计划】引用输入区重构与宿主注入-2026-09-22.md),那份明确不含本项。 + +## 一句话交付与验收判据 + +把从用户消息气泡(或任何同口径文本)复制出来的 `@显示名` / `$名称` 粘贴进引用输入区时,原位重建同顺序的引用芯片;其余文字逐字保留。 + +验收判据: + +1. 粘贴文本 → 引用:`@显示名` 与 `$名称` 逐字命中当前宿主注入的 provider 候选时原位换成芯片,一次 Ctrl+Z 整体回退,token 之外的每个字符(含换行与空白)原样保留。 +2. 逐字一致、不做兼容别名:不认 `@hero.png`、`resourceId`、大小写变体、全角 `@`;token 前后必须是行首 / 行尾或空白。 +3. 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、不猜路径或文件名。 +4. 只有真的解析出引用时才接管:同 namespace 的 `application/x-lexical-editor` 负载、不含 token 的纯文本、图片文件粘贴一律放行编辑器默认导入,现有粘贴行为逐字不变。 +5. 附件与运行画面区域不参与粘贴解析(静默 provider 没有候选),它们的 token 粘贴时按文本保留。 +6. Skill 目录冷启动不阻塞粘贴:`candidates()` 是纯函数,目录没到就是空数组(本次 `$名称` 保留为文本),`onPasteText` 补读一次让下一次粘贴能解析。 + +## 流程判定 + +本次是共享组件的一处行为增量 + provider 契约加两个可选能力,不动 schema、不动公开 API/DTO、不动后端;按轻量流程只建本实施计划,不新建主规范与里程碑规范(与上一步重构同一判定)。 + +## 提交切分 + +1. **反解析口径**:`resourceReferences.ts` 新增 `buildContentFromPastedText(text, references)`(粘贴侧唯一反解析;与 `buildContentFromTextTokens` 同一套边界规则),配规则矩阵单测。 +2. **provider 契约**:`reference-source/types.ts` 新增可选的 `candidates()` 与 `onPasteText(text)`;`resourceReferenceProvider` 给全量可提及候选,`skillReferenceProvider` 给去重后的 Skill 候选并按需补读目录。 +3. **输入区接管**:`ResourceReferenceInput` 注册 `COMMAND_PRIORITY_CRITICAL` 的 `PASTE_COMMAND`,只在解析出引用时 `preventDefault` 并在一次 `editor.update`(`PASTE_TAG`)内按选区插入;配集成用例。 +4. **文档**:`CONTEXT.md` 术语、本计划、ADR 修订节、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md` 与决策记录。 + +## 验证 + +定向:`npx vitest run apps/ai-game-creator-shell/tests/resourceReferences.test.ts apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`;全量:`npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。真实客户端手感(粘贴时的候选菜单位置、大段文本粘贴观感、Tauri 剪贴板只带图片时的行为)留待真机验收。 + +## 风险与回滚 + +- 解析接管会绕过 Lexical 的 `text/html` 富文本导入:只在文本里真的解析出引用时接管,取舍已在上一条判据里限定;要完全避开富文本场景可以后续按 `clipboardData.types` 再收窄。 +- 气泡里若出现与素材同名的 `@区域标签` / `@附件名`,粘贴会被认成素材引用——纯文本无法区分,属已知取舍。 +- 回滚按提交粒度 revert;canonical content 形状、provider 的既有能力与出站文本口径都不变。 + +## 执行状态(2026-09-22) + +已完成:`buildContentFromPastedText` 与规则矩阵单测(命中 / 未命中 / 相邻中文 / 扩展名 / 大小写 / 全角 / 同名歧义 / 重复出现 / 换行 / 与显示口径互为逆运算);provider 的 `candidates` 与 `onPasteText` 及用例;输入区 `PASTE_COMMAND` 接管与 4 条集成用例(粘贴重建芯片、未命中保持字面、纯文本走默认导入、Lexical 负载让位、Skill 冷启动补读);文档同步。 + +未做(本次范围外):斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片的解析、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5b90bd7d7..f73ffbccf 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,17 @@ # 决策记录 +## 2026-09-22 引用粘贴解析:只认显示口径的 token,宁可不成芯片也不能认错 + +- 背景:引用输入区的 `@` / `$` 只由 `LexicalTypeaheadMenuPlugin` 的逐字敲击触发,粘贴走 Lexical 默认路径(`text/plain` → 纯文本),所以从用户消息气泡复制回来的 `@显示名` / `$名称` 粘进来就是死文本;同时 chip 的 `text/plain` 是占位符,复制出去再粘回来必然丢引用(气泡显示文本才是完整 token 的形态)。 +- 决策(口径):新增粘贴侧唯一反解析 `buildContentFromPastedText(text, references)`(`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`),候选由宿主注入的 provider 枚举,token 就是 `chatReferenceMentionToken`——与出站显示逐字同一个字符串,所以**不做任何兼容别名**:不认 `@hero.png`、`resourceId`、大小写变体、全角 `@`;边界仍是「行首 / 行尾或空白」(显示侧 token 前后补空白,两端自洽)。 +- 决策(宁可不成芯片也不能认错):同一个 token 对应多条引用身份(同名素材)时一律按文本保留;未命中的 token 静默保留、不提示、不猜文件名或路径;附件与运行画面区域不参与(静默 provider 没有候选,`@附件名` / `@区域标签` 按文本保留)。 +- 决策(provider 契约加两道可选缝):`ReferenceProvider.candidates()` 给「当前就绪的全部候选」(不截断、不按 query 过滤,与菜单共用同一份集合),`onPasteText(text)` 是粘贴时的补读钩子(输入区不等待、不依赖结果)。这不推翻上一步「懒加载只走 `onMenuQueryChange`」的结论:`onPasteText` 只是让「用户把 `$名称` 贴进来」等价于「他自己敲了 `$`」,预载 Skill 目录与「工作台路径非法时不产生后端访问」的边界都不动。 +- 决策(接管范围):输入区在 `COMMAND_PRIORITY_CRITICAL` 注册 `PASTE_COMMAND`,**只在真的解析出引用时**接管(同 namespace 的 `application/x-lexical-editor` 负载、无 token 纯文本、图片文件一律 `return false` 走默认导入);接管时一次 `editor.update(..., { tag: PASTE_TAG })` 内按选区插入,所以一次 Ctrl+Z 整体回退,token 之外逐字保留。 +- 原因:粘贴是用户此刻的编辑,事后回头改写他的输入(例如清单到齐后再把文本改成芯片)等于前端替用户重写内容;而任何「多候选取其一」「按文件名猜资源」的启发式都会制造看不出错的错引用。 +- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/{resourceReferences.ts,ResourceReferenceInput.tsx,reference-source/{types.ts,resourceReferenceProvider.ts,skillReferenceProvider.ts}}`、`apps/ai-game-creator-shell/tests/{resourceReferences.test.ts,referenceSourceProviders.test.ts,resourceReferenceInput.test.tsx}`、`CONTEXT.md`、`docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md`(修订节)、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、本文件。 +- 未纳入本次:斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。 +- 验证方式:`buildContentFromPastedText` 规则矩阵单测(含「显示文本再粘贴回来得到同一份 content」这条逆运算)、provider 的 `candidates` / `onPasteText` 用例、输入区集成用例(真 Lexical `paste` 事件 → 芯片、未命中等价于默认粘贴、Skill 冷启动补读);另跑 `npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + ## 2026-09-22 引用输入区改为宿主注入引用 provider,选择器面板与输入区分离 - 背景:`ResourceReferenceInput`(`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`)同时承担「拿数据」与「编辑数据」:素材以未过滤 manifest 传入后由组件自己派生候选、显示名与「当前版本素材」scope,Skill 候选由组件自己 invoke `list_agc_skill_catalog` 与 `list_client_extensions`(只在用户敲出 `$` 时触发),素材选择面板与缩略图预览 invoke 也住在组件内部。后果是 5 个宿主(DirectProject 聊天、策划输入盒、画布生成面板、资源卡快速编辑、测试夹具)无差别获得 `$` Skill 候选,而只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill(Rust `direct_codex_user_item_to_codex_turn_input`,`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs`),其余宿主只把它退化成字面文本,形成误导入口。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 940c49ff5..f8f415c1d 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -31,6 +31,16 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, 输入校验按整条消息判断是否有内容:每个 `input_text` 片段都允许是空字符串、空格或换行,不逐片段拒绝,也不合并、删除或改写片段;原始文字、分段和 `content[]` 顺序保持不变。整条消息必须至少包含一段非空白文字,或至少一个非文本 part(素材引用 / 运行画面引用 / Skill 引用 / 附件引用),否则返回“聊天内容不能为空”。各类引用继续执行原有字段、数量、manifest 归属和路径安全校验;即使消息同时带有正文,非法引用也必须拒绝,不能由正文绕过。 +## 粘贴解析(2026-09-22) + +把含引用 token 的纯文本粘进输入区时,可以逐字命中的 token 会原位变回引用芯片: + +- 只认与显示口径逐字一致的 token:`@显示名`(素材)与 `$名称`(Skill)。`@hero.png`、资源 ID、大小写变体、全角 `@` 都不解析;token 前后必须是行首 / 行尾或空白(与出站文本「token 前后各留一个空白」自洽),所以从用户消息气泡复制出来的那段文字粘回来会重建同一批芯片。 +- 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、也不猜文件名或路径。 +- 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。 +- 只有真的解析出引用时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 +- Skill 目录是应用级异步读取,走候选菜单的懒加载口径:冷启动时第一次粘贴 `$名称` 会保持为文本,同时补读一次目录,下一次粘贴起才有候选(输入区不等待目录,粘贴不被网络或磁盘读取阻塞)。 + ## 拖拽引用(2026-09-21) 除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。 From ec1f207021e2e1f3b507a9c14e95f8953fed3275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 19:41:56 +0800 Subject: [PATCH 02/15] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E5=BC=95?= =?UTF-8?q?=E7=94=A8=20provider=20=E7=9A=84=20match=20/=20candidates=20?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E4=B8=BA=20fuzzyLookup=20/=20lookup=EF=BC=8C?= =?UTF-8?q?=E5=88=A0=E9=99=A4=20onPasteText?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit match(query) 改名 fuzzyLookup(query):名字写明它是包含匹配 + 截断的模糊菜单查询 candidates() 改名 lookup():名字写明它是精确查找用的就绪全量候选,不模糊不截断 删除 onPasteText 补读钩子:粘贴只用此刻就绪的候选,不等待、不补读,也不为解析提前读盘 输入区与资源 / Skill provider 同步改名,粘贴接管路径改读 lookup() provider 用例与粘贴集成用例同步:Skill 冷启动改为「保持字面,敲过 `$` 后可解析」 补 jsdom 缺的 Range.getBoundingClientRect 与 ResizeObserver 桩(候选菜单测量用,真实浏览器都有) 同步 ADR 修订、实施计划、功能说明与决策记录 --- .../ResourceReferenceInput.tsx | 11 +-- .../resourceReferenceProvider.ts | 6 +- .../skillReferenceProvider.ts | 10 +- .../reference-source/types.ts | 25 ++--- .../tests/referenceSourceProviders.test.ts | 95 ++++++++----------- .../tests/resourceReferenceInput.test.tsx | 60 ++++++++++-- .../【ADR】引用候选由宿主注入-2026-09-22.md | 14 ++- .../【实施计划】引用粘贴解析-2026-09-22.md | 6 +- .../shared-memory/decision-log.md | 4 +- .../【功能说明】AGC聊天素材引用-2026-09-08.md | 5 +- 10 files changed, 132 insertions(+), 104 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 1ca5ded23..7deef1bc7 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 @@ -593,10 +593,9 @@ function ResourceReferenceEditor({ const text = clipboardData.getData('text/plain'); if (!text.trim()) return false; const providers = providersRef.current; - // 不等待、不依赖:只让 provider 有机会按需补读,好让下一次粘贴能解析。 - for (const provider of providers) provider.onPasteText?.(text); + // 只认「此刻就绪」的候选:数据还没到的种类本次按文本保留,输入区不等待也不补读。 const references = providers.flatMap( - (provider) => provider.candidates?.() ?? [], + (provider) => provider.lookup?.() ?? [], ); const content = buildContentFromPastedText(text, references); if (!content) return false; @@ -888,16 +887,16 @@ function ProviderMentionMenu({ [onOpenChange, trigger], ); - // 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`match` 始终保持纯函数, + // 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`fuzzyLookup` 始终保持纯函数, // 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。 useEffect(() => { provider.onMenuQueryChange?.(query); }, [provider, query]); const options = useMemo(() => { - if (query === null || !provider.match) return []; + if (query === null || !provider.fuzzyLookup) return []; return provider - .match(query) + .fuzzyLookup(query) .map((reference) => new ReferenceMentionOption(reference)); }, [provider, query]); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts index 9914ef29a..7f724c365 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts @@ -49,14 +49,14 @@ export function createResourceReferenceProvider({ }): ReferenceProvider { return { trigger: '@', - match: (query) => + fuzzyLookup: (query) => resourceProviderData(assets) .references.filter((reference) => resourceReferenceMatchesQuery(reference, query), ) .slice(0, MENTION_OPTION_LIMIT), - // 粘贴解析用的全量候选:与菜单同一份「可提及」清单,只去掉 query 过滤与截断。 - candidates: () => resourceProviderData(assets).references, + // 精确查找用的全量候选:与菜单同一份「可提及」清单,只去掉模糊过滤与截断。 + lookup: () => resourceProviderData(assets).references, toReference: (part: DirectCodexUserContentPart): ChatReference | null => { if (part.type !== 'agc_resource_reference') return null; const asset = resourceProviderData(assets).byId.get(part.resourceId); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts index 3804d2ba2..228b6ddd3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts @@ -115,13 +115,9 @@ export function useSkillReferenceProvider(): ReferenceProvider { onMenuQueryChange: (query) => { if (query !== null) ensureCatalog(); }, - // 粘贴解析:目录还没就绪时返回空数组,本次 `$名称` 逐字保留; - // 同时用 onPasteText 补发一次懒加载,让下一次粘贴能解析。 - onPasteText: (text) => { - if (text.includes('$')) ensureCatalog(); - }, - candidates: () => skillReferences(skills), - match: (query) => { + // 精确查找:目录还没就绪(用户还没敲过 `$`)时返回空数组,本次 `$名称` 逐字保留。 + lookup: () => skillReferences(skills), + fuzzyLookup: (query) => { return skillReferences(skills) .filter( (reference) => diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts index 9321dace4..bf78a4901 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts @@ -19,22 +19,25 @@ export type ReferenceProvider = { */ trigger: string | null; /** - * 候选项:过滤、排序与截断都在 provider 内部完成。静默 provider 不实现。 + * 模糊查询:候选菜单的过滤。名字写明它是模糊的——按 query 做包含匹配、大小写不敏感, + * 并在 provider 内部截断到候选上限。静默 provider 不实现。 * * **必须是纯函数**:输入区在渲染阶段(`useMemo`)调用它,读清单、写 ref、发请求都会 * 在渲染期生效。需要为「菜单打开」拉一次数据时,用下面的 `onMenuQueryChange`。 + * 精确查找用 `lookup`,不要拿它反查 token。 */ - match?: (query: string) => ChatReference[]; + fuzzyLookup?: (query: string) => ChatReference[]; /** - * 全量候选枚举:粘贴解析用「文本 + 候选 → canonical content」的反解析在这里取候选。 + * 精确查找用的全量候选:某一刻 provider 真正能解析出的所有引用,不做模糊过滤、不截断。 * - * 与 `match` 的区别只有「过滤与截断」:`match` 是菜单形状(按 query 过滤、截断到候选上限), - * 这里给的是**当前就能解析出的全部引用**,不截断。没有触发符的静默 provider 不实现。 + * 与 `fuzzyLookup` 的区别只有「模糊与截断」,两者共用同一份候选来源。没有触发符的静默 + * provider 不实现。 * * **同样是纯函数**:输入区在粘贴事件里同步调用它;只返回已就绪的快照,不发起任何读取。 - * 数据还没到时返回空数组(或省略不实现),本次粘贴未命中的 token 逐字保留。 + * 数据还没到时返回空数组(或省略不实现),解析不出的 token 逐字保留——例如 Skill 目录的就绪 + * 时机仍是用户第一次敲出 `$`,冷启动时粘贴 `$名称` 就是字面文本,不会被猜成别的引用。 */ - candidates?: () => ChatReference[]; + lookup?: () => ChatReference[]; /** * 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在 * 带触发符的 provider 上调用**。 @@ -43,14 +46,6 @@ export type ReferenceProvider = { * 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。 */ onMenuQueryChange?: (query: string | null) => void; - /** - * 粘贴文本的补读钩子(可选):正文里可能含有本 provider 的 token 时由输入区调用一次。 - * - * 输入区**不等待也不依赖**它的结果——本次粘贴只用 `candidates()` 已经就绪的候选;这里只是让 - * provider 有机会按需补读,好让下一次粘贴能解析(例如 Skill 目录是应用级异步读取,冷启动时 - * 还没有候选)。判断「这段文本里有没有我的触发符」是 provider 自己的事。 - */ - onPasteText?: (text: string) => void; /** canonical part → 引用;不属于本 provider 或暂时无法解析时返回 `null`。 */ toReference: (part: DirectCodexUserContentPart) => ChatReference | null; /** 引用身份刷新(资源改名等);不属于本 provider 时返回 `null`,原样返回表示无需改写。 */ diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index 231f3cbf2..3b0178857 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -82,7 +82,7 @@ describe('资源引用 provider', () => { expect(provider.isReady?.()).toBe(true); }); - it('候选只含可提及素材,`match` 按显示名 / id / kind 过滤并截断到 8 条', () => { + it('候选只含可提及素材,`fuzzyLookup` 按显示名 / id / kind 模糊过滤并截断到 8 条', () => { const assets = [ heroAsset, asset('asset-icon', 'icon', 'image/png', 'assets/btn.png'), @@ -91,21 +91,21 @@ describe('资源引用 provider', () => { asset('asset-agent', 'document', 'text/markdown', '.agent/notes.md'), ]; const scoped = createResourceReferenceProvider({ assets }); - expect(scoped.match?.('')).toHaveLength(2); + expect(scoped.fuzzyLookup?.('')).toHaveLength(2); const many = Array.from({ length: 10 }, (_, index) => asset(`asset-${index}`, 'image', 'image/png', `assets/pic-${index}.png`), ); const limited = createResourceReferenceProvider({ assets: many }); - expect(limited.match?.('')).toHaveLength(8); + expect(limited.fuzzyLookup?.('')).toHaveLength(8); - expect(provider.match?.('hero')?.map((item) => item.type)).toEqual([ + expect(provider.fuzzyLookup?.('hero')?.map((item) => item.type)).toEqual([ 'resource', ]); - expect(provider.match?.('HERO-IDLE')).toHaveLength(1); - expect(provider.match?.('character')).toHaveLength(1); - expect(provider.match?.(' hero ')).toHaveLength(1); - expect(provider.match?.('missing')).toEqual([]); + expect(provider.fuzzyLookup?.('HERO-IDLE')).toHaveLength(1); + expect(provider.fuzzyLookup?.('character')).toHaveLength(1); + expect(provider.fuzzyLookup?.(' hero ')).toHaveLength(1); + expect(provider.fuzzyLookup?.('missing')).toEqual([]); }); it('同名不同目录的素材是两条候选:显示名相同,但身份键不同', () => { @@ -116,7 +116,7 @@ describe('资源引用 provider', () => { ], }); - const candidates = sameName.match?.('') ?? []; + const candidates = sameName.fuzzyLookup?.('') ?? []; // 显示 token 会撞(都是 `@hero`),所以候选菜单的 key 不能拿 token 当身份。 expect(candidates.map((item) => chatReferenceMentionToken(item))).toEqual([ '@hero', @@ -127,7 +127,7 @@ describe('资源引用 provider', () => { ); }); - it('`candidates` 给粘贴解析用的全量清单:与菜单同一份可提及素材,但不受菜单截断影响', () => { + it('`lookup` 给精确查找用的全量清单:与菜单同一份可提及素材,但不受模糊过滤与截断影响', () => { const assets = [ heroAsset, asset('asset-orphan', 'image', 'image/png', ''), @@ -143,27 +143,27 @@ describe('资源引用 provider', () => { const scoped = createResourceReferenceProvider({ assets }); // 不可提及素材(没有 localPath)不在候选里,条目数与展示名口径与菜单一致。 - expect(scoped.match?.('')).toHaveLength(8); - expect(scoped.candidates?.()).toHaveLength(11); - expect(scoped.candidates?.().map(chatReferenceMentionToken)).toEqual( + expect(scoped.fuzzyLookup?.('')).toHaveLength(8); + expect(scoped.lookup?.()).toHaveLength(11); + expect(scoped.lookup?.().map(chatReferenceMentionToken)).toEqual( expect.arrayContaining(['@hero-idle', '@pic-0', '@pic-9']), ); // 粘贴解析只认「显示名逐字一致」的 token,所以候选的 token 必须是显示名形态。 expect( scoped - .candidates?.() + .lookup?.() .some( (item) => 'resourceId' in item && item.resourceId === 'asset-orphan', ), ).toBe(false); // 菜单就是「同一份候选 + query 过滤 + 截断」,所以截断后的前缀逐字一致。 - expect(scoped.candidates?.().slice(0, 8)).toEqual(scoped.match?.('')); + expect(scoped.lookup?.().slice(0, 8)).toEqual(scoped.fuzzyLookup?.('')); }); - it('清单为空时 `candidates` 是空数组:粘贴不会把任何 token 当成引用', () => { - expect( - createResourceReferenceProvider({ assets: [] }).candidates?.(), - ).toEqual([]); + it('清单为空时 `lookup` 是空数组:粘贴不会把任何 token 当成引用', () => { + expect(createResourceReferenceProvider({ assets: [] }).lookup?.()).toEqual( + [], + ); }); it('`toReference` 只认资源 part:资产已删除时不合成引用', () => { @@ -186,7 +186,7 @@ describe('资源引用 provider', () => { }); it('`refresh` 按 manifest 换显示名:改名换新引用、未变恒等、已删除原样返回', () => { - const reference = provider.match?.('hero')?.[0] as ResourceReference; + const reference = provider.fuzzyLookup?.('hero')?.[0] as ResourceReference; expect(provider.refresh(reference)).toBe(reference); const renamed = createResourceReferenceProvider({ @@ -344,7 +344,7 @@ describe('运行画面区域 provider', () => { }); describe('Skill provider', () => { - it('触发符是 `$`:挂载与 `match` 都不发查询,菜单第一次打开时才读应用级目录', async () => { + it('触发符是 `$`:挂载与 `fuzzyLookup` 都不发查询,菜单第一次打开时才读应用级目录', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'list_agc_skill_catalog') { return [{ name: 'agc-test-skill', description: '测试 Skill' }]; @@ -358,9 +358,9 @@ describe('Skill provider', () => { expect(result.current.trigger).toBe('$'); expect(invoke).not.toHaveBeenCalled(); - // `match` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。 + // `fuzzyLookup` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。 act(() => { - expect(result.current.match?.('')).toEqual([]); + expect(result.current.fuzzyLookup?.('')).toEqual([]); }); expect(invoke).not.toHaveBeenCalled(); @@ -372,7 +372,7 @@ describe('Skill provider', () => { expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); }); await waitFor(() => { - expect(result.current.match?.('')).toHaveLength(1); + expect(result.current.fuzzyLookup?.('')).toHaveLength(1); }); // 目录只读一次:后续每次敲 `$` 都复用同一份候选。 expect(invoke).toHaveBeenCalledTimes(2); @@ -381,13 +381,13 @@ describe('Skill provider', () => { result.current.onMenuQueryChange?.(null); }); expect(invoke).toHaveBeenCalledTimes(2); - expect(result.current.match?.('测试')?.[0]).toMatchObject({ + expect(result.current.fuzzyLookup?.('测试')?.[0]).toMatchObject({ type: 'skill', name: 'agc-test-skill', }); }); - it('粘贴补读:`candidates` 保持纯函数(目录没到就是空数组),`onPasteText` 只在文本含 `$` 时补读一次', async () => { + it('`lookup` 是纯函数:目录还没读就返回空数组、也不发起读取,菜单打开后才查得到', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'list_agc_skill_catalog') { return [{ name: 'agc-test-skill', description: '测试 Skill' }]; @@ -399,35 +399,22 @@ describe('Skill provider', () => { const { result } = renderHook(() => useSkillReferenceProvider()); - // 冷启动:粘贴解析只能看到「当前就绪」的候选,目录没读过就是空数组,本次 `$名称` 逐字保留。 + // 冷启动:精确查找只能看到「此刻就绪」的候选,目录没读过就是空数组——粘贴 `$名称` 因此按字面保留。 act(() => { - expect(result.current.candidates?.()).toEqual([]); + expect(result.current.lookup?.()).toEqual([]); }); expect(invoke).not.toHaveBeenCalled(); - // 不含触发符的粘贴不触发任何读取。 + // 菜单第一次打开才读目录,读回之后精确查找立刻可用。 act(() => { - result.current.onPasteText?.('把这一版改成夜景'); - }); - expect(invoke).not.toHaveBeenCalled(); - - // 含 `$` 的粘贴补发一次懒加载:本次仍解析不出,下一次粘贴起才有候选。 - act(() => { - result.current.onPasteText?.('用 $agc-test-skill 出图'); + result.current.onMenuQueryChange?.(''); }); await waitFor(() => { - expect(result.current.candidates?.()).toHaveLength(1); + expect(result.current.lookup?.()).toHaveLength(1); }); - expect(invoke).toHaveBeenCalledTimes(2); - expect( - result.current.candidates?.().map(chatReferenceMentionToken), - ).toEqual(['$agc-test-skill']); - - // 已就绪后再粘贴同一个 token,不会再读一次目录。 - act(() => { - result.current.onPasteText?.('用 $agc-test-skill 出图'); - }); - expect(invoke).toHaveBeenCalledTimes(2); + expect(result.current.lookup?.().map(chatReferenceMentionToken)).toEqual([ + '$agc-test-skill', + ]); }); it('内置目录与已启用客户端 Skill 合并后按名字去重,并截断到 8 条', async () => { @@ -476,17 +463,17 @@ describe('Skill provider', () => { result.current.onMenuQueryChange?.(''); }); await waitFor(() => { - expect(result.current.match?.('')).toHaveLength(8); + expect(result.current.fuzzyLookup?.('')).toHaveLength(8); }); // 同名客户端项被内置项挡掉,上限只作用于当前查询的命中集合。 - expect(result.current.match?.('builtin-0')).toHaveLength(1); - expect(result.current.match?.('builtin-')).toHaveLength(8); - expect(result.current.match?.('client-skill')).toMatchObject([ + expect(result.current.fuzzyLookup?.('builtin-0')).toHaveLength(1); + expect(result.current.fuzzyLookup?.('builtin-')).toHaveLength(8); + expect(result.current.fuzzyLookup?.('client-skill')).toMatchObject([ { type: 'skill', name: 'client-skill' }, ]); // 未启用与非 Skill 扩展都不是候选。 - expect(result.current.match?.('client-off')).toEqual([]); - expect(result.current.match?.('client-plugin')).toEqual([]); + expect(result.current.fuzzyLookup?.('client-off')).toEqual([]); + expect(result.current.fuzzyLookup?.('client-plugin')).toEqual([]); }); it('一次瞬时失败不锁死候选:下一次 `match` 还会重读目录,并在控制台留痕', async () => { @@ -514,7 +501,7 @@ describe('Skill provider', () => { result.current.onMenuQueryChange?.('a'); }); await waitFor(() => { - expect(result.current.match?.('')).toHaveLength(1); + expect(result.current.fuzzyLookup?.('')).toHaveLength(1); }); expect(invoke).toHaveBeenCalledTimes(4); // 读取失败不能静默:控制台要留下可排障的一条。 diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 0b8cd75bf..ea1feaa2a 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -87,6 +87,45 @@ function defineEventClassShim(name: 'DragEvent' | 'ClipboardEvent') { defineEventClassShim('DragEvent'); defineEventClassShim('ClipboardEvent'); +/** + * jsdom 的 `Range` 没有 `getBoundingClientRect`,而候选菜单打开时会用它测锚点位置 + * (`LexicalTypeaheadMenuPlugin` 的 `getRect`);真实浏览器两者都在,这里只补测试环境缺的那部分。 + */ +function defineRangeRectShim() { + if (typeof Range === 'undefined') return; + if (typeof Range.prototype.getBoundingClientRect === 'function') return; + Range.prototype.getBoundingClientRect = () => + typeof DOMRect === 'function' + ? new DOMRect() + : ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + } as DOMRect); +} + +defineRangeRectShim(); + +/** jsdom 没有 `ResizeObserver`,候选菜单打开时会构造它;真实浏览器都有。 */ +function defineResizeObserverShim() { + if (typeof window === 'undefined') return; + if (typeof window.ResizeObserver === 'function') return; + window.ResizeObserver = class ResizeObserverStub { + observe() {} + + unobserve() {} + + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +defineResizeObserverShim(); + function asset( id: string, kind: string, @@ -480,7 +519,7 @@ describe('ResourceReferenceInput', () => { expect(draftResourceIds(ref.current?.getDraft())).toEqual([]); }); - test('Skill 目录冷启动:第一次粘贴 `$名称` 保持字面并补读目录,第二次粘贴才成 chip', async () => { + test('Skill 目录冷启动:粘贴 `$名称` 保持字面,敲过一次 `$` 之后粘贴才成 chip', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'list_agc_skill_catalog') { return [{ name: 'agc-test-skill', description: '测试 Skill' }]; @@ -501,17 +540,24 @@ describe('ResourceReferenceInput', () => { onChange={vi.fn()} />, ); + + // 冷目录:精确查找没有候选,`$名称` 逐字保留,也不替用户去读目录。 + pasteComposerText('用 $agc-test-skill 出图'); + await waitFor(() => { + expect(draftText(ref.current?.getDraft())).toBe( + '用 $agc-test-skill 出图', + ); + }); expect(invoke).not.toHaveBeenCalled(); - // 冷目录:候选还没到,`$名称` 逐字保留,同时补读一次目录。 - pasteComposerText('用 $agc-test-skill 出图'); + // 用户敲出 `$`(菜单懒加载)之后目录才就绪,此后粘贴才重建 chip。 + act(() => { + ref.current?.clear(); + }); + insertComposerText('$'); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); }); - expect(draftText(ref.current?.getDraft())).toBe( - '用 $agc-test-skill 出图', - ); - act(() => { ref.current?.clear(); }); diff --git a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md index 7b098a22a..47cadf7df 100644 --- a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md +++ b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md @@ -16,17 +16,21 @@ - 输入区删除 `versions` / `activeVersionId` / `showTriggerButton` / `onReferencePickerOpen` / `skills`,`assets` 被注入的 provider 取代;`projectPath` 只保留给输入区自己的润色链路。 - 输入区与宿主之间只留三个通用接缝:`providers`(引用来源)、`inputActions`(操作排里的宿主控件,例如 `@` 触发钮)、`submitSuppressed`(宿主浮层打开时 Enter 让位)。引用种类一个都不进输入区。 -- `provider.match` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。 +- `provider.fuzzyLookup` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。 - 附件并入 `ChatReference`,编辑器收敛为单一引用节点类型,附件 chip 的 DOM 契约逐字保留;附件导入成功后以芯片进入正文,失败不插入;控制器不再持有附件数组,`MAX_CHAT_COMPOSER_ATTACHMENTS` 改为按草稿中的附件芯片数计算,导入进行中禁止发送。 - 用户可见行为保持不变,唯一例外是已裁决的缺陷修复:非 DirectProject 宿主不再出现 `$` Skill 候选。 ## 修订(2026-09-22):粘贴解析需要第二道只读缝 -粘贴进来的纯文本要按同一套引用文本语法反解析回正文芯片,因此 provider 在 `match(query)`(菜单形状:按 query 过滤并截断)之外,再暴露两项可选能力: +粘贴进来的纯文本要按同一套引用文本语法反解析回正文芯片,因此 provider 的两个查询能力按「模糊 / 精确」分开命名,各自说清自己的语义: -- `candidates()`:当前就绪的全部候选,与菜单共用同一份对象集合,不截断、不按 query 过滤;仍是纯函数,由输入区在粘贴事件里同步调用,数据没到就是空数组。 -- `onPasteText(text)`:粘贴文本里可能出现本 provider 的 token 时的补读钩子;输入区不等待也不依赖它的结果(Skill 目录是应用级异步读取,冷启动时本次粘贴解析不出,下一次才有候选)。 +- `fuzzyLookup(query)`:候选菜单那条路——按 query 做包含匹配、大小写不敏感,并在 provider 内部截断到候选上限。名字写明它是模糊的,避免被拿去反查 token。 +- `lookup()`:精确查找那条路——某一刻 provider 真正能解析出的全部引用,不做模糊过滤、不截断,与菜单共用同一份候选来源;仍是纯函数,由输入区在粘贴事件里同步调用,数据没到就是空数组。 -这没有推翻本 ADR 的懒加载结论:`onMenuQueryChange` 仍是菜单那条懒加载路径,`onPasteText` 只是让「用户把 `$名称` 贴进来」等价于「他自己敲了 `$`」,且**不改变**「挂载即查询会让工作区路径非法时也产生后端访问」这条边界。附件与运行画面区域仍是静默 provider:它们没有候选,所以粘贴解析不认 `@附件名` / `@区域标签`,这两类 token 粘贴时逐字保留(附件与运行区域的身份来自文件与 run,纯文本重建不出来)。 +这不推翻本 ADR 的懒加载结论:`onMenuQueryChange` 仍是唯一的懒加载入口,粘贴解析**只用此刻就绪的候选**,不等待、不补读。Skill 目录因此还是「用户第一次敲出 `$` 才读」——冷启动时粘贴 `$名称` 就按字面文本保留(看得见、不是猜错),不为了粘贴去提前读盘。 + +附件与运行画面区域仍是静默 provider:它们没有候选,所以粘贴解析不认 `@附件名` / `@区域标签`,这两类 token 粘贴时逐字保留(附件与运行区域的身份来自文件与 run,纯文本重建不出来)。 歧义口径:同一个 token 对应多条引用身份(同名素材)时一律按文本保留;解析只认显示名逐字一致(不认扩展名、resourceId、大小写变体),未命中的 token 与其余文字逐字保留。 + +(本次把上一条同名决策里的 `match` / `candidates` 改名为 `fuzzyLookup` / `lookup`,语义不变;旧名不再保留。) diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md index d48ed7993..0e049968a 100644 --- a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -13,7 +13,7 @@ 3. 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、不猜路径或文件名。 4. 只有真的解析出引用时才接管:同 namespace 的 `application/x-lexical-editor` 负载、不含 token 的纯文本、图片文件粘贴一律放行编辑器默认导入,现有粘贴行为逐字不变。 5. 附件与运行画面区域不参与粘贴解析(静默 provider 没有候选),它们的 token 粘贴时按文本保留。 -6. Skill 目录冷启动不阻塞粘贴:`candidates()` 是纯函数,目录没到就是空数组(本次 `$名称` 保留为文本),`onPasteText` 补读一次让下一次粘贴能解析。 +6. Skill 目录冷启动不阻塞粘贴:`lookup()` 是纯函数,目录没到就是空数组——冷启动时粘贴 `$名称` 保留为文本(不等待、不补读),用户敲过一次 `$` 后即可解析。 ## 流程判定 @@ -22,7 +22,7 @@ ## 提交切分 1. **反解析口径**:`resourceReferences.ts` 新增 `buildContentFromPastedText(text, references)`(粘贴侧唯一反解析;与 `buildContentFromTextTokens` 同一套边界规则),配规则矩阵单测。 -2. **provider 契约**:`reference-source/types.ts` 新增可选的 `candidates()` 与 `onPasteText(text)`;`resourceReferenceProvider` 给全量可提及候选,`skillReferenceProvider` 给去重后的 Skill 候选并按需补读目录。 +2. **provider 契约**:`reference-source/types.ts` 把菜单查询改名为 `fuzzyLookup(query)`、新增精确查找 `lookup()`(两者共用同一份候选来源);`resourceReferenceProvider` 给全量可提及候选,`skillReferenceProvider` 给去重后的 Skill 候选。 3. **输入区接管**:`ResourceReferenceInput` 注册 `COMMAND_PRIORITY_CRITICAL` 的 `PASTE_COMMAND`,只在解析出引用时 `preventDefault` 并在一次 `editor.update`(`PASTE_TAG`)内按选区插入;配集成用例。 4. **文档**:`CONTEXT.md` 术语、本计划、ADR 修订节、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md` 与决策记录。 @@ -38,6 +38,6 @@ ## 执行状态(2026-09-22) -已完成:`buildContentFromPastedText` 与规则矩阵单测(命中 / 未命中 / 相邻中文 / 扩展名 / 大小写 / 全角 / 同名歧义 / 重复出现 / 换行 / 与显示口径互为逆运算);provider 的 `candidates` 与 `onPasteText` 及用例;输入区 `PASTE_COMMAND` 接管与 4 条集成用例(粘贴重建芯片、未命中保持字面、纯文本走默认导入、Lexical 负载让位、Skill 冷启动补读);文档同步。 +已完成:`buildContentFromPastedText` 与规则矩阵单测(命中 / 未命中 / 相邻中文 / 扩展名 / 大小写 / 全角 / 同名歧义 / 重复出现 / 换行 / 与显示口径互为逆运算);provider 的 `fuzzyLookup` / `lookup` 及用例;输入区 `PASTE_COMMAND` 接管与 5 条集成用例(粘贴重建芯片、未命中保持字面、纯文本走默认导入、Lexical 负载让位、Skill 冷启动保持字面且敲过 `$` 后可解析);文档同步。 未做(本次范围外):斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片的解析、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f73ffbccf..00b1d093d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,12 +5,12 @@ - 背景:引用输入区的 `@` / `$` 只由 `LexicalTypeaheadMenuPlugin` 的逐字敲击触发,粘贴走 Lexical 默认路径(`text/plain` → 纯文本),所以从用户消息气泡复制回来的 `@显示名` / `$名称` 粘进来就是死文本;同时 chip 的 `text/plain` 是占位符,复制出去再粘回来必然丢引用(气泡显示文本才是完整 token 的形态)。 - 决策(口径):新增粘贴侧唯一反解析 `buildContentFromPastedText(text, references)`(`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`),候选由宿主注入的 provider 枚举,token 就是 `chatReferenceMentionToken`——与出站显示逐字同一个字符串,所以**不做任何兼容别名**:不认 `@hero.png`、`resourceId`、大小写变体、全角 `@`;边界仍是「行首 / 行尾或空白」(显示侧 token 前后补空白,两端自洽)。 - 决策(宁可不成芯片也不能认错):同一个 token 对应多条引用身份(同名素材)时一律按文本保留;未命中的 token 静默保留、不提示、不猜文件名或路径;附件与运行画面区域不参与(静默 provider 没有候选,`@附件名` / `@区域标签` 按文本保留)。 -- 决策(provider 契约加两道可选缝):`ReferenceProvider.candidates()` 给「当前就绪的全部候选」(不截断、不按 query 过滤,与菜单共用同一份集合),`onPasteText(text)` 是粘贴时的补读钩子(输入区不等待、不依赖结果)。这不推翻上一步「懒加载只走 `onMenuQueryChange`」的结论:`onPasteText` 只是让「用户把 `$名称` 贴进来」等价于「他自己敲了 `$`」,预载 Skill 目录与「工作台路径非法时不产生后端访问」的边界都不动。 +- 决策(provider 契约按「模糊 / 精确」分两个口):上一条里的 `match(query)` 改名 `fuzzyLookup(query)`(名字写明它是包含匹配 + 截断的模糊菜单查询),`candidates()` 改名 `lookup()`(精确查找用的、就绪的全量候选,不模糊不截断),两者共用同一份候选来源。粘贴解析只用 `lookup()` 此刻就绪的候选:不等待、不补读,也不为了解析去提前读盘;Skill 目录仍是「用户第一次敲出 `$` 才读」,冷启动时粘贴 `$名称` 保持字面文本,敲过一次 `$` 后即可重建芯片。曾一度加过的 `onPasteText` 补读钩子已删除——它既不改变本次粘贴的结果,又让输入区反过来关心 provider 的触发符。 - 决策(接管范围):输入区在 `COMMAND_PRIORITY_CRITICAL` 注册 `PASTE_COMMAND`,**只在真的解析出引用时**接管(同 namespace 的 `application/x-lexical-editor` 负载、无 token 纯文本、图片文件一律 `return false` 走默认导入);接管时一次 `editor.update(..., { tag: PASTE_TAG })` 内按选区插入,所以一次 Ctrl+Z 整体回退,token 之外逐字保留。 - 原因:粘贴是用户此刻的编辑,事后回头改写他的输入(例如清单到齐后再把文本改成芯片)等于前端替用户重写内容;而任何「多候选取其一」「按文件名猜资源」的启发式都会制造看不出错的错引用。 - 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/{resourceReferences.ts,ResourceReferenceInput.tsx,reference-source/{types.ts,resourceReferenceProvider.ts,skillReferenceProvider.ts}}`、`apps/ai-game-creator-shell/tests/{resourceReferences.test.ts,referenceSourceProviders.test.ts,resourceReferenceInput.test.tsx}`、`CONTEXT.md`、`docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md`(修订节)、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、本文件。 - 未纳入本次:斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。 -- 验证方式:`buildContentFromPastedText` 规则矩阵单测(含「显示文本再粘贴回来得到同一份 content」这条逆运算)、provider 的 `candidates` / `onPasteText` 用例、输入区集成用例(真 Lexical `paste` 事件 → 芯片、未命中等价于默认粘贴、Skill 冷启动补读);另跑 `npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +- 验证方式:`buildContentFromPastedText` 规则矩阵单测(含「显示文本再粘贴回来得到同一份 content」这条逆运算)、provider 的 `fuzzyLookup` / `lookup` 用例、输入区集成用例(真 Lexical `paste` 事件 → 芯片、未命中等价于默认粘贴、Skill 冷启动保持字面且敲过 `$` 后可解析);另跑 `npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 ## 2026-09-22 引用输入区改为宿主注入引用 provider,选择器面板与输入区分离 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index f8f415c1d..f75bb17f7 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -9,7 +9,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, `ResourceReferenceInput` 只接受宿主注入的一组「引用 provider」(`ReferenceProvider`,每种引用一个独立工厂): - `createResourceReferenceProvider({ assets })` → `@` 素材候选; -- `useSkillReferenceProvider()` → `$` Skill 候选(读取应用级 Skill 目录,只在用户第一次敲出 `$` 时发生——输入区在 effect 里回调 provider 的 `onMenuQueryChange`,`match` 本身是纯函数;失败会放开重试); +- `useSkillReferenceProvider()` → `$` Skill 候选(读取应用级 Skill 目录,只在用户第一次敲出 `$` 时发生——输入区在 effect 里回调 provider 的 `onMenuQueryChange`,`fuzzyLookup` 本身是纯函数;失败会放开重试); - 附件与运行画面区域是**静默 provider**(无触发符、无候选),只参与正文 part 的身份解析、改名刷新与文本形态。 输入区按 `providers` 数组顺序取第一个非空回答,不判断任何引用种类,也没有总装 builder。**没注入就没有这类引用**:只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill(Rust `direct_codex_user_item_to_codex_turn_input`),所以只有它注入 Skill provider;策划输入盒、画布生成面板、资源卡快速编辑与画布生成浮层只注入资源 + 两个静默 provider,不再出现退化成正文文本的 `$` 误导入口(已裁决的缺陷修复)。 @@ -39,7 +39,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、也不猜文件名或路径。 - 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。 - 只有真的解析出引用时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 -- Skill 目录是应用级异步读取,走候选菜单的懒加载口径:冷启动时第一次粘贴 `$名称` 会保持为文本,同时补读一次目录,下一次粘贴起才有候选(输入区不等待目录,粘贴不被网络或磁盘读取阻塞)。 +- 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。 +- Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。 ## 拖拽引用(2026-09-21) From 9e50947d0275e98eceb6c72f4c262bb57cf17eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 19:53:42 +0800 Subject: [PATCH 03/15] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=BE=93=E5=85=A5=E5=8C=BA=E7=9A=84=E9=80=89=E5=8C=BA?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E6=94=B6=E6=95=9B=E6=88=90=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出 $selectionOrRootEnd():选区缺失或指向已被重建掉的节点时统一回落到草稿末尾 insertReferences / insertText / $insertContentAtSelection 三处重复的兜底判断改为调用它 行为不变:插件插入与粘贴插入仍走同一套「插到草稿末尾」的策略,只是不再各写一份 --- .../ResourceReferenceInput.tsx | 82 ++++++++----------- 1 file changed, 34 insertions(+), 48 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 7deef1bc7..ffbbe4035 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 @@ -300,6 +300,22 @@ function applyContentToRoot( }); } +/** + * 取当前可用的选区:选区缺失、或指向已被重建掉的节点时(跨会话恢复草稿后就是这种), + * 统一回落到草稿末尾,避免把内容插到一个已经不存在的位置。取不到时返回 `null`。 + */ +function $selectionOrRootEnd() { + let selection = $getSelection(); + if ( + !$isRangeSelection(selection) || + !selection.anchor.getNode().isAttached() + ) { + $getRoot().selectEnd(); + selection = $getSelection(); + } + return $isRangeSelection(selection) ? selection : null; +} + /** * 粘贴插入:在光标处就地插入 content 对应的节点,正文其余部分逐字不动。 * @@ -311,31 +327,19 @@ function $insertContentAtSelection( content: readonly DirectCodexUserContentPart[], providers: readonly ReferenceProvider[], ) { - // 与 insertReferences 同一处置:选区可能指向已被重建掉的节点,统一回落到草稿末尾。 - // 每插一段都重新取一次选区:插入会移动光标,缓存下来的那个 RangeSelection 会过期。 - const currentSelection = () => { - let selection = $getSelection(); - if ( - !$isRangeSelection(selection) || - !selection.anchor.getNode().isAttached() - ) { - $getRoot().selectEnd(); - selection = $getSelection(); - } - return $isRangeSelection(selection) ? selection : null; - }; - if (!currentSelection()) return; + // 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。 + if (!$selectionOrRootEnd()) return; content.forEach((part) => { if (part.type === 'input_text') { part.text.split('\n').forEach((line, index) => { - if (index > 0) currentSelection()?.insertParagraph(); - if (line) currentSelection()?.insertText(line); + if (index > 0) $selectionOrRootEnd()?.insertParagraph(); + if (line) $selectionOrRootEnd()?.insertText(line); }); return; } const reference = referenceFromPart(providers, part); if (reference) { - currentSelection()?.insertNodes([ + $selectionOrRootEnd()?.insertNodes([ $createResourceReferenceNode(reference), ]); } @@ -413,24 +417,14 @@ function ResourceReferenceEditor({ (nextReferences: ChatReference[]) => { if (nextReferences.length === 0) return; editor.update(() => { - let selection = $getSelection(); - // 跨会话恢复草稿后选区可能仍指向已被重建掉的节点,这里统一回落到草稿末尾, - // 避免把引用插到一个已经不存在的位置。 - if ( - !$isRangeSelection(selection) || - !selection.anchor.getNode().isAttached() - ) { - $getRoot().selectEnd(); - selection = $getSelection(); - } - if ($isRangeSelection(selection)) { - selection.insertNodes( - nextReferences.flatMap((reference) => [ - $createResourceReferenceNode(reference), - $createTextNode(' '), - ]), - ); - } + const selection = $selectionOrRootEnd(); + if (!selection) return; + selection.insertNodes( + nextReferences.flatMap((reference) => [ + $createResourceReferenceNode(reference), + $createTextNode(' '), + ]), + ); }); editor.focus(); }, @@ -442,19 +436,11 @@ function ResourceReferenceEditor({ const insert = text.replace(/\s+$/u, ''); if (!insert.trim()) return; editor.update(() => { - let selection = $getSelection(); - if ( - !$isRangeSelection(selection) || - !selection.anchor.getNode().isAttached() - ) { - $getRoot().selectEnd(); - selection = $getSelection(); - } - if ($isRangeSelection(selection)) { - const rootText = $getRoot().getTextContent(); - if (rootText && !/\s$/u.test(rootText)) selection.insertText(' '); - selection.insertText(insert); - } + const selection = $selectionOrRootEnd(); + if (!selection) return; + const rootText = $getRoot().getTextContent(); + if (rootText && !/\s$/u.test(rootText)) selection.insertText(' '); + selection.insertText(insert); }); editor.focus(); }, From 14f11e77a2b4319b2697419b360e1dbbc2d28532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 19:54:36 +0800 Subject: [PATCH 04/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E8=A7=A3=E6=9E=90=E5=87=BA=E7=9A=84=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E8=8B=A5=E8=A7=A3=E6=9E=90=E4=B8=8D=E5=87=BA=E6=9D=A5=EF=BC=8C?= =?UTF-8?q?=E9=80=80=E5=8C=96=E6=88=90=20token=20=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E8=80=8C=E4=B8=8D=E6=98=AF=E6=95=B4=E6=9D=A1=E4=B8=A2=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $insertContentAtSelection 的引用分支补 else:referenceFromPart 返回 null 时插入该 part 的 token 文本 此前上游 buildContentFromPastedText 已把 token 摘走,插入端再丢一次就会让这段内容静默消失 新增用例:provider 认得出 token 但插入时已解析不出引用,断言正文保持 `看 @hero 一眼`、不产生芯片 变异验证:去掉该兜底后同一用例变红(正文变成 `看 一眼`),恢复即绿 --- .../ResourceReferenceInput.tsx | 7 +++++ .../tests/resourceReferenceInput.test.tsx | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+) 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 ffbbe4035..199b53997 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 @@ -322,6 +322,9 @@ function $selectionOrRootEnd() { * 与 `applyContentToRoot`(整根替换,供初始草稿与润色回写使用)的区别只在替换范围: * 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成 * chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。 + * + * 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本, + * 绝不静默丢掉——粘贴进来的内容一个字符都不会凭空消失。 */ function $insertContentAtSelection( content: readonly DirectCodexUserContentPart[], @@ -342,7 +345,11 @@ function $insertContentAtSelection( $selectionOrRootEnd()?.insertNodes([ $createResourceReferenceNode(reference), ]); + return; } + // 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。 + const token = mentionTokenFromPart(providers, part); + if (token) $selectionOrRootEnd()?.insertText(token); }); } diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index ea1feaa2a..0fdbd3298 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -33,6 +33,7 @@ import { attachmentReferenceProvider } from '../src/features/project-workspace/r import { createResourceReferenceProvider } from '../src/features/project-workspace/reference-source/resourceReferenceProvider'; import { runtimeRegionReferenceProvider } from '../src/features/project-workspace/reference-source/runtimeRegionReferenceProvider'; import { useSkillReferenceProvider } from '../src/features/project-workspace/reference-source/skillReferenceProvider'; +import type { ReferenceProvider } from '../src/features/project-workspace/reference-source/types'; import { ResourceReferenceInput, type ResourceReferenceInputHandle, @@ -458,6 +459,36 @@ describe('ResourceReferenceInput', () => { expect(editorTextSize()).toBe('看 '.length + 1 + ' 这一版'.length); }); + test('粘贴解析出的引用若已无法解析,退化成 token 文本而不是整条丢掉', async () => { + const ref = createRef(); + // provider 认得出这个 token(lookup 有候选),但插入那一刻已经解析不出引用(例如资产刚被删)。 + const ghostProvider: ReferenceProvider = { + trigger: '@', + fuzzy_lookup: () => [], + lookup: () => [resourceReferenceFromAsset(assets[0]!, 'asset-picker')], + toReference: () => null, + refresh: (reference) => reference, + mentionToken: (part) => + part.type === 'agc_resource_reference' ? '@hero' : null, + }; + render( + , + ); + + pasteComposerText('看 @hero 一眼'); + + await waitFor(() => { + expect(draftText(ref.current?.getDraft())).toBe('看 @hero 一眼'); + }); + expect(draftResourceIds(ref.current?.getDraft())).toEqual([]); + }); + test('粘贴未命中的 token:整段按字面落进正文,不接管、不提示', async () => { const ref = createRef(); render( From 072bf691d98f6fea8af632a3b68e8f3cddfbc693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 19:56:21 +0800 Subject: [PATCH 05/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E8=A7=A3=E6=9E=90=E6=B3=A8=E9=87=8A=E9=87=8C=E7=9A=84?= =?UTF-8?q?=20provider=20=E6=88=90=E5=91=98=E5=90=8D=E4=B8=8E=E5=AE=9E?= =?UTF-8?q?=E9=99=85=20API=20=E5=AF=B9=E9=BD=90=20buildContentFromPastedTe?= =?UTF-8?q?xt=20=E7=9A=84=E6=B3=A8=E9=87=8A=E4=BB=8D=E5=86=99=E7=9D=80?= =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E5=89=8D=E7=9A=84=20ReferenceProvid?= =?UTF-8?q?er.candidates=EF=BC=8C=E6=94=B9=E6=88=90=20lookup=20=E9=A1=BA?= =?UTF-8?q?=E5=B8=A6=E5=86=99=E6=98=8E=E5=80=99=E9=80=89=E6=9D=A5=E8=87=AA?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=96=B9=E4=BC=A0=E5=85=A5=E7=9A=84=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E6=B8=85=E5=8D=95=EF=BC=8C=E9=81=BF=E5=85=8D=E6=8A=8A?= =?UTF-8?q?=E3=80=8C=E8=B0=81=E5=8E=BB=E6=9F=A5=E5=80=99=E9=80=89=E3=80=8D?= =?UTF-8?q?=E8=AF=AF=E8=AE=B0=E8=BF=9B=E8=BF=99=E4=B8=AA=E7=BA=AF=E5=87=BD?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/project-workspace/resourceReferences.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index ed1d22f45..bc4e0dfb6 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -435,7 +435,8 @@ function countMentionTokenOccurrences(value: string, token: string) { /** * 粘贴文本 → canonical content(粘贴侧的唯一解析口径)。 * - * 候选由宿主注入的 provider 枚举(`ReferenceProvider.candidates`),token 就是 + * 候选来自调用方传入的引用清单(输入区给的是 `ReferenceProvider.lookup()` 此刻就绪的全量 + * 候选),token 就是 * `chatReferenceMentionToken`——与显示口径逐字同一个字符串,所以「从气泡复制再粘贴」不需要 * 任何兼容别名:`@显示名` / `$名称` 认得出,`@hero.png`、resourceId、大小写变体一律不认。 * From 5e36b615345126cc8136bbaa355fb6960f5e528c 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 10:40:21 +0800 Subject: [PATCH 06/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=90=8D=E5=86=85=E9=83=A8=E7=A9=BA=E7=99=BD=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=8A=98=E6=88=90=20`-`=EF=BC=8C=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E4=B8=8D=E5=86=8D=E5=A4=9A=E6=8F=92=E4=B8=80?= =?UTF-8?q?=E6=9E=9A=E8=8A=AF=E7=89=87=20resourceReferences.ts=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=85=B1=E4=BA=AB=20normalizeMentionName=EF=BC=9A?= =?UTF-8?q?=E5=86=85=E9=83=A8=E7=A9=BA=E7=99=BD=E6=8A=98=20`-`=E3=80=81?= =?UTF-8?q?=E8=A3=81=E6=8E=89=E9=A6=96=E5=B0=BE=EF=BC=8Ctoken=20=E5=B1=82?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E5=86=8D=E6=8A=98=E4=B8=80=E6=AC=A1=20?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E6=98=BE=E7=A4=BA=E5=90=8D=EF=BC=88resourceD?= =?UTF-8?q?isplayName=EF=BC=89=E3=80=81Skill=20=E5=90=8D=EF=BC=88=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E8=AF=BB=E5=85=A5=E4=B8=8E=20toReference=20/=20mentio?= =?UTF-8?q?nToken=EF=BC=89=E3=80=81=E9=99=84=E4=BB=B6=E5=90=8D=EF=BC=88?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=98=A0=E5=B0=84=E4=B8=8E=20toReference=20/?= =?UTF-8?q?=20mentionToken=EF=BC=89=E7=BB=9F=E4=B8=80=E8=BF=87=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E4=BB=BD=E5=8F=A3=E5=BE=84=20resourceDisplayName=20?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=20`||=20asset.id`=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=9A=E5=BC=95=E7=94=A8=E5=90=8D=E5=81=87=E5=AE=9A=E9=9D=9E?= =?UTF-8?q?=E7=A9=BA=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=9B=9E=E9=80=80=20resour?= =?UTF-8?q?ceId=20=E5=89=8D=E7=BC=80=E9=87=8D=E5=8F=A0=20bug=20=E7=94=B1?= =?UTF-8?q?=E6=AD=A4=E6=94=B6=E5=8F=A3=EF=BC=9A`@hero`=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=91=BD=E4=B8=AD=20`@hero-v2`=EF=BC=88=E5=90=8E?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=AD=97=E7=AC=A6=E4=B8=8D=E6=98=AF=E7=A9=BA?= =?UTF-8?q?=E7=99=BD=EF=BC=89=EF=BC=8C=E7=B2=98=E8=B4=B4=E5=8F=AA=E5=89=A9?= =?UTF-8?q?=E6=AD=A3=E7=A1=AE=E7=9A=84=E9=82=A3=E4=B8=80=E6=9E=9A=E8=8A=AF?= =?UTF-8?q?=E7=89=87=20=E6=96=B0=E5=A2=9E=E7=94=A8=E4=BE=8B=EF=BC=9A?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=20/=20Skill=20/=20=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E4=B8=89=E7=B1=BB=E5=BD=92=E4=B8=80=E5=8C=96=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E3=80=81=E5=89=8D=E7=BC=80=E9=87=8D=E5=8F=A0=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E3=80=81Skill=20=E7=9B=AE=E5=BD=95=E5=90=8D=E5=B8=A6=E7=A9=BA?= =?UTF-8?q?=E7=99=BD=E3=80=81=E9=99=84=E4=BB=B6=E5=90=8D=E5=B8=A6=E7=A9=BA?= =?UTF-8?q?=E7=99=BD=EF=BC=9B=E6=8A=8A=20normalizeMentionName=20=E5=8F=98?= =?UTF-8?q?=E5=BC=82=E6=88=90=E6=81=92=E7=AD=89=E5=87=BD=E6=95=B0=E5=90=8E?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=A8=E4=BE=8B=E5=85=A8=E7=BA=A2=20?= =?UTF-8?q?=E9=A1=BA=E5=B8=A6=E4=BF=AE=E6=8E=89=20referenceSourceProviders?= =?UTF-8?q?=20=E7=94=A8=E4=BE=8B=E9=87=8C=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E9=81=97=E7=95=99=E7=9A=84=20provider.match=20=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E4=B8=8E=20skill=20provider=20=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E9=87=8C=E7=9A=84=20match=20=E5=90=8C=E6=AD=A5=20CONTEXT.md=20?= =?UTF-8?q?=E6=9C=AF=E8=AF=AD=E3=80=81=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E3=80=81=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=E4=B8=8E=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 4 ++ .../attachmentReferenceProvider.ts | 11 +++- .../skillReferenceProvider.ts | 19 ++++--- .../project-workspace/resourceReferences.ts | 35 ++++++++++-- .../directCodexTurnAttachments.ts | 4 +- .../tests/referenceSourceProviders.test.ts | 57 ++++++++++++++++++- .../tests/resourceReferences.test.ts | 43 ++++++++++++++ .../【实施计划】引用粘贴解析-2026-09-22.md | 9 +++ .../shared-memory/decision-log.md | 10 ++++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 11 +++- 10 files changed, 184 insertions(+), 19 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 8755820a6..0ab68ba28 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -210,6 +210,10 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内 引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。 _Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段 +**引用名**: +引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`。 +_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白 + **引用候选枚举**: 一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。 _Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts index 762ef2657..b0b9a0afb 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts @@ -1,5 +1,8 @@ import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; -import type { ChatReference } from '../resourceReferences'; +import { + type ChatReference, + normalizeMentionName, +} from '../resourceReferences'; import type { ReferenceProvider } from './types'; /** canonical 附件 part → `ChatReference` 的附件成员(字段逐字对齐)。 */ @@ -8,7 +11,7 @@ export function attachmentReferenceFromPart( ): ChatReference { return { type: 'attachment', - name: part.name, + name: normalizeMentionName(part.name), mediaType: part.mediaType, size: part.size, localPath: part.localPath, @@ -32,7 +35,9 @@ export function createAttachmentReferenceProvider(): ReferenceProvider { refresh: (reference: ChatReference): ChatReference | null => reference.type === 'attachment' ? reference : null, mentionToken: (part) => - part.type === 'agc_attachment_reference' ? `@${part.name}` : null, + part.type === 'agc_attachment_reference' + ? `@${normalizeMentionName(part.name)}` + : null, }; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts index 228b6ddd3..e99648c42 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts @@ -2,7 +2,10 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../../app/tauri'; import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; -import type { ChatReference } from '../resourceReferences'; +import { + type ChatReference, + normalizeMentionName, +} from '../resourceReferences'; import type { ReferenceProvider } from './types'; /** 候选菜单最多展示多少条:与资源候选同一上限。 */ @@ -25,7 +28,7 @@ function loadSkillCatalog(): Promise { 'list_agc_skill_catalog', ).then((items) => items.map((item) => ({ - name: item.name, + name: normalizeMentionName(item.name), description: item.description, })), ), @@ -44,7 +47,7 @@ function loadSkillCatalog(): Promise { item.enabled && item.status === 'enabled', ) - .map((item) => ({ name: item.name })), + .map((item) => ({ name: normalizeMentionName(item.name) })), ), ]).then(([builtin, client]) => [...builtin, ...client]); } @@ -61,7 +64,7 @@ function matchesSkillQuery( ); } -/** 目录项 → 引用:同名只留第一条(与 `match` 同一份去重口径)。 */ +/** 目录项 → 引用:同名只留第一条(与 `fuzzyLookup` 同一份去重口径)。 */ function skillReferences(skills: readonly SkillCatalogItem[]) { const seen = new Set(); const references: ChatReference[] = []; @@ -83,7 +86,7 @@ function skillReferences(skills: readonly SkillCatalogItem[]) { * 与资源 provider 不同,Skill 候选是**异步**的应用级读取,所以它必须是一份 React 状态: * 用户敲出 `$` 打开候选菜单时(`onMenuQueryChange` 收到非 `null`,由输入区在 effect 里回调) * 发起读取,结果到了之后宿主重渲染,输入区随之拿到新的候选。 - * `match` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。 + * `fuzzyLookup` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。 * 读取本身不进输入区,只有宿主才知道这条路该不该存在—— * 目前只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill。 */ @@ -127,12 +130,14 @@ export function useSkillReferenceProvider(): ReferenceProvider { }, toReference: (part: DirectCodexUserContentPart): ChatReference | null => part.type === 'agc_skill_reference' - ? { type: 'skill', name: part.name } + ? { type: 'skill', name: normalizeMentionName(part.name) } : null, refresh: (reference: ChatReference): ChatReference | null => reference.type === 'skill' ? reference : null, mentionToken: (part) => - part.type === 'agc_skill_reference' ? `$${part.name}` : null, + part.type === 'agc_skill_reference' + ? `$${normalizeMentionName(part.name)}` + : null, }), [ensureCatalog, skills], ); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index bc4e0dfb6..fe81b1f3c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -154,6 +154,21 @@ export function resourceLabelResolver( }; } +/** + * 引用名(资源显示名 / Skill 名 / 附件名)的空白不变量:`@显示名`、`$名称`、`@附件名` 里 + * 不允许出现空白,内部空白统一折成 `-`。 + * + * 引用的名字同时就是它在正文里的 token,而 token 的边界规则是「前后为空白或行首行尾」 + * (`isMentionTokenBoundary`)。名字里一旦有空白,`@hero v2` 在反解析时会被切成 `@hero` + + * 文本 `v2`:短名字抢先命中,真正的引用反而变成补在末尾的孤儿。空白折成 `-` 之后 token 自带 + * 边界,`@hero` 不会再命中 `@hero-v2`(后一个字符是 `-`,不是空白)。 + * + * 不在这里做 `resourceId` 之类的兜底:引用名假定非空,空名字属于上游数据问题,不靠兜底掩盖。 + */ +export function normalizeMentionName(value: string) { + return value.trim().replace(/\s+/gu, '-'); +} + /** * canonical content → 可读文本;每个引用 part 经 `tokenOf` 展开,文本 part 逐字保留。 * @@ -209,10 +224,14 @@ export function directCodexContentToPromptText( ) { return joinMentionText(content, (part) => { if (part.type === 'input_text') return null; - if (part.type === 'agc_attachment_reference') return `@${part.name}`; - if (part.type === 'agc_skill_reference') return `$${part.name}`; + if (part.type === 'agc_attachment_reference') + return `@${normalizeMentionName(part.name)}`; + if (part.type === 'agc_skill_reference') + return `$${normalizeMentionName(part.name)}`; if (part.type === 'agc_runtime_region_reference') return `@${part.label}`; - return `@${resolveResourceLabel(part.resourceId) ?? part.resourceId}`; + return `@${normalizeMentionName( + resolveResourceLabel(part.resourceId) ?? part.resourceId, + )}`; }); } @@ -290,8 +309,12 @@ export function chatReferenceToContentPart( * 资源与运行画面区域 `@显示名`。资源引用自带显示名,所以这里不必再查 manifest。 */ export function chatReferenceMentionToken(reference: ChatReference): string { - if (reference.type === 'skill') return `$${reference.name}`; - if (reference.type === 'attachment') return `@${reference.name}`; + if (reference.type === 'skill') + return `$${normalizeMentionName(reference.name)}`; + if (reference.type === 'attachment') + return `@${normalizeMentionName(reference.name)}`; + if (reference.type === 'resource') + return `@${normalizeMentionName(reference.label)}`; return `@${reference.label}`; } @@ -605,7 +628,7 @@ export function directCodexContentToLegacyContentDto( export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id; - return fileName.replace(/\.[^.]+$/u, '').trim() || asset.id; + return normalizeMentionName(fileName.replace(/\.[^.]+$/u, '')); } export function resourceReferenceFromAsset( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts index aee761531..c5c7ec553 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts @@ -1,4 +1,5 @@ import type { LauncherImportedAttachment } from '../../../../app/types'; +import { normalizeMentionName } from '../../../../features/project-workspace/resourceReferences'; export type DirectCodexTurnAttachment = { name: string; @@ -16,7 +17,8 @@ export function toDirectCodexTurnAttachments( } return imported.map((item) => { const attachment: DirectCodexTurnAttachment = { - name: item.fileName, + // 附件名同时是正文里的 `@附件名` token,所以和其它引用名一样不允许空白(见 normalizeMentionName)。 + name: normalizeMentionName(item.fileName), mediaType: item.mediaType, }; if ( diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index 3b0178857..6cfb4cd75 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -230,7 +230,23 @@ describe('资源引用 provider', () => { describe('附件 provider', () => { it('静默:没有触发符也没有候选,注入它不会加出任何入口', () => { expect(attachmentReferenceProvider.trigger).toBe(null); - expect(attachmentReferenceProvider.match).toBeUndefined(); + expect(attachmentReferenceProvider.fuzzyLookup).toBeUndefined(); + }); + + it('附件名过同一份空白口径:`toReference` 与 `mentionToken` 都是 `@brief-v2.md`', () => { + const part = { + type: 'agc_attachment_reference' as const, + name: 'brief v2.md', + mediaType: 'text/markdown', + size: 128, + localPath: 'notes/brief v2.md', + status: 'imported', + }; + expect(attachmentReferenceProvider.toReference(part)).toMatchObject({ + type: 'attachment', + name: 'brief-v2.md', + }); + expect(attachmentReferenceProvider.mentionToken(part)).toBe('@brief-v2.md'); }); it('`toReference` 逐字搬运附件字段,并只认附件 part', () => { @@ -387,6 +403,45 @@ describe('Skill provider', () => { }); }); + it('目录里的名字带空白时折成 `-`:候选与 token 是同一份口径', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'list_agc_skill_catalog') { + return [{ name: 'agc test skill', description: '测试 Skill' }]; + } + if (command === 'list_client_extensions') { + return [ + { + name: 'client skill', + extensionType: 'skill', + enabled: true, + status: 'enabled', + }, + ]; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke: invoke as never } }; + + const { result } = renderHook(() => useSkillReferenceProvider()); + act(() => { + result.current.onMenuQueryChange?.(''); + }); + await waitFor(() => { + expect(result.current.lookup?.()).toHaveLength(2); + }); + expect( + result.current + .lookup?.() + .map((item) => (item.type === 'skill' ? item.name : null)), + ).toEqual(['agc-test-skill', 'client-skill']); + expect( + result.current.mentionToken?.({ + type: 'agc_skill_reference', + name: 'agc test skill', + }), + ).toBe('$agc-test-skill'); + }); + it('`lookup` 是纯函数:目录还没读就返回空数组、也不发起读取,菜单打开后才查得到', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'list_agc_skill_catalog') { diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index fe78e5bc3..ce275ecc3 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -6,8 +6,11 @@ import { type ChatComposerDraft, chatComposerDraftToDirectCodexUserItem, type ChatReference, + chatReferenceMentionToken, directCodexContentToPromptText, hasMeaningfulDirectCodexContent, + normalizeMentionName, + resourceDisplayName, resourceLabelResolver, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; @@ -196,6 +199,46 @@ describe('粘贴文本反解析', () => { ]); }); + it('引用名内部空白折成 `-`:资源显示名 / Skill 名 / 附件名同一条口径', () => { + expect(normalizeMentionName('hero v2')).toBe('hero-v2'); + expect(normalizeMentionName(' 英雄\u3000参考\t')).toBe('英雄-参考'); + expect( + resourceDisplayName(assetEntry('hero-v2', 'assets/hero v2.png')), + ).toBe('hero-v2'); + expect( + chatReferenceMentionToken({ type: 'skill', name: 'image gen' }), + ).toBe('$image-gen'); + expect( + chatReferenceMentionToken({ + type: 'attachment', + name: 'brief v2.md', + mediaType: 'text/markdown', + size: 1, + localPath: 'notes/brief v2.md', + status: 'imported', + }), + ).toBe('@brief-v2.md'); + }); + + it('空白折成 `-` 后 token 自带边界:前缀重叠不再多插一个 chip', () => { + const heroV2Asset = assetEntry('hero-v2', 'assets/hero v2.png'); + const heroV2 = resourceReferenceFromAsset(heroV2Asset, 'asset-picker'); + const displayed = directCodexContentToPromptText( + [ + { type: 'input_text', text: '看' }, + { type: 'agc_resource_reference', resourceId: 'hero-v2' }, + { type: 'input_text', text: '这一版' }, + ], + resourceLabelResolver([heroAsset, heroV2Asset]), + ); + expect(displayed).toBe('看 @hero-v2 这一版'); + expect(buildContentFromPastedText(displayed, [hero, heroV2])).toEqual([ + { type: 'input_text', text: '看 ' }, + { type: 'agc_resource_reference', resourceId: 'hero-v2' }, + { type: 'input_text', text: ' 这一版' }, + ]); + }); + it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => { const content: DirectCodexUserContentPart[] = [ { type: 'input_text', text: '看 ' }, diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md index 0e049968a..cc60b645a 100644 --- a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -41,3 +41,12 @@ 已完成:`buildContentFromPastedText` 与规则矩阵单测(命中 / 未命中 / 相邻中文 / 扩展名 / 大小写 / 全角 / 同名歧义 / 重复出现 / 换行 / 与显示口径互为逆运算);provider 的 `fuzzyLookup` / `lookup` 及用例;输入区 `PASTE_COMMAND` 接管与 5 条集成用例(粘贴重建芯片、未命中保持字面、纯文本走默认导入、Lexical 负载让位、Skill 冷启动保持字面且敲过 `$` 后可解析);文档同步。 未做(本次范围外):斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片的解析、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。 + +## 追加执行状态(2026-09-23):前缀重叠按「引用名无空白」收口 + +自动评审留下的唯一破坏性项(`@hero` 与 `@hero v2` 互为前缀时粘贴会多插一枚短名芯片)不改反解析,改为把不变量前移到引用名: + +- `resourceReferences.ts` 新增共享 `normalizeMentionName(value)`(内部空白折 `-`、裁首尾),素材显示名(`resourceDisplayName`)、Skill 名(目录读入与 `toReference` / `mentionToken`)、附件名(导入映射与 `toReference` / `mentionToken`)与两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)统一过这一份;token 层重复归一化是幂等的。 +- 名称里不再做 `resourceId` 兜底(`resourceDisplayName` 去掉 `|| asset.id`);假定引用名非空。 +- 代价与取舍:`hero v2` 与 `hero-v2` 归一化后同名时走既有的「同名多候选按文本保留」;改动前生成的旧文本里的 `@hero v2` 不再解析。 +- 验证:`normalizeMentionName` / 显示名 / token 口径单测 + 前缀重叠回归用例(归一化后粘贴只剩正确的那一枚芯片)+ provider 两处用例;把 `normalizeMentionName` 变异成恒等后新增用例全红。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00b1d093d..ffed66ba5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName` + +- 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。 +- 决策:把不变量前移到引用名——`@显示名` / `$名称` / `@附件名` 的名字内部不允许空白,统一经共享 `normalizeMentionName(value)`(内部空白折成 `-`、裁掉首尾)处理。落点是名字的产生处:`resourceDisplayName()`、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`,外加两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)——token 层幂等再折一次,「token 里没有空白」就是不变量本身的性质,不依赖上游数据干净。 +- 决策(不兜底):引用名假定非空,不做 `resourceId` 之类的兜底;`resourceDisplayName` 原来的 `|| asset.id` 一并去掉。 +- 原因:不改反解析是因为粘贴解析与润色回包共用 `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` 不再解析,重试 / 润色回填时那条引用会退化成末尾孤儿(内容不丢、位置可能不对)。 +- 验证方式:`normalizeMentionName`、`resourceDisplayName`、`chatReferenceMentionToken` 的口径单测;「空白折 `-` 后 token 自带边界、前缀重叠只剩正确芯片」的回归用例;Skill 目录名带空白与附件名带空白的 provider 用例;把 `normalizeMentionName` 变异成恒等函数后以上新增用例全部变红。另跑受影响用例、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-09-22 引用粘贴解析:只认显示口径的 token,宁可不成芯片也不能认错 - 背景:引用输入区的 `@` / `$` 只由 `LexicalTypeaheadMenuPlugin` 的逐字敲击触发,粘贴走 Lexical 默认路径(`text/plain` → 纯文本),所以从用户消息气泡复制回来的 `@显示名` / `$名称` 粘进来就是死文本;同时 chip 的 `text/plain` 是占位符,复制出去再粘回来必然丢引用(气泡显示文本才是完整 token 的形态)。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index f75bb17f7..554cae23d 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -1,6 +1,6 @@ # AGC 聊天素材引用 -更新时间:2026-09-22 +更新时间:2026-09-23 AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 @@ -42,6 +42,15 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。 - Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。 +## 引用名的空白不变量(2026-09-23) + +引用的名字(素材显示名、Skill 名、附件名)同时就是它在正文里的 token,所以**名字内部不允许出现空白**:内部空白统一经共享的 `normalizeMentionName` 折成 `-`(`hero v2.png` → `@hero-v2`、`my skill` → `$my-skill`),首尾空白照旧裁掉。 + +- 归一化落在名字的产生处:`resourceDisplayName()`(素材显示名由文件名派生)、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`、以及两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)。token 层再折一次是幂等的,因此「token 里绝不会有空白」是这一层的性质,不依赖上游数据干净。 +- 为什么必须有这条不变量:token 的边界规则是「前后为空白或行首行尾」。名字里带空白时,`@hero v2` 会被反解析切成 `@hero` + 文本 `v2`,短名字抢先命中,真正的引用反而被当成孤儿补到正文末尾(粘贴回填会多出一枚错芯片)。折成 `-` 之后 token 自带边界,`@hero` 不会命中 `@hero-v2`(后一个字符是 `-`)。 +- 名字里不做 `resourceId` 之类的兜底:引用名假定非空。 +- 已存在的老文本(本次改动前生成的回合 prompt、历史气泡)里的 `@hero v2` 不再解析——粘贴时按字面保留,重试 / 润色回填时该引用会退化成末尾孤儿。内容不丢,位置可能不对。 + ## 拖拽引用(2026-09-21) 除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。 From 4e90465c4579006021c17ec96054cca51bb79e7c 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 11:53:14 +0800 Subject: [PATCH 07/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E6=98=BE=E7=A4=BA=E5=90=8D=E5=9C=A8=E8=AF=8D=E5=B9=B2?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA=E6=97=B6=E5=9B=9E=E9=80=80=20asset.id?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=BA=A7=E7=94=9F=E7=A9=BA=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=90=8D=20resourceDisplayName=20=E5=9C=A8=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8D=E5=8E=BB=E6=8E=89=E6=89=A9=E5=B1=95=E5=90=8D?= =?UTF-8?q?=E5=90=8E=E4=B8=BA=E7=A9=BA=EF=BC=88`.env`=20/=20`.gitignore`?= =?UTF-8?q?=20=E8=BF=99=E7=B1=BB=E6=95=B4=E5=90=8D=E5=B0=B1=E6=98=AF?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=90=8D=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=89?= =?UTF-8?q?=E6=97=B6=E5=9B=9E=E9=80=80=20asset.id=EF=BC=8C=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E5=80=BC=E5=90=8C=E6=A0=B7=E8=BF=87=20normalizeMentio?= =?UTF-8?q?nName=EF=BC=9B=E7=A9=BA=E5=90=8D=E5=AD=97=E4=BC=9A=E8=AE=A9=20t?= =?UTF-8?q?oken=20=E9=80=80=E5=8C=96=E6=88=90=E5=8F=AA=E6=9C=89=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E7=AC=A6=E7=9A=84=E8=A3=B8=20`@`=EF=BC=8C=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=8F=9C=E5=8D=95=E9=87=8C=E6=98=AF=E7=A9=BA=E8=8A=AF?= =?UTF-8?q?=E7=89=87=E3=80=81=E7=B2=98=E8=B4=B4=E8=A7=A3=E6=9E=90=E8=BF=98?= =?UTF-8?q?=E4=BC=9A=E8=AE=A4=E9=A2=86=E6=AD=A3=E6=96=87=E9=87=8C=E4=BB=BB?= =?UTF-8?q?=E4=BD=95=E4=B8=80=E5=A4=84=E8=A3=B8=20`@`=20normalizeMentionNa?= =?UTF-8?q?me=20=E7=9A=84=E6=B3=A8=E9=87=8A=E6=94=B9=E4=B8=BA=E3=80=8C?= =?UTF-8?q?=E5=8F=AA=E5=81=9A=E5=BD=92=E4=B8=80=E5=8C=96=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E5=81=9A=E5=85=9C=E5=BA=95=E3=80=8D=EF=BC=8C=E6=8A=8A=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E7=9A=84=E8=90=BD=E7=82=B9=E5=86=99=E6=98=8E=E5=9C=A8?= =?UTF-8?q?=E5=90=8D=E5=AD=97=E7=9A=84=E6=9D=A5=E6=BA=90=E4=BE=A7=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=A8=E4=BE=8B=EF=BC=9A`.env`=20=E4=B8=8E?= =?UTF-8?q?=20`config/.gitignore`=20=E7=9A=84=E6=98=BE=E7=A4=BA=E5=90=8D?= =?UTF-8?q?=E5=9B=9E=E9=80=80=20asset.id=E3=80=81=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E4=B8=8D=E5=86=8D=E4=B8=BA=E7=A9=BA=EF=BC=9B?= =?UTF-8?q?=E6=8A=8A=E8=AF=A5=E5=9B=9E=E9=80=80=E5=8E=BB=E6=8E=89=E5=90=8E?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=8F=98=E7=BA=A2=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95=EF=BC=88=E6=A0=A1=E6=AD=A3?= =?UTF-8?q?=E3=80=8C=E4=B8=8D=E5=81=9A=20resourceId=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E3=80=8D=E4=B8=80=E6=9D=A1=EF=BC=89=E3=80=81AGC=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=B4=A0=E6=9D=90=E5=BC=95=E7=94=A8=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E4=B8=8E=E5=BC=95=E7=94=A8=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-workspace/resourceReferences.ts | 14 ++++++++++++-- .../tests/resourceReferences.test.ts | 13 +++++++++++++ .../plans/【实施计划】引用粘贴解析-2026-09-22.md | 2 +- docs/project-memory/shared-memory/decision-log.md | 1 + docs/【功能说明】AGC聊天素材引用-2026-09-08.md | 2 +- 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index fe81b1f3c..bef2cd660 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -163,7 +163,8 @@ export function resourceLabelResolver( * 文本 `v2`:短名字抢先命中,真正的引用反而变成补在末尾的孤儿。空白折成 `-` 之后 token 自带 * 边界,`@hero` 不会再命中 `@hero-v2`(后一个字符是 `-`,不是空白)。 * - * 不在这里做 `resourceId` 之类的兜底:引用名假定非空,空名字属于上游数据问题,不靠兜底掩盖。 + * 这里只做归一化、不做 `resourceId` 之类的兜底:兜底属于名字的来源侧(例如 + * `resourceDisplayName` 在文件名词干为空时回退 `asset.id`),归一化本身保持是个纯函数。 */ export function normalizeMentionName(value: string) { return value.trim().replace(/\s+/gu, '-'); @@ -626,9 +627,18 @@ export function directCodexContentToLegacyContentDto( }; } +/** + * 素材显示名:文件名去掉扩展名,再过一遍引用名口径(见 `normalizeMentionName`)。 + * + * 整名就是扩展名时(`.env`、`.gitignore`)去扩展名会得到空串,这里回退 `asset.id`: + * 显示名同时是正文里的 token,空名字会退化成只有触发符的裸 `@`——候选菜单里是一枚空芯片, + * 粘贴解析还会拿它认领正文里任何一处裸 `@`(见 `buildContentFromPastedText`)。 + * 与渲染侧「显示名解析不到就用 `resourceId`」是同一口径。 + */ export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id; - return normalizeMentionName(fileName.replace(/\.[^.]+$/u, '')); + const stem = fileName.replace(/\.[^.]+$/u, ''); + return normalizeMentionName(stem || asset.id); } export function resourceReferenceFromAsset( diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index ce275ecc3..aee75e632 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -239,6 +239,19 @@ describe('粘贴文本反解析', () => { ]); }); + it('整名就是扩展名(`.env` / `.gitignore`)时显示名回退 asset.id,不留空名', () => { + // 空名字会退化成只有触发符的裸 `@` token:菜单里是一枚空芯片,粘贴解析还会认领正文里 + // 任何一处裸 `@`(见「退化 token 不参与解析」用例)。 + expect(resourceDisplayName(assetEntry('dotenv', '.env'))).toBe('dotenv'); + expect( + resourceDisplayName(assetEntry('gitignore', 'config/.gitignore')), + ).toBe('gitignore'); + expect( + resourceReferenceFromAsset(assetEntry('dotenv', '.env'), 'asset-picker') + .label, + ).toBe('dotenv'); + }); + it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => { const content: DirectCodexUserContentPart[] = [ { type: 'input_text', text: '看 ' }, diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md index cc60b645a..966cab5fb 100644 --- a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -47,6 +47,6 @@ 自动评审留下的唯一破坏性项(`@hero` 与 `@hero v2` 互为前缀时粘贴会多插一枚短名芯片)不改反解析,改为把不变量前移到引用名: - `resourceReferences.ts` 新增共享 `normalizeMentionName(value)`(内部空白折 `-`、裁首尾),素材显示名(`resourceDisplayName`)、Skill 名(目录读入与 `toReference` / `mentionToken`)、附件名(导入映射与 `toReference` / `mentionToken`)与两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)统一过这一份;token 层重复归一化是幂等的。 -- 名称里不再做 `resourceId` 兜底(`resourceDisplayName` 去掉 `|| asset.id`);假定引用名非空。 +- 名称里不做 `resourceId` 兜底这一条按评审校正:词干为空(`.env` / `.gitignore` 这类整名就是扩展名的文件)时 `resourceDisplayName` 回退 `asset.id`,回退值同样过 `normalizeMentionName`;`normalizeMentionName` 自己仍只做归一化。 - 代价与取舍:`hero v2` 与 `hero-v2` 归一化后同名时走既有的「同名多候选按文本保留」;改动前生成的旧文本里的 `@hero v2` 不再解析。 - 验证:`normalizeMentionName` / 显示名 / token 口径单测 + 前缀重叠回归用例(归一化后粘贴只剩正确的那一枚芯片)+ provider 两处用例;把 `normalizeMentionName` 变异成恒等后新增用例全红。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 85f5b4e34..494a01517 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,6 +5,7 @@ - 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。 - 决策:把不变量前移到引用名——`@显示名` / `$名称` / `@附件名` 的名字内部不允许空白,统一经共享 `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` 自己没有兜底、只做归一化这条不变。 - 原因:不改反解析是因为粘贴解析与润色回包共用 `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 554cae23d..549cb3e06 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -48,7 +48,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 归一化落在名字的产生处:`resourceDisplayName()`(素材显示名由文件名派生)、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`、以及两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)。token 层再折一次是幂等的,因此「token 里绝不会有空白」是这一层的性质,不依赖上游数据干净。 - 为什么必须有这条不变量:token 的边界规则是「前后为空白或行首行尾」。名字里带空白时,`@hero v2` 会被反解析切成 `@hero` + 文本 `v2`,短名字抢先命中,真正的引用反而被当成孤儿补到正文末尾(粘贴回填会多出一枚错芯片)。折成 `-` 之后 token 自带边界,`@hero` 不会命中 `@hero-v2`(后一个字符是 `-`)。 -- 名字里不做 `resourceId` 之类的兜底:引用名假定非空。 +- 词干为空的素材名回退 `asset.id`:整名就是扩展名时(`.env` / `.gitignore`)文件名去掉扩展名会得到空串,空名字会让 token 退化成只有触发符的裸 `@`——候选菜单里是一枚空芯片,粘贴解析还会拿它认领正文里任何一处裸 `@`。所以 `resourceDisplayName()` 在这一种情况下回退 `asset.id`,回退值同样过一遍 `normalizeMentionName`;`normalizeMentionName` 自身仍然只做归一化、不做兜底。 - 已存在的老文本(本次改动前生成的回合 prompt、历史气泡)里的 `@hero v2` 不再解析——粘贴时按字面保留,重试 / 润色回填时该引用会退化成末尾孤儿。内容不丢,位置可能不对。 ## 拖拽引用(2026-09-21) From 3f9c698a67eb82972243a27e65452d62a2fd1e13 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 11:54:09 +0800 Subject: [PATCH 08/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E8=A7=A3=E6=9E=90=E8=B7=B3=E8=BF=87=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E7=AC=A6=E7=9A=84=E9=80=80=E5=8C=96=20token?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=94=99=E8=AE=A4=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E8=A3=B8=20`@`=20buildContentFromPastedText?= =?UTF-8?q?=20=E7=BB=84=E8=A3=85=E5=80=99=E9=80=89=E6=97=B6=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=20token=20=E9=95=BF=E5=BA=A6=E5=B0=8F=E4=BA=8E?= =?UTF-8?q?=E7=AD=89=E4=BA=8E=201=20=E7=9A=84=E6=9D=A1=E7=9B=AE=EF=BC=88?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E5=90=8D=E4=B8=BA=E7=A9=BA=E6=97=B6=20token?= =?UTF-8?q?=20=E5=B0=B1=E6=98=AF=E8=A3=B8=20`@`=20/=20`$`=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E8=BE=B9=E7=95=8C=E8=A7=84=E5=88=99=E4=BC=9A=E8=AE=A9?= =?UTF-8?q?=E5=AE=83=E5=9C=A8=E6=AD=A3=E6=96=87=E9=87=8C=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E5=88=B0=E4=BB=BB=E4=BD=95=E4=B8=80=E5=A4=84=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E7=AC=A6=EF=BC=8C=E8=AE=A4=E4=B8=8B=E6=9D=A5=E7=AD=89=E4=BA=8E?= =?UTF-8?q?=E6=8A=8A=E6=97=A0=E5=85=B3=E6=96=87=E5=AD=97=E9=94=99=E8=AE=A4?= =?UTF-8?q?=E6=88=90=E4=B8=80=E6=9D=A1=E5=BC=95=E7=94=A8=20=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E6=B3=A8=E9=87=8A=E7=9A=84=E4=BF=9D=E5=AE=88=E8=A7=84?= =?UTF-8?q?=E5=88=99=E4=BB=8E=E4=B8=A4=E6=9D=A1=E8=A1=A5=E6=88=90=E4=B8=89?= =?UTF-8?q?=E6=9D=A1=EF=BC=8C=E5=86=99=E6=98=8E=E7=A9=BA=E5=90=8D=E5=AD=97?= =?UTF-8?q?=E6=8C=89=E3=80=8C=E5=AE=81=E5=8F=AF=E4=B8=8D=E6=88=90=20chip?= =?UTF-8?q?=E3=80=8D=E7=9A=84=E5=90=8C=E4=B8=80=E5=8F=A3=E5=BE=84=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=20=E6=96=B0=E5=A2=9E=E7=94=A8=E4=BE=8B=EF=BC=9A?= =?UTF-8?q?=E7=A9=BA=E5=90=8D=E5=AD=97=E5=BC=95=E7=94=A8=E7=9A=84=20token?= =?UTF-8?q?=20=E6=98=AF=20`@`=20=E6=97=B6=E4=B8=8D=E8=AE=A4=E9=A2=86?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E9=87=8C=E7=9A=84=E8=A3=B8=20`@`=E3=80=81?= =?UTF-8?q?=E5=85=B6=E4=BD=99=20token=20=E7=85=A7=E5=B8=B8=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=EF=BC=9B=E5=8E=BB=E6=8E=89=E8=AF=A5=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=90=8E=E7=94=A8=E4=BE=8B=E5=8F=98=E7=BA=A2?= =?UTF-8?q?=20=E5=90=8C=E6=AD=A5=20AGC=20=E8=81=8A=E5=A4=A9=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=BC=95=E7=94=A8=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E7=9A=84=E7=B2=98=E8=B4=B4=E8=A7=A3=E6=9E=90=E5=B0=8F=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-workspace/resourceReferences.ts | 7 +++++- .../tests/resourceReferences.test.ts | 23 +++++++++++++++++++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 1 + 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index bef2cd660..08bfe5250 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -464,9 +464,11 @@ function countMentionTokenOccurrences(value: string, token: string) { * `chatReferenceMentionToken`——与显示口径逐字同一个字符串,所以「从气泡复制再粘贴」不需要 * 任何兼容别名:`@显示名` / `$名称` 认得出,`@hero.png`、resourceId、大小写变体一律不认。 * - * 两条保守规则: + * 三条保守规则: * * - 同名多候选(同一个 token 对应多条引用身份)一律按文本保留——宁可不成 chip,也不能认错引用。 + * - 退化 token(只有触发符的裸 `@` / `$`,即引用名为空)不参与解析:边界规则会把正文里 + * 任何一处裸 `@` 当成它,等于把无关文字错认成一条引用。 * - 只把正文里真的出现过的 token 交给 `buildContentFromTextTokens`,不做「未命中候选补到末尾」 * (那是润色回包的语义,照搬会把整份清单追加到粘贴文本后面)。同一 token 出现几次就展开几条 * 候选,所以重复出现的引用会各自原位成 chip。 @@ -492,6 +494,9 @@ export function buildContentFromPastedText( } const candidates: ContentTokenCandidate[] = []; for (const [token, bucket] of referencesByToken) { + // 名字为空的引用只会给出一个裸触发符:它能在正文里匹配到任何一处 `@` / `$`, + // 认下来就是错认。空名字是上游数据问题,这里按「宁可不成 chip」的同一口径跳过。 + if (token.length <= 1) continue; if (bucket.length !== 1) continue; const reference = bucket[0]!; const part = chatReferenceToContentPart(reference); diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index aee75e632..3ed6d96c5 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -252,6 +252,29 @@ describe('粘贴文本反解析', () => { ).toBe('dotenv'); }); + it('退化 token(名字为空只剩触发符)不参与解析,正文里的裸 `@` 逐字保留', () => { + // 上游仍然可能送来空名字的引用(显示名兜底只是把常见路径堵上),裸 `@` token 会在正文里 + // 匹配到任何一处 `@`,认下来就是错认:这里必须按「宁可不成 chip」跳过。 + const nameless: ChatReference = { + ...resourceReferenceFromAsset( + assetEntry('dotenv', '.env'), + 'asset-picker', + ), + label: '', + }; + expect(chatReferenceMentionToken(nameless)).toBe('@'); + expect( + buildContentFromPastedText('@ 单独一个 @ 符号,看 @hero', [ + nameless, + hero, + ]), + ).toEqual([ + { type: 'input_text', text: '@ 单独一个 @ 符号,看 ' }, + { type: 'agc_resource_reference', resourceId: 'hero' }, + ]); + expect(buildContentFromPastedText('@ 只有裸符号', [nameless])).toBeNull(); + }); + it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => { const content: DirectCodexUserContentPart[] = [ { type: 'input_text', text: '看 ' }, diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 549cb3e06..f57cbf43e 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -37,6 +37,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 只认与显示口径逐字一致的 token:`@显示名`(素材)与 `$名称`(Skill)。`@hero.png`、资源 ID、大小写变体、全角 `@` 都不解析;token 前后必须是行首 / 行尾或空白(与出站文本「token 前后各留一个空白」自洽),所以从用户消息气泡复制出来的那段文字粘回来会重建同一批芯片。 - 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、也不猜文件名或路径。 +- 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。 - 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。 - 只有真的解析出引用时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 - 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。 From 3391ecf7d44ef89a3b1aec9c01659ec43c35d571 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 11:55:40 +0800 Subject: [PATCH 09/15] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=9A=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E6=8E=A5=E7=AE=A1=E6=94=B9=E4=B8=BA=E3=80=8C=E7=9C=9F?= =?UTF-8?q?=E7=9A=84=E6=8F=92=E8=BF=9B=E6=AD=A3=E6=96=87=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=E3=80=8D=E6=89=8D=E7=94=9F=E6=95=88=EF=BC=8C=E6=8F=92=E5=85=A5?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA=E6=97=B6=E6=94=BE=E8=A1=8C=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=20ResourceReferenceInput=20=E7=9A=84=20PASTE?= =?UTF-8?q?=5FCOMMAND=20=E5=A4=84=E7=90=86=E5=99=A8=E5=8E=9F=E5=85=88?= =?UTF-8?q?=E5=85=88=20preventDefault=20=E5=86=8D=E5=8E=BB=E6=8F=92?= =?UTF-8?q?=E5=85=A5=EF=BC=9A=E6=8F=92=E5=85=A5=E4=B8=BA=E7=A9=BA=E6=97=B6?= =?UTF-8?q?=E8=BF=99=E6=AE=B5=E6=96=87=E5=AD=97=E6=97=A2=E4=B8=8D=E8=BF=9B?= =?UTF-8?q?=E6=88=91=E4=BB=AC=E7=9A=84=E6=8F=92=E5=85=A5=E3=80=81=E5=8F=88?= =?UTF-8?q?=E8=A2=AB=E6=8C=A1=E6=8E=89=E4=BA=86=E9=BB=98=E8=AE=A4=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E3=80=82=E7=8E=B0=E5=9C=A8=E5=85=88=E5=81=9A=E6=8F=92?= =?UTF-8?q?=E5=85=A5=EF=BC=8C=E5=8F=AA=E6=9C=89=E6=8F=92=E5=85=A5=E7=A1=AE?= =?UTF-8?q?=E5=AE=9E=E5=8F=91=E7=94=9F=E4=BA=86=E6=89=8D=20preventDefault?= =?UTF-8?q?=20=E5=B9=B6=E8=BF=94=E5=9B=9E=20true=EF=BC=8C=E5=90=A6?= =?UTF-8?q?=E5=88=99=E8=BF=94=E5=9B=9E=20false=20=E4=BA=A4=E7=BB=99=20Lexi?= =?UTF-8?q?cal=20=E7=9A=84=E9=BB=98=E8=AE=A4=E7=B2=98=E8=B4=B4=20$insertCo?= =?UTF-8?q?ntentAtSelection=20=E6=94=B9=E4=B8=BA=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E3=80=8C=E6=8F=92=E8=BF=9B=E5=8E=BB=E6=B2=A1=E6=9C=89=E3=80=8D?= =?UTF-8?q?=EF=BC=9A=E9=80=89=E5=8C=BA=E5=8F=96=E4=B8=8D=E5=88=B0=E3=80=81?= =?UTF-8?q?=E6=88=96=E6=AF=8F=E4=B8=AA=20part=20=E9=83=BD=E6=B2=A1?= =?UTF-8?q?=E8=90=BD=E4=B8=8B=E5=86=85=E5=AE=B9=E6=97=B6=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=20false=20=E7=BC=96=E8=BE=91=E5=99=A8=E5=B7=B2=E5=9C=A8?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=9B=B4=E6=96=B0=E9=87=8C=E6=97=B6=20editor?= =?UTF-8?q?.update=20=E4=BC=9A=E6=8A=8A=E5=9B=9E=E8=B0=83=E6=8E=92?= =?UTF-8?q?=E9=98=9F=EF=BC=8C=E8=BF=99=E6=97=B6=E6=8C=89=E5=8E=9F=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=85=88=E6=8E=A5=E7=AE=A1=EF=BC=88ranSync=20?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=8C=BA=E5=88=86=E3=80=8C=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E5=B7=B2=E5=90=8C=E6=AD=A5=E8=B7=91=E5=AE=8C=E3=80=8D=E4=B8=8E?= =?UTF-8?q?=E3=80=8C=E5=9B=9E=E8=B0=83=E8=A2=AB=E6=8E=92=E9=98=9F=E3=80=8D?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E9=81=BF=E5=85=8D=E8=AF=AF=E5=88=A4=E6=88=90?= =?UTF-8?q?=E6=8F=92=E5=85=A5=E4=B8=BA=E7=A9=BA=E8=80=8C=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E7=B2=98=E8=B4=B4=E9=80=A0=E6=88=90=E5=8F=8C?= =?UTF-8?q?=E4=BB=BD=E6=8F=92=E5=85=A5=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=E7=9A=84=E7=B2=98=E8=B4=B4=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=B0=8F=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResourceReferenceInput.tsx | 43 ++++++++++++++----- .../【功能说明】AGC聊天素材引用-2026-09-08.md | 2 +- 2 files changed, 34 insertions(+), 11 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 199b53997..633b73c26 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 @@ -325,18 +325,28 @@ function $selectionOrRootEnd() { * * 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本, * 绝不静默丢掉——粘贴进来的内容一个字符都不会凭空消失。 + * + * 返回「这次到底插进去没有」:调用方据此决定要不要接管这次粘贴,插入为空时必须放行 + * 编辑器的默认粘贴,否则这段文字两边都不管。 */ function $insertContentAtSelection( content: readonly DirectCodexUserContentPart[], providers: readonly ReferenceProvider[], -) { +): boolean { // 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。 - if (!$selectionOrRootEnd()) return; + if (!$selectionOrRootEnd()) return false; + let inserted = false; content.forEach((part) => { if (part.type === 'input_text') { part.text.split('\n').forEach((line, index) => { - if (index > 0) $selectionOrRootEnd()?.insertParagraph(); - if (line) $selectionOrRootEnd()?.insertText(line); + if (index > 0) { + $selectionOrRootEnd()?.insertParagraph(); + inserted = true; + } + if (line) { + $selectionOrRootEnd()?.insertText(line); + inserted = true; + } }); return; } @@ -345,12 +355,17 @@ function $insertContentAtSelection( $selectionOrRootEnd()?.insertNodes([ $createResourceReferenceNode(reference), ]); + inserted = true; return; } // 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。 const token = mentionTokenFromPart(providers, part); - if (token) $selectionOrRootEnd()?.insertText(token); + if (token) { + $selectionOrRootEnd()?.insertText(token); + inserted = true; + } }); + return inserted; } /** @@ -567,9 +582,9 @@ function ResourceReferenceEditor({ ); }, [editor, multiline]); - // 粘贴解析:只有「粘贴文本里真的解析出了引用 token」时才接管,其余一律返回 false 放行 - // Lexical 的默认导入——同 namespace 复制出来的 Lexical payload 本来就能还原真 chip, - // 不含 token 的纯文本、图片文件粘贴也都保持原行为。 + // 粘贴解析:只有「粘贴文本里真的解析出了引用 token」且「这一整段真的插进了正文」时才接管, + // 其余一律返回 false 放行 Lexical 的默认导入——同 namespace 复制出来的 Lexical payload + // 本来就能还原真 chip,不含 token 的纯文本、图片文件粘贴也都保持原行为。 // // 接管时整段文本在一次 update 内插入(与默认粘贴同样打 PASTE_TAG),所以一次 Ctrl+Z 就整体 // 回退;token 之外的每个字符逐字保留(换行落成段落分隔,与默认纯文本粘贴同形状)。 @@ -592,14 +607,22 @@ function ResourceReferenceEditor({ ); const content = buildContentFromPastedText(text, references); if (!content) return false; - event.preventDefault(); + // 先真的插进去,再决定接管这次粘贴:插入为空(取不到选区)时必须放行默认粘贴, + // 否则这段文字既没进我们的插入、又被 preventDefault 挡掉了默认导入,静默消失。 + // 编辑器已经在一次更新里时 `editor.update` 会把回调排队,这时 `ranSync` 仍是 false, + // 按原口径先接管,等队列里的那次插入落地。 + let ranSync = false; + let inserted = false; editor.update( () => { - $insertContentAtSelection(content, providersRef.current); + ranSync = true; + inserted = $insertContentAtSelection(content, providersRef.current); }, // 与编辑器默认粘贴同一口径:粘贴是它自己的一条撤销记录。 { tag: PASTE_TAG }, ); + if (ranSync && !inserted) return false; + event.preventDefault(); return true; }, COMMAND_PRIORITY_CRITICAL, diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index f57cbf43e..b91a0eee4 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -39,7 +39,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、也不猜文件名或路径。 - 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。 - 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。 -- 只有真的解析出引用时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 +- 只有真的解析出引用、并且这一整段真的插进了正文时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。取不到选区时插入为空,这时也放行默认粘贴,粘贴的文字不会「两边都不管」。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 - 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。 - Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。 From c9bb5a93f0ea652e798ca419ed39cf182fc2c4ae 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 11:55:53 +0800 Subject: [PATCH 10/15] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E5=BC=95=E7=94=A8=E5=90=8D=E5=BD=92=E4=B8=80=E5=8C=96?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E6=92=9E=E5=90=8D=EF=BC=8C=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E7=A2=B0=E6=92=9E=E7=9B=AE=E5=89=8D=E5=8F=AA=E5=9C=A8=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E4=BE=A7=E5=85=9C=E4=BD=8F=20normalizeMentionName=20?= =?UTF-8?q?=E7=9A=84=E6=B3=A8=E9=87=8A=E8=A1=A5=E4=B8=8A=E3=80=8C=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=8C=96=E4=B8=8D=E4=BF=9D=E8=AF=81=E5=90=8D=E5=AD=97?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E3=80=8D=EF=BC=9A`hero=20v2`=20=E4=B8=8E=20`?= =?UTF-8?q?hero-v2`=20=E6=8A=98=E6=88=90=E5=90=8C=E4=B8=80=E4=B8=AA=20toke?= =?UTF-8?q?n=EF=BC=8C=E5=80=99=E9=80=89=E8=8F=9C=E5=8D=95=E4=BE=A7?= =?UTF-8?q?=E4=B8=8D=E5=81=9A=E5=86=B2=E7=AA=81=E6=A3=80=E6=B5=8B=E3=80=81?= =?UTF-8?q?=E4=BC=9A=E6=98=BE=E7=A4=BA=E5=90=8C=E4=B8=80=E4=B8=AA=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=EF=BC=8C=E7=A2=B0=E6=92=9E=E7=94=B1=20buildContentFro?= =?UTF-8?q?mPastedText=20=E7=9A=84=E3=80=8C=E5=90=8C=E5=90=8D=E5=A4=9A?= =?UTF-8?q?=E5=80=99=E9=80=89=E4=B8=80=E5=BE=8B=E6=8C=89=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E3=80=8D=E5=85=9C=E4=BD=8F=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20AGC=20=E8=81=8A=E5=A4=A9=E7=B4=A0=E6=9D=90=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E=E7=9A=84=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=90=8D=E7=A9=BA=E7=99=BD=E4=B8=8D=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=B0=8F=E8=8A=82=EF=BC=8C=E5=86=99=E6=98=8E=E8=BF=99=E6=98=AF?= =?UTF-8?q?=E5=B7=B2=E7=9F=A5=E6=AE=8B=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/project-workspace/resourceReferences.ts | 4 ++++ docs/【功能说明】AGC聊天素材引用-2026-09-08.md | 1 + 2 files changed, 5 insertions(+) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 08bfe5250..440e70d6b 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -165,6 +165,10 @@ export function resourceLabelResolver( * * 这里只做归一化、不做 `resourceId` 之类的兜底:兜底属于名字的来源侧(例如 * `resourceDisplayName` 在文件名词干为空时回退 `asset.id`),归一化本身保持是个纯函数。 + * + * 归一化不保证名字唯一:`hero v2` 与 `hero-v2` 会折成同一个 token,两条引用因此在候选菜单里 + * 显示同一个标签。归一化后的碰撞由 `buildContentFromPastedText` 的「同名多候选一律按文本保留」 + * 兜住(不认错,但两者都成不了 chip);候选菜单侧不做冲突检测,标签重复是这条取舍的可见残留。 */ export function normalizeMentionName(value: string) { return value.trim().replace(/\s+/gu, '-'); diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index b91a0eee4..3bbd8ac33 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -50,6 +50,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 归一化落在名字的产生处:`resourceDisplayName()`(素材显示名由文件名派生)、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`、以及两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)。token 层再折一次是幂等的,因此「token 里绝不会有空白」是这一层的性质,不依赖上游数据干净。 - 为什么必须有这条不变量:token 的边界规则是「前后为空白或行首行尾」。名字里带空白时,`@hero v2` 会被反解析切成 `@hero` + 文本 `v2`,短名字抢先命中,真正的引用反而被当成孤儿补到正文末尾(粘贴回填会多出一枚错芯片)。折成 `-` 之后 token 自带边界,`@hero` 不会命中 `@hero-v2`(后一个字符是 `-`)。 - 词干为空的素材名回退 `asset.id`:整名就是扩展名时(`.env` / `.gitignore`)文件名去掉扩展名会得到空串,空名字会让 token 退化成只有触发符的裸 `@`——候选菜单里是一枚空芯片,粘贴解析还会拿它认领正文里任何一处裸 `@`。所以 `resourceDisplayName()` 在这一种情况下回退 `asset.id`,回退值同样过一遍 `normalizeMentionName`;`normalizeMentionName` 自身仍然只做归一化、不做兜底。 +- 归一化不保证名字唯一:`hero v2` 与 `hero-v2` 会折成同一个 token,候选菜单里两条候选显示同一个标签,从气泡复制出来的那段文字也重建不成芯片(解析侧按「同名多候选一律按文本保留」处理,不认错)。菜单侧目前不做冲突检测,这是这条不变量的已知残留。 - 已存在的老文本(本次改动前生成的回合 prompt、历史气泡)里的 `@hero v2` 不再解析——粘贴时按字面保留,重试 / 润色回填时该引用会退化成末尾孤儿。内容不丢,位置可能不对。 ## 拖拽引用(2026-09-21) From f138f648e7844521b83bf65cd36dac240923a386 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 12:12:34 +0800 Subject: [PATCH 11/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E6=8F=92=E5=85=A5=E8=A7=A3=E6=9E=90=E4=B8=8D=E5=87=BA?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E6=97=B6=E8=90=BD=E4=B8=8B=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E5=BD=A2=E6=80=81=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=BB=80=E4=B9=88?= =?UTF-8?q?=E9=83=BD=E4=B8=8D=E6=8F=92=20resourceReferences.ts=20=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=20contentPartToken=EF=BC=9Apart=20=E2=86=92=20?= =?UTF-8?q?=E6=AD=A3=E6=96=87=20token=20=E7=9A=84=E9=80=90=E5=AD=97?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=EF=BC=8C=E4=B8=8D=E8=A1=A5=E7=99=BD=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E8=A3=81=E5=89=AA=EF=BC=88=E8=A1=A5=E7=99=BD=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=20joinMentionText=20=E6=8B=BC=E6=95=B4=E6=AE=B5?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E6=97=B6=E5=8A=A0=EF=BC=89=EF=BC=9BdirectCod?= =?UTF-8?q?exContentToPromptText=20=E4=B8=8E=E6=96=B0=E5=AF=BC=E5=87=BA=20?= =?UTF-8?q?contentPartText=20=E9=83=BD=E8=B5=B0=E8=BF=99=E4=B8=80=E4=BB=BD?= =?UTF-8?q?=20contentPartText=20=E5=B0=B1=E6=98=AF=E5=8D=95=E4=B8=AA?= =?UTF-8?q?=E9=9D=9E=E6=96=87=E6=9C=AC=20part=20=E7=9A=84=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=BD=A2=E6=80=81=EF=BC=9A=E8=B5=84=E6=BA=90=E8=90=BD?= =?UTF-8?q?=20@resourceId=E3=80=81Skill=20=E8=90=BD=20$=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E3=80=81=E9=99=84=E4=BB=B6=E8=90=BD=20@=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=E3=80=81=E8=BF=90=E8=A1=8C=E5=8C=BA=E5=9F=9F=E8=90=BD?= =?UTF-8?q?=20@=E6=A0=87=E7=AD=BE=EF=BC=8C=E6=96=87=E6=9C=AC=20part=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20null=20$insertContentAtSelection=20?= =?UTF-8?q?=E7=9A=84=E5=85=9C=E5=BA=95=E9=93=BE=E6=94=B9=E6=88=90=20provid?= =?UTF-8?q?er=20=E7=9A=84=20mentionToken=20=E4=B9=8B=E5=A4=96=E5=86=8D?= =?UTF-8?q?=E6=8E=A5=20contentPartText=EF=BC=8C=E8=A7=A3=E6=9E=90=E4=B8=8D?= =?UTF-8?q?=E5=87=BA=E5=BC=95=E7=94=A8=E4=B9=9F=E4=B8=80=E5=AE=9A=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=E6=AD=A3=E5=B8=B8=E6=96=87=E6=9C=AC=E8=90=BD?= =?UTF-8?q?=E5=88=B0=E5=85=89=E6=A0=87=E5=A4=84=EF=BC=8C=E5=88=A0=E6=8E=89?= =?UTF-8?q?=E3=80=8C=E4=B8=A4=E6=9D=A1=E9=83=BD=E7=AD=94=E4=B8=8D=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=E5=B0=B1=E4=BB=80=E4=B9=88=E9=83=BD=E4=B8=8D=E6=8F=92?= =?UTF-8?q?=E3=80=8D=E8=BF=99=E6=9D=A1=E8=BF=9D=E8=83=8C=E3=80=8C=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E5=86=85=E5=AE=B9=E9=80=90=E5=AD=97=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E3=80=8D=E7=9A=84=E5=88=86=E6=94=AF=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E8=A6=86=E7=9B=96=E5=9B=9B=E7=B1=BB=20part?= =?UTF-8?q?=20=E7=9A=84=E6=96=87=E6=9C=AC=E5=BD=A2=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=9C=AC=20part=20=E8=BF=94=E5=9B=9E=20null=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=BE=93=E5=85=A5=E5=8C=BA=E9=9B=86=E6=88=90?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=EF=BC=88provider=20=E7=9A=84=20toReference?= =?UTF-8?q?=20=E4=B8=8E=20mentionToken=20=E9=83=BD=E7=AD=94=E4=B8=8D?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=E6=97=B6=E7=B2=98=E8=B4=B4=E4=BB=8D=E6=8C=89?= =?UTF-8?q?=20@resourceId=20=E8=90=BD=E4=B8=8B=EF=BC=89=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E5=85=9C=E5=BA=95=E5=8D=B3=E5=8F=98=E7=BA=A2=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=20AGC=20=E8=81=8A=E5=A4=A9=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E=E7=9A=84?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E8=A7=A3=E6=9E=90=E5=B0=8F=E8=8A=82=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=9C=A8=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=E8=A1=A5?= =?UTF-8?q?=E8=AE=B0=E6=9C=AC=E8=BD=AE=E8=AF=84=E5=AE=A1=E7=9A=84=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResourceReferenceInput.tsx | 10 ++-- .../project-workspace/resourceReferences.ts | 49 ++++++++++++++----- .../tests/resourceReferenceInput.test.tsx | 35 +++++++++++++ .../tests/resourceReferences.test.ts | 36 ++++++++++++++ .../【实施计划】引用粘贴解析-2026-09-22.md | 10 ++++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 1 + 6 files changed, 127 insertions(+), 14 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 633b73c26..877747834 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 @@ -64,6 +64,7 @@ import { chatReferenceKey, chatReferenceMentionToken, chatReferenceToContentPart, + contentPartText, dedupeChatReferences, joinMentionText, } from './resourceReferences'; @@ -323,8 +324,9 @@ function $selectionOrRootEnd() { * 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成 * chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。 * - * 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本, - * 绝不静默丢掉——粘贴进来的内容一个字符都不会凭空消失。 + * 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本;连 provider 的 + * `mentionToken` 都答不出来时退到 `contentPartText` 的通用文本形态。两条兜底合起来保证 + * 「粘贴进来的内容一个字符都不会凭空消失」,这个分支不存在什么都不插的出路。 * * 返回「这次到底插进去没有」:调用方据此决定要不要接管这次粘贴,插入为空时必须放行 * 编辑器的默认粘贴,否则这段文字两边都不管。 @@ -359,7 +361,9 @@ function $insertContentAtSelection( return; } // 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。 - const token = mentionTokenFromPart(providers, part); + // provider 连 token 都答不出来(契约被破坏)时落它的通用文本形态,绝不留空。 + const token = + mentionTokenFromPart(providers, part) ?? contentPartText(part); if (token) { $selectionOrRootEnd()?.insertText(token); inserted = true; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 440e70d6b..adc78ecba 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -227,17 +227,44 @@ export function directCodexContentToPromptText( content: readonly DirectCodexUserContentPart[], resolveResourceLabel: ResourceLabelResolver, ) { - return joinMentionText(content, (part) => { - if (part.type === 'input_text') return null; - if (part.type === 'agc_attachment_reference') - return `@${normalizeMentionName(part.name)}`; - if (part.type === 'agc_skill_reference') - return `$${normalizeMentionName(part.name)}`; - if (part.type === 'agc_runtime_region_reference') return `@${part.label}`; - return `@${normalizeMentionName( - resolveResourceLabel(part.resourceId) ?? part.resourceId, - )}`; - }); + return joinMentionText(content, (part) => + contentPartToken(part, resolveResourceLabel), + ); +} + +/** + * 一个 part 在正文文本里的 token(`@显示名` / `$名称` / `@附件名` / `@区域标签`);文本 part + * 没有 token,返回 `null`。 + * + * 逐字返回 token 本身:不带补白、不裁剪——补白是 `joinMentionText` 在拼整段文本时加的, + * 什么时候需要留白由那里的上下文决定,token 的投影不替它决定。 + */ +function contentPartToken( + part: DirectCodexUserContentPart, + resolveResourceLabel: ResourceLabelResolver, +): string | null { + if (part.type === 'input_text') return null; + if (part.type === 'agc_attachment_reference') + return `@${normalizeMentionName(part.name)}`; + if (part.type === 'agc_skill_reference') + return `$${normalizeMentionName(part.name)}`; + if (part.type === 'agc_runtime_region_reference') return `@${part.label}`; + return `@${normalizeMentionName( + resolveResourceLabel(part.resourceId) ?? part.resourceId, + )}`; +} + +/** + * 单个非文本 part 的文本形态,就是它的 token(见 `contentPartToken`):资源拿不到显示名时 + * 退回 `resourceId`。文本 part 没有文本形态,返回 `null`。 + * + * 只给「插入那一刻解析不出引用、也拿不到 provider 的 token」兜底:粘贴过来的字必须落下去, + * 哪怕落成一段文本。它不参与解析,也不是解析依据——扫描一律用 `chatReferenceMentionToken`。 + */ +export function contentPartText( + part: DirectCodexUserContentPart, +): string | null { + return contentPartToken(part, () => undefined); } /** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */ diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 0fdbd3298..0a62f8041 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -489,6 +489,41 @@ describe('ResourceReferenceInput', () => { expect(draftResourceIds(ref.current?.getDraft())).toEqual([]); }); + test('粘贴命中的引用连 token 都解析不出来时,落下它的文本形态而不是什么都不插', async () => { + const ref = createRef(); + // provider 契约被破坏:lookup 有候选,但 toReference 与 mentionToken 都答不出来。 + // 这时必须落一段正常文本(资源退回 resourceId),粘贴进来的字不许凭空消失。 + const brokenProvider: ReferenceProvider = { + trigger: '@', + fuzzy_lookup: () => [], + lookup: () => [ + resourceReferenceFromAsset( + asset('ghost-asset', 'character', 'image/png', 'assets/幽灵.png'), + 'asset-picker', + ), + ], + toReference: () => null, + refresh: (reference) => reference, + mentionToken: () => null, + }; + render( + , + ); + + pasteComposerText('看 @幽灵 一眼'); + + await waitFor(() => { + expect(draftText(ref.current?.getDraft())).toBe('看 @ghost-asset 一眼'); + }); + expect(draftResourceIds(ref.current?.getDraft())).toEqual([]); + }); + test('粘贴未命中的 token:整段按字面落进正文,不接管、不提示', async () => { const ref = createRef(); render( diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index 3ed6d96c5..12c1c1abb 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -7,6 +7,7 @@ import { chatComposerDraftToDirectCodexUserItem, type ChatReference, chatReferenceMentionToken, + contentPartText, directCodexContentToPromptText, hasMeaningfulDirectCodexContent, normalizeMentionName, @@ -275,6 +276,41 @@ describe('粘贴文本反解析', () => { expect(buildContentFromPastedText('@ 只有裸符号', [nameless])).toBeNull(); }); + it('单 part 文本形态:解析不出引用时也有一段正常文本可落,不留空', () => { + // 插入那一刻 provider 认不出这个 part 时,`$insertContentAtSelection` 落的就是这一份。 + const runtimeRegionPart = (label: string): DirectCodexUserContentPart => ({ + type: 'agc_runtime_region_reference', + label, + runId: null, + versionId: null, + elementTag: null, + elementRole: null, + text: null, + width: null, + height: null, + resourceIds: [], + }); + expect( + contentPartText({ type: 'agc_resource_reference', resourceId: 'hero' }), + ).toBe('@hero'); + expect( + contentPartText({ type: 'agc_skill_reference', name: 'image gen' }), + ).toBe('$image-gen'); + expect( + contentPartText({ + type: 'agc_attachment_reference', + name: 'brief v2.md', + mediaType: 'text/markdown', + size: 1, + localPath: 'notes/brief v2.md', + status: 'imported', + }), + ).toBe('@brief-v2.md'); + expect(contentPartText(runtimeRegionPart('主画面'))).toBe('@主画面'); + // 文本 part 没有「文本形态」,它自己就是文本。 + expect(contentPartText({ type: 'input_text', text: '先看' })).toBeNull(); + }); + it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => { const content: DirectCodexUserContentPart[] = [ { type: 'input_text', text: '看 ' }, diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md index 966cab5fb..bb8f0c2c1 100644 --- a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -50,3 +50,13 @@ - 名称里不做 `resourceId` 兜底这一条按评审校正:词干为空(`.env` / `.gitignore` 这类整名就是扩展名的文件)时 `resourceDisplayName` 回退 `asset.id`,回退值同样过 `normalizeMentionName`;`normalizeMentionName` 自己仍只做归一化。 - 代价与取舍:`hero v2` 与 `hero-v2` 归一化后同名时走既有的「同名多候选按文本保留」;改动前生成的旧文本里的 `@hero v2` 不再解析。 - 验证:`normalizeMentionName` / 显示名 / token 口径单测 + 前缀重叠回归用例(归一化后粘贴只剩正确的那一枚芯片)+ provider 两处用例;把 `normalizeMentionName` 变异成恒等后新增用例全红。 + +## 追加执行状态(2026-09-23 第二轮自动评审):空名字与插入兜底收口 + +第二轮自动评审的四个问题逐个提交处理,落点都在 `apps/ai-game-creator-shell`: + +- 显示名不再可能为空:`resourceDisplayName` 在文件名词干为空(`.env` / `.gitignore`)时回退 `asset.id`,回退值同样过 `normalizeMentionName`(提交 `4e90465c4`)。这条校正了上一节「不做兜底」的写法,决策记录与功能说明同步。 +- 粘贴解析跳过退化 token:`buildContentFromPastedText` 组装候选时跳过 `token.length <= 1` 的条目,裸 `@` / `$` 不再认领正文里的触发符(提交 `3f9c698a6`)。 +- 粘贴接管改成「插入真的发生之后」才 `preventDefault`:`$insertContentAtSelection` 返回「插进去没有」,插入为空时放行默认粘贴;编辑器已在更新中(回调被排队)时按原口径先接管(提交 `3391ecf7d`)。 +- 插入兜底不再留空:provider 的 `toReference` 与 `mentionToken` 都答不出来的 part 退到新增的 `contentPartText`(通用文本形态,资源落 `@resourceId`)。此前这一支会什么都不插,是「粘贴内容逐字保留」唯一的例外分支;现在没有例外。新增单测覆盖四类 part 的文本形态,新增输入区集成用例证明被破坏的 provider 契约下这段粘贴仍按文本落下,去掉兜底即变红。 +- 归一化撞名(`hero v2` 与 `hero-v2` 折成同一个 token)只在解析侧兜住、候选菜单不提示冲突,这一条涉及「哪些素材能被 @ 到」的产品取舍,未改代码,留给下一轮决定。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 3bbd8ac33..ea9048a42 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -40,6 +40,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。 - 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。 - 只有真的解析出引用、并且这一整段真的插进了正文时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。取不到选区时插入为空,这时也放行默认粘贴,粘贴的文字不会「两边都不管」。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 +- 插入那一刻解析不出引用时不留空:先退回 provider 的 token(`@显示名` / `$名称`),provider 连 token 都答不出来(契约被破坏)时退到 `contentPartText` 的通用文本形态——资源拿不到显示名就落 `@resourceId`,Skill / 附件 / 运行区域各落自己的名字或标签。所以「粘贴进来的内容一个字符都不会凭空消失」在这条链路上没有例外分支。 - 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。 - Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。 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 12/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=B6=A6?= =?UTF-8?q?=E8=89=B2=E5=9B=9E=E5=86=99=E4=B8=8E=E7=B2=98=E8=B4=B4=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E5=90=8C=E4=B8=80=E6=9D=A1=E8=90=BD=E7=82=B9=E5=85=9C?= =?UTF-8?q?=E5=BA=95=EF=BC=8C=E8=AE=A4=E4=B8=8D=E5=87=BA=E7=9A=84=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=90=BD=E6=96=87=E6=9C=AC=E8=80=8C=E4=B8=8D=E6=98=AF?= =?UTF-8?q?=E6=B6=88=E5=A4=B1=20ResourceReferenceInput=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20mentionTokenOrText=EF=BC=9Aprovider=20=E7=9A=84=20m?= =?UTF-8?q?entionToken=EF=BC=8C=E7=AD=94=E4=B8=8D=E5=87=BA=E6=9D=A5?= =?UTF-8?q?=EF=BC=88=E5=A5=91=E7=BA=A6=E8=A2=AB=E7=A0=B4=E5=9D=8F=EF=BC=89?= =?UTF-8?q?=E6=97=B6=E9=80=80=20contentPartText=20=E7=9A=84=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E6=96=87=E6=9C=AC=E5=BD=A2=E6=80=81=EF=BC=9B=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E6=8F=92=E5=85=A5=E3=80=81=E6=B6=A6=E8=89=B2=E5=9B=9E?= =?UTF-8?q?=E5=86=99=E7=9A=84=E5=80=99=E9=80=89=E6=89=AB=E6=8F=8F=E3=80=81?= =?UTF-8?q?=E6=95=B4=E6=A0=B9=E6=9B=BF=E6=8D=A2=E7=9A=84=E8=90=BD=E7=82=B9?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E8=BF=99=E4=B8=80=E6=9D=A1=20=E6=B6=A6?= =?UTF-8?q?=E8=89=B2=E5=9B=9E=E5=86=99=E7=9A=84=E5=80=99=E9=80=89=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=B7=B3=E8=BF=87=E3=80=8C=E7=AD=94=E4=B8=8D=E5=87=BA?= =?UTF-8?q?=20token=20=E7=9A=84=20part=E3=80=8D=EF=BC=9A=E8=BF=99=E7=B1=BB?= =?UTF-8?q?=20part=20=E5=9C=A8=E5=9B=9E=E5=8C=85=E9=87=8C=E6=B2=A1?= =?UTF-8?q?=E8=A2=AB=E6=8F=90=E5=88=B0=E6=97=B6=E4=BD=9C=E4=B8=BA=E6=9C=AB?= =?UTF-8?q?=E5=B0=BE=E5=AD=A4=E5=84=BF=E8=A1=A5=E5=9B=9E=E6=9D=A5=EF=BC=8C?= =?UTF-8?q?=E8=80=8C=E4=B8=8D=E6=98=AF=E5=9C=A8=E7=BF=BB=E8=AF=91=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E7=9B=B4=E6=8E=A5=E6=B6=88=E5=A4=B1=20applyContentToR?= =?UTF-8?q?oot=EF=BC=88=E5=88=9D=E5=A7=8B=E8=8D=89=E7=A8=BF=E4=B8=8E?= =?UTF-8?q?=E6=B6=A6=E8=89=B2=E5=9B=9E=E5=86=99=E5=85=B1=E7=94=A8=EF=BC=89?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E4=B8=8D=E5=87=BA=E5=BC=95=E7=94=A8=E6=97=B6?= =?UTF-8?q?=E8=90=BD=E6=96=87=E6=9C=AC=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=98=AF?= =?UTF-8?q?=E6=95=B4=E6=9D=A1=E5=90=83=E6=8E=89=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E8=BE=93=E5=85=A5=E5=8C=BA=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=EF=BC=9Aprovider=20=E5=8F=AA=E7=AD=94=E5=BE=97=E5=87=BA?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E7=AD=94=E4=B8=8D=E5=87=BA=20token=20?= =?UTF-8?q?=E6=97=B6=E6=B6=A6=E8=89=B2=E5=9B=9E=E5=86=99=E4=BB=8D=E4=BF=9D?= =?UTF-8?q?=E4=BD=8F=E5=BC=95=E7=94=A8=EF=BC=9B=E5=88=9D=E5=A7=8B=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E9=87=8C=E5=B7=B2=E8=A7=A3=E6=9E=90=E4=B8=8D=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E5=BC=95=E7=94=A8=E8=90=BD=E6=88=90=20@resourceId=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E3=80=82=E4=B8=A4=E5=A4=84=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E5=8D=B3=E5=8F=98=E7=BA=A2=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20AGC=20=E8=81=8A=E5=A4=A9=E7=B4=A0=E6=9D=90=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E=E3=80=81=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E7=B2=98=E8=B4=B4=E8=A7=A3=E6=9E=90=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E4=B8=8E=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=88=E8=90=BD=E7=82=B9=E5=85=9C=E5=BA=95=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=EF=BC=8C=E5=90=AB=E5=88=9D=E5=A7=8B=E8=8D=89=E7=A8=BF=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E5=8F=98=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 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。 From c2ec9781991a524d78787140f418d75ca54ad6d9 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 23 Sep 2026 18:20:42 +0800 Subject: [PATCH 13/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E8=AF=B7=E6=B1=82=E9=87=8D=E5=A4=8D=E5=8F=91?= =?UTF-8?q?=E9=80=81=E5=8E=9F=E5=A7=8B=E7=94=A8=E6=88=B7=E8=BE=93=E5=85=A5?= =?UTF-8?q?=20(#500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分首次用户请求与宿主反馈,继续时发送实际验收或错误信息 固定 GUI 和 CLI 的原始用户条目,保留历史与回合身份关联 补充结构化输入、反馈发送、图片输入和历史去重的定向回归测试 同步更新实施计划与排障记录,保持交付验收条件和预算不变 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/500 Co-authored-by: Linghong Co-committed-by: Linghong --- .../src/agent/codex_app_server/mod.rs | 176 ++++++++++++++++-- .../src-tauri/src/agent/direct_runtime/mod.rs | 36 +++- .../tests/appSurface/home.suite.ts | 3 +- docs/project-memory/shared-memory/pitfalls.md | 4 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 + .../src/components/PlatformSegmentedTabs.tsx | 18 +- .../shared/src/contracts/gameDistribution.ts | 8 +- 7 files changed, 205 insertions(+), 47 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 35c52a8bc..bc5d088a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -839,7 +839,7 @@ fn direct_thread_visible_item( /// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 + /// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id; /// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。 -fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { +pub(super) fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { let client_turn_id = client_turn_id.trim(); (!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user")) } @@ -1619,6 +1619,36 @@ async fn stage_codex_app_server_image( Ok(path) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DirectCodexTurnKind { + User, + HostFeedback, +} + +async fn direct_project_turn_input( + request: &LlmRunRequest, + prompt: &str, + workspace_path: &std::path::Path, + turn_kind: DirectCodexTurnKind, + original_user_item: Option<&serde_json::Value>, + skill_roots: &[std::path::PathBuf], +) -> Result { + // 原始用户条目一直用于历史和事件关联,但只有首次请求将它作为模型输入。 + if turn_kind == DirectCodexTurnKind::User { + if let Some(item) = original_user_item { + let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + .map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?; + return direct_codex_user_item_to_codex_turn_input( + workspace_path, + &canonical, + skill_roots, + ) + .map_err(platform_llm::LlmError::InvalidRequest); + } + } + codex_app_server_turn_input(request, prompt, workspace_path).await +} + async fn codex_app_server_turn_input( request: &LlmRunRequest, prompt: &str, @@ -3217,6 +3247,7 @@ impl CodexAppServerConnection { None, None, None, + DirectCodexTurnKind::User, on_agent_message_delta, direct_observer, audit, @@ -3233,6 +3264,7 @@ impl CodexAppServerConnection { direct_history_root: Option<&std::path::Path>, direct_client_turn_id: Option<&str>, direct_user_item: Option<&serde_json::Value>, + turn_kind: DirectCodexTurnKind, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, mut audit: Option<&mut DirectCodexTurnAudit>, @@ -3358,18 +3390,15 @@ impl CodexAppServerConnection { .map_err(platform_llm::LlmError::InvalidRequest)? }; let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - if let Some(item) = direct_user_item { - let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) - .map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?; - direct_codex_user_item_to_codex_turn_input( - &self.inner.workspace_path, - &canonical, - self.inner._skill_roots.as_deref().unwrap_or_default(), - ) - .map_err(platform_llm::LlmError::InvalidRequest)? - } else { - codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await? - } + direct_project_turn_input( + &request, + &prompt, + &self.inner.workspace_path, + turn_kind, + direct_user_item, + self.inner._skill_roots.as_deref().unwrap_or_default(), + ) + .await? } else { codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await? }; @@ -5042,6 +5071,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( root, system_prompt, user_prompt, + DirectCodexTurnKind::User, None, None, None, @@ -5060,6 +5090,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( root, system_prompt, user_prompt, + DirectCodexTurnKind::User, None, Some(observer), None, @@ -5072,6 +5103,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, + turn_kind: DirectCodexTurnKind, client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, @@ -5171,6 +5203,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( Some(&codex_root), effective_client_turn_id, direct_user_item.as_ref(), + turn_kind, None, observer, audit, @@ -5348,6 +5381,7 @@ mod tests { Path::new("not-read"), String::new(), String::new(), + DirectCodexTurnKind::User, Some("not-executed"), None, None, @@ -6018,6 +6052,108 @@ mod tests { } } + #[tokio::test] + async fn direct_project_turn_input_preserves_user_input_and_sends_host_feedback() { + let temp = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(temp.path(), "feedback-input", "反馈输入") + .expect("init project"); + let skill_root = temp.path().join("skills"); + let skill_path = skill_root.join("test-skill").join("SKILL.md"); + std::fs::create_dir_all(skill_path.parent().unwrap()).expect("create skill directory"); + std::fs::write(&skill_path, "# 测试 Skill").expect("write skill"); + let user_item = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:feedback-input:user", + "content": [ + {"type": "input_text", "text": "请创建菜单"}, + {"type": "agc_skill_reference", "name": "test-skill"} + ] + }); + let skill_roots = vec![skill_root]; + let request = LlmRunRequest::single_turn("系统", "请创建菜单"); + let input = direct_project_turn_input( + &request, + &direct_codex_user_prompt(&request), + temp.path(), + DirectCodexTurnKind::User, + Some(&user_item), + &skill_roots, + ) + .await + .expect("initial input"); + assert_eq!( + input, + serde_json::json!([ + {"type": "text", "text": "请创建菜单"}, + {"type": "skill", "name": "test-skill", "path": skill_path} + ]) + ); + + // 连续验收反馈和执行错误都携带同一个原始条目,但 wire input 必须是当次反馈。 + for feedback in [ + "宿主验收未通过:缺少移动端玩法证据,请补齐。", + "npm run build 失败:入口不存在,请修复后继续。", + ] { + let request = LlmRunRequest::single_turn("系统", feedback); + let input = direct_project_turn_input( + &request, + &direct_codex_user_prompt(&request), + temp.path(), + DirectCodexTurnKind::HostFeedback, + Some(&user_item), + &skill_roots, + ) + .await + .expect("host feedback input"); + assert_eq!( + input, + serde_json::json!([{"type": "text", "text": feedback}]) + ); + } + } + + #[tokio::test] + async fn direct_project_turn_input_cli_feedback_keeps_one_original_history_item() { + let temp = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(temp.path(), "cli-feedback", "CLI 反馈") + .expect("init project"); + let user_item = direct_project_local_message_item( + "user", + "请创建菜单", + direct_codex_user_item_id_for_client_turn_id("direct-cli-feedback").as_deref(), + ) + .expect("freeze original CLI input"); + for (kind, prompt) in [ + (DirectCodexTurnKind::User, "请创建菜单"), + ( + DirectCodexTurnKind::HostFeedback, + "宿主验收未通过:缺少玩法证据", + ), + (DirectCodexTurnKind::HostFeedback, "构建失败:请修复入口"), + ] { + // 与发送入口一样,每次先幂等落盘原始用户条目,再构造本次模型输入。 + append_direct_project_user_message_at(temp.path(), &user_item) + .expect("persist original user item without an ID conflict"); + let request = LlmRunRequest::single_turn("系统", prompt); + let input = direct_project_turn_input( + &request, + &direct_codex_user_prompt(&request), + temp.path(), + kind, + Some(&user_item), + &[], + ) + .await + .expect("CLI turn input"); + assert_eq!(input, serde_json::json!([{"type": "text", "text": prompt}])); + } + assert_eq!( + read_direct_project_history_items_at(temp.path()).expect("read history"), + vec![user_item] + ); + } + #[tokio::test] async fn app_server_turn_input_stages_data_url_as_isolated_local_image() { let temp = tempfile::tempdir().expect("temp dir"); @@ -6035,9 +6171,16 @@ mod tests { let prompt = codex_app_server_text_prompt(&request).expect("sanitized prompt"); assert!(!prompt.contains("data:image")); assert!(prompt.contains("原生视觉输入")); - let input = codex_app_server_turn_input(&request, &prompt, temp.path()) - .await - .expect("turn input"); + let input = direct_project_turn_input( + &request, + &prompt, + temp.path(), + DirectCodexTurnKind::User, + None, + &[], + ) + .await + .expect("turn input"); assert_eq!(input[0]["type"], "text"); assert_eq!(input[1]["type"], "localImage"); let staged_path = input[1]["path"].as_str().expect("staged path"); @@ -7717,6 +7860,7 @@ done Some(&project), Some("turn-0001"), Some(&user_item), + DirectCodexTurnKind::User, None, Some(&mut observer), None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index e4c3d182e..4db66f9f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -5115,6 +5115,20 @@ async fn run_direct_game_creator_turn_inner( if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { return Ok(report); } + // CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID + // 写成内容不同的用户消息。GUI 则保留原有的 Skill、资源及附件引用。 + let direct_user_item = match direct_user_item { + Some(item) => item, + None => execution_session.snapshot().and_then(|state| { + direct_project_local_message_item( + "user", + prompt, + direct_codex_user_item_id_for_client_turn_id(&state.client_turn_id).as_deref(), + ) + }).map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?, + }; if execution_session.newly_accepted { if let Ok(ledger) = execution_session.snapshot() { crate::analytics::goal::accepted( @@ -5268,20 +5282,23 @@ async fn run_direct_game_creator_turn_inner( } }; let mut feedback_prompt = prompt.to_string(); + let mut turn_kind = DirectCodexTurnKind::User; let mut audit = audit; let mut attempt = 1; let reply_result = loop { - match direct_game_creator_codex_chat_at_with_optional_observer( + let result = direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt.clone(), feedback_prompt.clone(), + turn_kind, Some(&client_turn_id), Some(&mut observer), audit.as_deref_mut(), - direct_user_item.clone(), + Some(direct_user_item.clone()), ) - .await - { + .await; + turn_kind = DirectCodexTurnKind::HostFeedback; + match result { Ok(value) => match super::direct_delivery::review_reply(root,&execution_session).await { Ok(Some(report)) => break Ok(report), Ok(None) => break Ok(value), @@ -5324,21 +5341,24 @@ async fn run_direct_game_creator_turn_inner( reply_result } else { let mut feedback_prompt = prompt.to_string(); + let mut turn_kind = DirectCodexTurnKind::User; let mut audit = audit; let mut response = None; let mut attempt = 1; loop { - match direct_game_creator_codex_chat_at_with_optional_observer( + let result = direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt.clone(), feedback_prompt.clone(), + turn_kind, None, None, audit.as_deref_mut(), - direct_user_item.clone(), + Some(direct_user_item.clone()), ) - .await - { + .await; + turn_kind = DirectCodexTurnKind::HostFeedback; + match result { Ok(value) => { match super::direct_delivery::review_reply(root,&execution_session).await { Ok(Some(report)) => { response = Some(report); break; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e081afb8b..539a85caf 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1716,8 +1716,7 @@ export function registerHomeProjectCreationTests() { '卡住的自动建项', ); let resolveAutomaticProject: - | ((result: Record) => void) - | null = null; + ((result: Record) => void) | null = null; const invoke = vi.fn(async (command: string) => { if (command === 'preflight_web_game_creation') return { status: 'ready' }; if (command === 'create_automatic_local_game_project') { diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 017035200..56fa9611a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## Direct 宿主继续请求不能重发原始用户条目 + +原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 + ## 最近项目一次失败会被钉成终态 - **现象**:AGC 卡住一次后,项目列表每一行都显示「检查失败 + 待识别」,首页「最近项目」变成「暂无最近项目」;现场在后端恢复后逐条复跑 `inspect_local_project_directory`(8 个项目)全部 0ms 成功,界面仍然全红(issue #490)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 147f98fe0..25451c57d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,12 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-23 Direct 宿主继续请求输入修复 + +- 首次模型请求使用原始结构化用户输入,保留 Skill 提及及其它引用;未提供结构化输入时沿用请求正文与图片转换。 +- 验收反馈和可修复错误触发的后续模型请求,显式标记为宿主继续,只发送本次反馈正文,不再用原始用户条目覆盖。GUI 与 CLI 共用这一规则,验收条件、重试上限和执行预算保持不变。 +- 原始用户条目仍用于历史持久化和回合事件关联,沿用同一 clientTurnId/itemId;反馈不是新的用户输入。不要通过丢弃原始条目来修复发送内容。 +- 定向回归检查首次结构化输入、验收继续和错误继续的实际 wire input,以及同一用户条目的历史去重。 + ## 2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动 本节覆盖下文“打开项目自动识别 Godot”中的旧口径:判定从「唯一命中」放宽为「确定性命中」,`project.godot` 从「必须是普通文件」放宽为「按链接目标判定」。 diff --git a/packages/shared/src/components/PlatformSegmentedTabs.tsx b/packages/shared/src/components/PlatformSegmentedTabs.tsx index 3959b6d65..5413e759f 100644 --- a/packages/shared/src/components/PlatformSegmentedTabs.tsx +++ b/packages/shared/src/components/PlatformSegmentedTabs.tsx @@ -1,26 +1,14 @@ import type { ReactNode } from 'react'; export type PlatformSegmentedTabsColumns = - | 'one' - | 'two' - | 'three' - | 'four' - | 'threeToSix'; + 'one' | 'two' | 'three' | 'four' | 'threeToSix'; export type PlatformSegmentedTabsGap = 'sm' | 'md'; export type PlatformSegmentedTabsRadius = 'md' | 'lg' | 'xl'; export type PlatformSegmentedTabsSize = - | 'sm' - | 'md' - | 'compact' - | 'choice' - | 'tab'; + 'sm' | 'md' | 'compact' | 'choice' | 'tab'; export type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent'; export type PlatformSegmentedTabsTone = - | 'neutral' - | 'warm' - | 'rose' - | 'accent' - | 'underline'; + 'neutral' | 'warm' | 'rose' | 'accent' | 'underline'; export type PlatformSegmentedTabsFrame = 'panel' | 'bare'; export type PlatformSegmentedTabsSemantics = 'segment' | 'tabs'; export type PlatformSegmentedTabsLayout = 'grid' | 'scroll'; diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 22b3e4a31..f4923e6db 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -31,9 +31,7 @@ export type GameDistributionDeviceSupport = { export type GameDistributionInputMode = 'keyboard' | 'mouse' | 'touch'; export type GameDistributionOrientation = - | 'landscape' - | 'portrait' - | 'responsive'; + 'landscape' | 'portrait' | 'responsive'; export type GameDistributionAuthor = { id: string; @@ -54,9 +52,7 @@ export type GameDistributionVersionStatus = | 'revoked'; export type GameDistributionGameVisibility = - | 'unpublished' - | 'published' - | 'suspended'; + 'unpublished' | 'published' | 'suspended'; export type GameDistributionVersionSummary = { id: string; From 136e1ebceb5d838a6c423d657aa8ec506b019c86 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 18:49:07 +0800 Subject: [PATCH 14/15] Remove redundant `fuzzy_lookup` methods from test ReferenceProviders for cleanup --- .../tests/resourceReferenceInput.test.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 83b43c78d..723689942 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -464,7 +464,6 @@ describe('ResourceReferenceInput', () => { // provider 认得出这个 token(lookup 有候选),但插入那一刻已经解析不出引用(例如资产刚被删)。 const ghostProvider: ReferenceProvider = { trigger: '@', - fuzzy_lookup: () => [], lookup: () => [resourceReferenceFromAsset(assets[0]!, 'asset-picker')], toReference: () => null, refresh: (reference) => reference, @@ -495,7 +494,6 @@ describe('ResourceReferenceInput', () => { // 这时必须落一段正常文本(资源退回 resourceId),粘贴进来的字不许凭空消失。 const brokenProvider: ReferenceProvider = { trigger: '@', - fuzzy_lookup: () => [], lookup: () => [ resourceReferenceFromAsset( asset('ghost-asset', 'character', 'image/png', 'assets/幽灵.png'), @@ -870,7 +868,6 @@ describe('ResourceReferenceInput', () => { // provider 契约被破坏:解析得出引用,但答不出 token。它仍然必须活过这一轮润色回写。 const tokenlessProvider: ReferenceProvider = { trigger: '@', - fuzzy_lookup: () => [], lookup: () => [reference], toReference: (part) => part.type === 'agc_resource_reference' ? reference : null, From ebb288a6a4cf6fb4f0d80b0f4d2356b6c9b1c815 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 23 Sep 2026 18:49:44 +0800 Subject: [PATCH 15/15] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=B8=A0=E9=81=93=E4=BA=A7=E5=93=81=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev 渠道显示为陶泥儿开发版并保持既有 identifier 与升级链。 将产品名注入 Tauri、原生窗口、前端标题栏和关于页面,统一快捷方式与注册表展示来源。 补充渠道配置门禁、发布脚本测试和渠道身份文档。 --- .../scripts/build-release.mjs | 5 ++++ .../scripts/build-release.test.mjs | 11 ++++++-- .../scripts/channel-identity.mjs | 25 ++++++++++--------- .../scripts/check-config.mjs | 5 ++-- .../src-tauri/tauri.conf.json | 4 +-- .../src/app/appMetadata.ts | 12 ++++++++- .../src/components/WindowChrome.tsx | 8 ++++-- .../appSurface/runtime-settings.suite.ts | 4 +-- ...实施计划】AGC渠道安装身份隔离-2026-09-21.md | 7 ++++++ 9 files changed, 57 insertions(+), 24 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 85d4b63a5..38d6d1c5c 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -386,6 +386,9 @@ export function createChannelConfig( return { productName, identifier, + app: { + windows: [{ title: productName }], + }, plugins: { updater: { endpoints: [updateManifestUrl(channel, target)], @@ -452,6 +455,8 @@ export function runTauriBuild( // Vite embeds the platform API origin in the packaged renderer. The // release channel and updater channel therefore cannot drift apart. VITE_AGC_PLATFORM_CHANNEL: channel, + VITE_AGC_PRODUCT_NAME: + resolveChannelInstallIdentity(channel).productName, }, }, ); diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index fefd89e67..4c62cd8b4 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -177,8 +177,11 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { - productName: AGC_PRODUCT_NAME, + productName: `${AGC_PRODUCT_NAME}开发版`, identifier: AGC_APP_IDENTIFIER, + app: { + windows: [{ title: `${AGC_PRODUCT_NAME}开发版` }], + }, plugins: { updater: { endpoints: [ @@ -202,7 +205,7 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { test('channel install identity isolates co-installed builds and keeps the default channel stable', () => { // 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。 assert.deepEqual(resolveChannelInstallIdentity('dev'), { - productName: AGC_PRODUCT_NAME, + productName: `${AGC_PRODUCT_NAME}开发版`, identifier: AGC_APP_IDENTIFIER, }); assert.deepEqual(resolveChannelInstallIdentity('release'), { @@ -277,6 +280,10 @@ test('packaged renderer receives the same channel as the updater manifest', () = }, }); assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release'); + assert.equal( + spawnOptions?.env?.VITE_AGC_PRODUCT_NAME, + `${AGC_PRODUCT_NAME} Release`, + ); }); test('macOS manifests advertise exactly the architectures actually built', () => { diff --git a/apps/ai-game-creator-shell/scripts/channel-identity.mjs b/apps/ai-game-creator-shell/scripts/channel-identity.mjs index 5de68db22..db90f9c37 100644 --- a/apps/ai-game-creator-shell/scripts/channel-identity.mjs +++ b/apps/ai-game-creator-shell/scripts/channel-identity.mjs @@ -10,7 +10,7 @@ * 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、 * 本地项目与运行锁。 * - * 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。 + * 默认渠道 `dev` 保持已发布客户端标识不变:升级链路与既有安装不能断;展示名显式标记为开发版。 */ export const AGC_DEFAULT_CHANNEL = 'dev'; @@ -46,8 +46,9 @@ export function resolveReleaseChannel(env = process.env) { return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'); } -/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */ +/** 安装身份里的展示后缀:`dev` → `开发版`,`release` → `Release`,`beta-2` → `Beta-2`。 */ export function channelDisplaySuffix(channel) { + if (channel === AGC_DEFAULT_CHANNEL) return '开发版'; return validateReleaseChannel(channel) .split('-') .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) @@ -55,19 +56,19 @@ export function channelDisplaySuffix(channel) { } /** - * 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀, - * 保证同一台设备上不同渠道互不覆盖。 + * 渠道对应的安装身份。默认渠道保持既有 identifier 以兼容已安装客户端, + * 但展示名明确标记为开发版;其它渠道派生独立 identifier,保证同一台设备上并存。 */ export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) { validateReleaseChannel(channel); - if (channel === AGC_DEFAULT_CHANNEL) { - return Object.freeze({ - productName: AGC_PRODUCT_NAME, - identifier: AGC_APP_IDENTIFIER, - }); - } return Object.freeze({ - productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`, - identifier: `${AGC_APP_IDENTIFIER}.${channel}`, + productName: + channel === AGC_DEFAULT_CHANNEL + ? `${AGC_PRODUCT_NAME}开发版` + : `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`, + identifier: + channel === AGC_DEFAULT_CHANNEL + ? AGC_APP_IDENTIFIER + : `${AGC_APP_IDENTIFIER}.${channel}`, }); } diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 448f91c87..31b7d52f4 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -25,7 +25,6 @@ execFileSync( import { AGC_APP_IDENTIFIER, - AGC_PRODUCT_NAME, resolveChannelInstallIdentity, } from './channel-identity.mjs'; import { @@ -1313,7 +1312,7 @@ if ( // 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端 // 的升级链路与既有安装目录都会断开。 const defaultChannelIdentity = resolveChannelInstallIdentity('dev'); -if (tauriConfig.productName !== AGC_PRODUCT_NAME) { +if (tauriConfig.productName !== defaultChannelIdentity.productName) { throw new Error('AI game creator shell productName drifted'); } @@ -1486,7 +1485,7 @@ if ( !Array.isArray(tauriConfig.app?.windows) || tauriConfig.app.windows.length !== 1 || tauriConfig.app.windows[0]?.label !== 'client' || - tauriConfig.app.windows[0]?.title !== '陶泥儿' || + tauriConfig.app.windows[0]?.title !== defaultChannelIdentity.productName || tauriConfig.app.windows[0]?.url !== 'index.html' ) { throw new Error( diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 4771e3df3..cf24da0c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "陶泥儿", + "productName": "陶泥儿开发版", "version": "0.1.67", "identifier": "world.genarrative.ai-game-creator", "build": { @@ -14,7 +14,7 @@ "windows": [ { "label": "client", - "title": "陶泥儿", + "title": "陶泥儿开发版", "url": "index.html", "width": 1280, "height": 800, diff --git a/apps/ai-game-creator-shell/src/app/appMetadata.ts b/apps/ai-game-creator-shell/src/app/appMetadata.ts index 8ba27e8e9..d9e690fa7 100644 --- a/apps/ai-game-creator-shell/src/app/appMetadata.ts +++ b/apps/ai-game-creator-shell/src/app/appMetadata.ts @@ -1,5 +1,15 @@ import appPackage from '../../package.json'; +const DEFAULT_APP_NAME = '陶泥儿开发版'; + +/** + * 产品名由构建期注入;本地 Vite 开发没有注入时沿用 dev 渠道产品名。 + * + * 发布构建可通过 `VITE_AGC_PRODUCT_NAME` 注入渠道产品名,避免 UI 自己 + * 根据渠道推导名称,保证标题栏、关于页等前端展示与安装身份保持一致。 + */ +const injectedAppName = import.meta.env.VITE_AGC_PRODUCT_NAME?.trim(); + /** Product metadata shared by the client UI and release bundle. */ -export const APP_NAME = 'Genarrative AI Game Creator'; +export const APP_NAME = injectedAppName || DEFAULT_APP_NAME; export const APP_VERSION = appPackage.version; diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index 67d591ffb..e12c46cd8 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -9,6 +9,7 @@ import { } from 'react'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; +import { APP_NAME } from '../app/appMetadata'; import { appUpdateCheckEnabled } from '../app/featureFlags'; import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel'; import { subscribeTauriEvent } from '../services/tauriEventSubscription'; @@ -150,12 +151,15 @@ export function WindowChrome({ children }: WindowChromeProps) { {appUpdateCheckEnabled ? : null}
-
+
- 陶泥儿 + {APP_NAME} GameAgent
diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 9d8c95fb5..95cbf2b10 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; -import { APP_VERSION } from '../../src/app/appMetadata'; +import { APP_NAME, APP_VERSION } from '../../src/app/appMetadata'; import { repoPath } from '../repoPath'; import { act, @@ -348,7 +348,7 @@ export function registerRuntimeSettingsTests() { fireEvent.click(screen.getByRole('button', { name: /关于/ })); - expect(screen.getByText('Genarrative AI Game Creator')).not.toBeNull(); + expect(screen.getByText(APP_NAME)).not.toBeNull(); expect( screen.getByTestId('runtime-settings-app-logo').getAttribute('src'), ).toContain('taonier-product-ip.png'); diff --git a/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md index a8f468584..982d7cb08 100644 --- a/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md +++ b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md @@ -47,3 +47,10 @@ - 风险:非默认渠道首次以新身份安装,老 `dev` 用户不会自动迁移本地数据。回滚点:渠道身份只影响非默认渠道构建,撤销该渠道的构建产物即可,仓库侧无数据迁移。 - 风险:窗口标题改为构建期产品名后,标题不再等于配置里的字面量。回滚点:去掉 `main.rs` 的标题覆盖调用,行为回到配置标题。 - 风险:ACL managed 识别放宽到前缀族。回滚点:`is_game_creator_packaged_app_data_leaf` 收紧回单一直线值,但非默认渠道的提权修复会重新失败关闭。 + +## 2026-09-23 追加:dev 渠道展示名统一 + +- `dev` 渠道继续复用 `world.genarrative.ai-game-creator`,保证既有安装、升级链和 AppData 路径不变;展示名统一为 `陶泥儿开发版`。 +- `channel-identity.mjs` 是展示名单一来源。发布构建将同一 `productName` 同时注入 Tauri 安装配置、原生窗口标题和 `VITE_AGC_PRODUCT_NAME`;React 自绘标题栏及关于/运行时配置展示从该注入值读取。 +- 因此 Windows NSIS 默认生成的快捷方式、开始菜单/卸载注册表展示名随 Tauri `productName` 变为 `陶泥儿开发版`;未新增自定义注册表或快捷方式实现。 +- 静态 Tauri 基线配置同步为 `陶泥儿开发版`,本地壳与正式 `dev` 包的显示名保持一致。