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/22] =?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/22] =?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/22] =?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/22] =?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/22] =?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/22] =?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/22] =?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/22] =?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/22] =?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/22] =?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/22] =?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/22] =?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 688cf7a971dbf1613b4213c0da30e8cd295d3c47 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 23 Sep 2026 10:38:01 +0000 Subject: [PATCH 13/22] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E9=87=8D=E5=A4=8D=E6=92=AD=E6=94=BE=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E4=BC=AA=E6=B5=81=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按消息 ID 合并实时正文与正式消息,保留逐步显示进度 分离请求收尾与动画结束,隔离旧回合事件及异步返回 独立展示工具状态,保留 Provider 重试的正文重置语义 补充重复终态、整块回复、多消息及回合隔离回归测试 同步更新策划展示规范与排障记录 --- apps/ai-game-creator-shell/src/App.tsx | 252 ++++++------------ .../planning/PlanningChatView.tsx | 113 +++++--- .../planning/useDesignReplyAnimation.ts | 113 ++++++++ .../tests/appSurface/design-agent.suite.ts | 165 ++++++++++++ .../tests/designReplyAnimation.test.tsx | 99 +++++++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 + 7 files changed, 530 insertions(+), 221 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts create mode 100644 apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9fe8fc245..8a9c7e24b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -116,6 +116,7 @@ import { type DirectProjectInitialTurn, } from './view/project-development/chat/DirectProjectChatView'; import { PlanningChatView } from './view/project-development/planning/PlanningChatView'; +import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation'; import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; function isPersistableDirectCodexConversationMessage(message: ChatMessage) { @@ -366,23 +367,31 @@ export function App({ // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); - const [designAgentTransientReply, setDesignAgentTransientReplyVisible] = - useState(''); - const designAgentTransientReplyTargetRef = useRef(''); - const designAgentVisibleReplyRef = useRef(''); - const designAgentPendingViewRef = useRef<{ - clientTurnId: string; - projectPath: string; - view: DesignView; - } | null>(null); + const designReplyAnimation = useDesignReplyAnimation(); + const [designAgentStatus, setDesignAgentStatus] = useState(''); const [designAgentReasoning, setDesignAgentReasoning] = useState(''); - function setDesignAgentTransientReplyTarget(next: string) { - designAgentTransientReplyTargetRef.current = next; - if (!next) { - designAgentVisibleReplyRef.current = ''; - setDesignAgentTransientReplyVisible(''); - } + function isCurrentDesignTurn(projectPath: string, clientTurnId: string) { + const tracked = designAgentTurnRef.current; + return ( + localProjectPathRef.current === projectPath && + tracked?.projectPath === projectPath && + tracked.clientTurnId === clientTurnId + ); + } + + function beginDesignTurn(projectPath: string, clientTurnId: string) { + designAgentTurnRef.current = { projectPath, clientTurnId }; + designAgentReasoningTurnRef.current = { projectPath, clientTurnId }; + designReplyAnimation.reset( + latestMessagesRef.current.flatMap((message) => + message.messageId ? [message.messageId] : [], + ), + ); + setDesignAgentStatus(''); + setDesignAgentReasoning(''); + setProjectChatError(''); + setChatAgentBusy(true); } function designAgentEventSubscriptionReady() { @@ -407,35 +416,12 @@ export function App({ designAgentEventSubscriptionResolveRef.current = null; } - useEffect(() => { - const timer = window.setInterval(() => { - const target = designAgentTransientReplyTargetRef.current; - setDesignAgentTransientReplyVisible((current) => { - if (!target) { - designAgentVisibleReplyRef.current = ''; - return ''; - } - const prefix = target.startsWith(current) ? current : ''; - if (prefix === target) { - designAgentVisibleReplyRef.current = target; - return target; - } - const remaining = target.length - prefix.length; - const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1; - const next = target.slice(0, prefix.length + step); - designAgentVisibleReplyRef.current = next; - return next; - }); - }, 50); - return () => window.clearInterval(timer); - }, []); - /** * 策划 Agent 的实时事件流:本轮流式正文、思考过程和回合中途的视图都靠它推给界面。 * * 订阅建立是异步的,而回合由一个 invoke 发起;`designAgentEventSubscriptionReady()` - * 让回合等监听器挂好再开始,避免开头几个事件丢掉。事件只认当前项目;有在跑的回合时 - * 还要认本轮 `clientTurnId`,迟到的上一轮事件不会画到这一轮上。 + * 让回合等监听器挂好再开始,避免开头几个事件丢掉。正文和视图严格匹配活动回合, + * reasoning 另按原回合接收迟到补充,不能让过期视图重播正文。 */ useEffect(() => { const ready = createDesignAgentEventSubscriptionReady(); @@ -452,19 +438,7 @@ export function App({ let disposed = false; void subscribeTauriEvent('design-agent-update', (event) => { const payload = event.payload; - const tracked = designAgentTurnRef.current; - if ( - payload.projectPath !== localProjectPathRef.current || - (tracked && payload.clientTurnId !== tracked.clientTurnId) - ) { - return; - } - if ( - (payload.kind === 'text' || payload.kind === 'tool') && - payload.text - ) { - setDesignAgentTransientReplyTarget(payload.text); - } + if (payload.projectPath !== localProjectPathRef.current) return; if (payload.reasoningText != null) { const reasoningTurn = designAgentReasoningTurnRef.current; if ( @@ -474,8 +448,21 @@ export function App({ setDesignAgentReasoning(payload.reasoningText); } } + if (!isCurrentDesignTurn(payload.projectPath, payload.clientTurnId)) + return; + if ( + payload.kind === 'text' && + payload.messageId && + payload.text != null + ) { + designReplyAnimation.receiveText(payload.messageId, payload.text); + if (payload.text) setDesignAgentStatus(''); + } + if (payload.kind === 'tool' && payload.text != null) { + setDesignAgentStatus(payload.text); + } if (payload.view) { - applyDesignAgentViewAfterTransient( + applyDesignAgentTurnView( payload.view, payload.projectPath, payload.clientTurnId, @@ -557,67 +544,18 @@ export function App({ latestMessagesRef.current = conversation; } - function commitDesignAgentView(view: DesignView, projectPath: string) { - const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId; - designAgentPendingViewRef.current = null; - applyDesignView(view, projectPath); - setDesignAgentReasoning(''); - setDesignAgentTransientReplyTarget(''); - if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) { - designAgentTurnRef.current = null; - } - } - - function applyDesignAgentViewAfterTransient( + function applyDesignAgentTurnView( view: DesignView, projectPath: string, clientTurnId: string, ) { - let target = designAgentTransientReplyTargetRef.current; - const tracked = designAgentTurnRef.current; - if (!target.trim() && !view.running) { - const latestAssistantText = [...view.messages] - .reverse() - .find((message) => message.role !== 'user' && message.text.trim()) - ?.text.trim(); - if (latestAssistantText) { - setDesignAgentTransientReplyTarget(latestAssistantText); - target = latestAssistantText; - } - } - if ( - !view.running && - tracked?.clientTurnId === clientTurnId && - target.trim() && - designAgentVisibleReplyRef.current !== target - ) { - designAgentPendingViewRef.current = { - clientTurnId, - projectPath, - view, - }; - return; - } - commitDesignAgentView(view, projectPath); + if (!isCurrentDesignTurn(projectPath, clientTurnId)) return; + designReplyAnimation.receiveView(view); + applyDesignView(view, projectPath); + setDesignAgentReasoning(''); + if (!view.running) setDesignAgentStatus(''); } - useEffect(() => { - const timer = window.setInterval(() => { - const pending = designAgentPendingViewRef.current; - if (!pending) { - return; - } - const target = designAgentTransientReplyTargetRef.current; - if (target && designAgentVisibleReplyRef.current !== target) { - return; - } - commitDesignAgentView(pending.view, pending.projectPath); - }, 50); - return () => window.clearInterval(timer); - // 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - async function hydrateDesignAgentSession(nextProjectPath: string) { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath.trim()) { @@ -647,32 +585,18 @@ export function App({ setProjectChatError('需要在 Tauri App 内运行。'); return; } - designAgentTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentReasoningTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentPendingViewRef.current = null; + beginDesignTurn(nextProjectPath, clientTurnId); await designAgentEventSubscriptionReady(); - setChatAgentBusy(true); - setProjectChatError(''); - setDesignAgentTransientReplyTarget(''); - setDesignAgentReasoning(''); + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; try { const view = await invoke('continue_design_agent_session', { projectPath: nextProjectPath, clientTurnId, input, }); - if (localProjectPathRef.current !== nextProjectPath) { - return; - } - applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId); + applyDesignAgentTurnView(view, nextProjectPath, clientTurnId); } catch (error) { - if (localProjectPathRef.current !== nextProjectPath) { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) { return; } const message = error instanceof Error ? error.message : String(error); @@ -680,12 +604,13 @@ export function App({ requestRuntimeConfigOpen(); } setProjectChatError(message); + designReplyAnimation.discardUnpersisted(); } finally { - if (!designAgentPendingViewRef.current) { + if (isCurrentDesignTurn(nextProjectPath, clientTurnId)) { designAgentTurnRef.current = null; - setDesignAgentTransientReplyTarget(''); + setDesignAgentStatus(''); + setChatAgentBusy(false); } - setChatAgentBusy(false); } } @@ -876,7 +801,8 @@ export function App({ }, [ messages, projectChatError, - designAgentTransientReply, + designReplyAnimation.replies, + designAgentStatus, designAgentReasoning, designAgentView, pendingUiConfirmation, @@ -1369,8 +1295,8 @@ export function App({ setChatFilesImporting(false); setChatFileImportNotice(''); setProjectChatError(''); - setDesignAgentTransientReplyTarget(''); - designAgentPendingViewRef.current = null; + designReplyAnimation.reset(); + setDesignAgentStatus(''); setDesignAgentReasoning(''); setDesignAgentActive(planningStartMode); designAgentActiveRef.current = planningStartMode; @@ -2295,7 +2221,8 @@ export function App({ pendingConfirmation={pendingUiConfirmation} projectPath={localProject?.projectPath ?? projectPath} conversationMessages={messages} - transientReply={designAgentTransientReply} + replyAnimations={designReplyAnimation.replies} + designStatus={designAgentStatus} showDesignReasoning={designAgentActive} designReasoning={designAgentReasoning} designReasoningEntries={ @@ -2313,66 +2240,37 @@ export function App({ return; } const clientTurnId = createAgentChatRunId('design-agent-turn'); - designAgentTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentReasoningTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentPendingViewRef.current = null; - setDesignAgentTransientReplyTarget(''); - setDesignAgentReasoning(''); - setChatAgentBusy(true); + beginDesignTurn(nextProjectPath, clientTurnId); void designAgentEventSubscriptionReady() - .then(() => - invoke('decide_design_phase', { + .then(() => { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) + return null; + return invoke('decide_design_phase', { projectPath: nextProjectPath, clientTurnId, requestId, approved, - }), - ) + }); + }) .then((view) => { - if ( - localProjectPathRef.current !== nextProjectPath || - designAgentTurnRef.current?.projectPath !== - nextProjectPath || - designAgentTurnRef.current?.clientTurnId !== clientTurnId - ) { - return; - } - applyDesignAgentViewAfterTransient( + if (!view) return; + applyDesignAgentTurnView( view, nextProjectPath, clientTurnId, ); }) .catch((error) => { - if ( - localProjectPathRef.current !== nextProjectPath || - designAgentTurnRef.current?.projectPath !== - nextProjectPath || - designAgentTurnRef.current?.clientTurnId !== clientTurnId - ) { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - } setProjectChatError(String(error)); + designReplyAnimation.discardUnpersisted(); }) .finally(() => { - if ( - localProjectPathRef.current !== nextProjectPath || - designAgentTurnRef.current?.projectPath !== - nextProjectPath || - designAgentTurnRef.current?.clientTurnId !== clientTurnId - ) { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - } - if (!designAgentPendingViewRef.current) { - designAgentTurnRef.current = null; - setDesignAgentTransientReplyTarget(''); - } + designAgentTurnRef.current = null; + setDesignAgentStatus(''); setChatAgentBusy(false); }); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index d0b7d74d4..645a8e957 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -31,6 +31,7 @@ import { DesignAgentPendingActions, DesignAgentPhaseStatus, } from './DesignAgentSurface'; +import type { DesignReplyAnimation } from './useDesignReplyAnimation'; /** 后台任务失败文案要过一遍运行态错误解释器再给用户看。 */ function planningMessageText(message: Pick) { @@ -80,7 +81,8 @@ type PlanningChatViewProps = { onSubmit: FormEventHandler; pendingConfirmation: PendingUiConfirmation | null; projectPath: string; - transientReply: string; + replyAnimations?: DesignReplyAnimation[]; + designStatus?: string; showDesignReasoning?: boolean; designReasoning?: string; designReasoningEntries?: DesignReasoningEntry[]; @@ -117,7 +119,8 @@ export function PlanningChatView({ onSubmit, pendingConfirmation, projectPath, - transientReply, + replyAnimations = [], + designStatus = '', showDesignReasoning = false, designReasoning = '', designReasoningEntries = [], @@ -169,34 +172,67 @@ export function PlanningChatView({ const handleDesignClarify = onDesignClarify ?? (() => undefined); const handleDesignRetry = onDesignRetry ?? (() => undefined); - const renderMessage = (message: ChatMessage, index: number) => ( -
- - - - {showDesignReasoning && message.reasoningText ? ( - - ) : null} - {message.role === 'user' && message.updatedAt ? ( - - ) : null} -
+ const animationsById = new Map( + replyAnimations.map((reply) => [reply.messageId, reply]), ); + const displayedMessages = [...visibleMessages]; + const conversationIds = new Set( + conversationMessages.map((message) => message.messageId), + ); + for (const reply of replyAnimations) { + if ( + !reply.persisted && + reply.target && + !conversationIds.has(reply.messageId) + ) { + displayedMessages.push({ + role: 'assistant', + messageId: reply.messageId, + text: reply.target, + }); + } + } + const renderMessage = (message: ChatMessage, index: number) => { + const animation = message.messageId + ? animationsById.get(message.messageId) + : undefined; + const streaming = Boolean( + animation && + (!animation.persisted || animation.visible !== animation.target), + ); + return ( +
+ + + + {showDesignReasoning && message.reasoningText ? ( + + ) : null} + {message.role === 'user' && message.updatedAt ? ( + + ) : null} +
+ ); + }; return (
@@ -251,7 +287,7 @@ export function PlanningChatView({ ) : null} - {visibleMessages.map(renderMessage)} + {displayedMessages.map(renderMessage)} {showDesignReasoning ? designReasoningEntries .filter((entry) => !entry.messageId) @@ -269,20 +305,9 @@ export function PlanningChatView({ label="策划 Agent 思考过程" /> ) : null} - {transientReply ? ( -
- - - + {designStatus ? ( +
+ {designStatus}
) : null}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts b/apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts new file mode 100644 index 000000000..d8c6ef578 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { DesignView } from '../../../app/types'; + +export type DesignReplyAnimation = { + messageId: string; + target: string; + visible: string; + persisted: boolean; +}; + +/** 正文动画只管理显示进度,不延迟正式会话状态或参与请求收尾。 */ +export function useDesignReplyAnimation() { + const entriesRef = useRef([]); + const historyIdsRef = useRef(new Set()); + const [replies, setReplies] = useState([]); + const publish = useCallback((next: DesignReplyAnimation[]) => { + entriesRef.current = next; + setReplies(next); + }, []); + + const reset = useCallback( + (historyIds: string[] = []) => { + historyIdsRef.current = new Set(historyIds); + publish([]); + }, + [publish], + ); + + const receiveText = useCallback( + (messageId: string, target: string) => { + if (historyIdsRef.current.has(messageId)) return; + const previous = entriesRef.current.find( + (entry) => entry.messageId === messageId, + ); + // 空文本是同一 Provider 请求的 attempt 重置,不能清掉正式回复。 + if (previous?.persisted) return; + const next = { + messageId, + target, + visible: target.startsWith(previous?.visible ?? '') + ? (previous?.visible ?? '') + : '', + persisted: false, + }; + publish( + previous + ? entriesRef.current.map((entry) => + entry.messageId === messageId ? next : entry, + ) + : [...entriesRef.current, next], + ); + }, + [publish], + ); + + const receiveView = useCallback( + (view: DesignView) => { + let next = [...entriesRef.current]; + for (const message of view.messages) { + if ( + message.role !== 'assistant' || + historyIdsRef.current.has(message.id) + ) + continue; + const index = next.findIndex((entry) => entry.messageId === message.id); + const previous = next[index]; + const entry = { + messageId: message.id, + target: message.text, + visible: message.text.startsWith(previous?.visible ?? '') + ? (previous?.visible ?? '') + : '', + persisted: true, + }; + if (index < 0) next.push(entry); + else next[index] = entry; + } + if (!view.running) { + const persistedIds = new Set( + view.messages.map((message) => message.id), + ); + next = next.filter((entry) => persistedIds.has(entry.messageId)); + } + publish(next); + }, + [publish], + ); + + const discardUnpersisted = useCallback(() => { + publish(entriesRef.current.filter((entry) => entry.persisted)); + }, [publish]); + + useEffect(() => { + const timer = window.setInterval(() => { + let changed = false; + const next = entriesRef.current.map((entry) => { + const remaining = entry.target.length - entry.visible.length; + if (remaining <= 0) return entry; + changed = true; + const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1; + return { + ...entry, + visible: entry.target.slice(0, entry.visible.length + step), + }; + }); + if (changed) publish(next); + }, 50); + return () => window.clearInterval(timer); + }, [publish]); + + return { replies, reset, receiveText, receiveView, discardUnpersisted }; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 79f1e49b7..71baf3f5b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -405,11 +405,159 @@ export function registerDesignAgentSurfaceTests() { ); }); + it('keeps one Design Agent reply when repeated completion arrives after typing catches up', async () => { + const harness = createProjectChatRuntimeHarness({ + designAgentView: designConversationView(), + }); + const originalInvoke = harness.invoke.getMockImplementation()!; + let finish!: (view: unknown) => void; + harness.invoke.mockImplementation((command, args) => + command === 'continue_design_agent_session' + ? new Promise((resolve) => { + finish = resolve; + }) + : originalInvoke(command, args), + ); + renderDesignAgent(harness); + const input = await screen.findByLabelText('项目需求'); + await expectDesignModelReady(); + await setComposerText(input, '只回复 pong'); + fireEvent.submit(input.closest('form') as HTMLFormElement); + await waitFor(() => expect(finish).toBeDefined()); + const call = harness.invoke.mock.calls.find( + ([command]) => command === 'continue_design_agent_session', + )!; + const clientTurnId = String( + (call[1] as { clientTurnId: string }).clientTurnId, + ); + const messageId = `${clientTurnId}:response:0`; + const view = { + ...designConversationView(), + messages: [{ id: messageId, role: 'assistant', text: 'pong' }], + }; + const emit = (payload: Record) => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + ...payload, + }); + act(() => emit({ kind: 'text', messageId, text: 'pong' })); + await screen.findByText('pong'); + act(() => emit({ kind: 'state', view })); + act(() => emit({ kind: 'state', view })); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + expect(screen.getAllByText('pong')).toHaveLength(1); + expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull(); + await act(async () => finish(view)); + expect(screen.getAllByText('pong')).toHaveLength(1); + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'continue_design_agent_session', + ), + ).toHaveLength(1); + }); + + for (const firstDelivery of ['event', 'command'] as const) { + it(`animates a whole Design Agent reply once when ${firstDelivery} completes first`, async () => { + const history = { id: 'history', role: 'assistant', text: '历史回复' }; + const harness = createProjectChatRuntimeHarness({ + designAgentView: { ...designConversationView(), messages: [history] }, + }); + const originalInvoke = harness.invoke.getMockImplementation()!; + const requests: { + clientTurnId: string; + finish: (view: unknown) => void; + }[] = []; + harness.invoke.mockImplementation((command, args) => + command === 'continue_design_agent_session' + ? new Promise((resolve) => { + requests.push({ + clientTurnId: String(args?.clientTurnId), + finish: resolve, + }); + }) + : originalInvoke(command, args), + ); + renderDesignAgent(harness); + await screen.findByText(history.text); + await expectDesignModelReady(); + const input = screen.getByLabelText('项目需求'); + await setComposerText(input, '继续'); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(requests).toHaveLength(1)); + const request = requests[0]; + const messageId = `${request.clientTurnId}:response:0`; + const reply = '整块返回也逐步显示'; + const terminal = { + ...designConversationView(), + messages: [history, { id: messageId, role: 'assistant', text: reply }], + }; + const emit = (payload: Record) => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId: request.clientTurnId, + ...payload, + }); + if (firstDelivery === 'event') + act(() => emit({ kind: 'state', view: terminal })); + else await act(async () => request.finish(terminal)); + const bubble = screen.getByLabelText('策划 Agent 实时回复'); + expect(bubble.textContent).not.toBe(reply); + expect(screen.getAllByText(history.text)).toHaveLength(1); + expect( + screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'), + ).toBe(false); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + const prefix = bubble.textContent; + expect(prefix?.length).toBeGreaterThan(0); + act(() => { + emit({ kind: 'state', view: terminal }); + emit({ kind: 'state', view: terminal }); + emit({ kind: 'text', messageId, text: '' }); + }); + expect(bubble.textContent).toBe(prefix); + await screen.findByText(reply); + expect(screen.getByText(reply).closest('.message')).toBe(bubble); + expect( + document.querySelectorAll(`[data-message-id="${messageId}"]`), + ).toHaveLength(1); + expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull(); + + // 用户可在旧 invoke 尚未返回时开始下一轮;旧 finally 不能解锁新回合。 + await setComposerText(input, '下一轮'); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(requests).toHaveLength(2)); + act(() => { + emit({ kind: 'text', messageId: 'stale', text: '迟到旧回复' }); + emit({ kind: 'state', view: terminal }); + }); + await act(async () => request.finish(terminal)); + expect( + screen.getByRole('button', { name: '思考中' }).hasAttribute('disabled'), + ).toBe(true); + expect(screen.queryByText('迟到旧回复')).toBeNull(); + await act(async () => requests[1].finish(terminal)); + }); + } + it('follows streamed Design Agent content until the user scrolls up', async () => { const harness = createProjectChatRuntimeHarness({ designAgentView: designConversationView(), designAgentContinueView: designConversationView(), }); + const originalInvoke = harness.invoke.getMockImplementation()!; + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + harness.invoke.mockImplementation(async (command, args) => { + if (command === 'continue_design_agent_session') await gate; + return originalInvoke(command, args); + }); renderDesignAgent(harness); const input = await screen.findByLabelText('项目需求'); @@ -459,11 +607,26 @@ export function registerDesignAgentSurfaceTests() { projectPath: harness.projectPath, clientTurnId, kind: 'text', + messageId: `${clientTurnId}:response:0`, text: '正在补充关卡节奏', }), ); await screen.findByLabelText('策划 Agent 实时回复'); await waitFor(() => expect(messageList.scrollTop).toBe(1000)); + const replyBeforeTool = screen.getByLabelText('策划 Agent 实时回复'); + const prefixBeforeTool = replyBeforeTool.textContent; + act(() => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + kind: 'tool', + text: '正在读取方案文件', + }), + ); + expect(screen.getByLabelText('策划 Agent 工具状态').textContent).toBe( + '正在读取方案文件', + ); + expect(replyBeforeTool.textContent).toBe(prefixBeforeTool); messageList.scrollTop = 120; fireEvent.scroll(messageList); @@ -478,6 +641,7 @@ export function registerDesignAgentSurfaceTests() { projectPath: harness.projectPath, clientTurnId, kind: 'text', + messageId: `${clientTurnId}:response:0`, text: '正在补充关卡节奏与多人规则', }); }); @@ -488,6 +652,7 @@ export function registerDesignAgentSurfaceTests() { ).toContain('多人规则'), ); expect(messageList.scrollTop).toBe(120); + await act(async () => finish()); }); it('shows only known optimistic send times and does not invent persisted times', async () => { diff --git a/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx new file mode 100644 index 000000000..e51bb4fb3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import type { DesignView } from '../src/app/types'; +import { useDesignReplyAnimation } from '../src/view/project-development/planning/useDesignReplyAnimation'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +function view(messages: DesignView['messages'], running = false): DesignView { + return { + session: { + sessionId: 's', + projectId: 'p', + currentPhase: 'concept', + approvedPhases: [], + pendingApproval: null, + pendingClarification: null, + turnIndex: 1, + lastError: null, + }, + messages, + running, + canRetry: false, + }; +} + +it('keeps each reply progress across tool snapshots and repeated completion', () => { + const { result } = renderHook(useDesignReplyAnimation); + const history = { id: 'history', role: 'assistant', text: '历史' }; + const first = { id: 't:response:0', role: 'assistant', text: '第一条回复' }; + const second = { id: 't:response:1', role: 'assistant', text: '第二条回复' }; + act(() => { + result.current.reset([history.id]); + result.current.receiveText(first.id, first.text); + vi.advanceTimersByTime(100); + }); + expect(result.current.replies[0].visible).toBe('第一'); + act(() => + result.current.receiveView( + view( + [history, first, { id: 'tool', role: 'tool', text: '工具已完成' }], + true, + ), + ), + ); + expect(result.current.replies).toHaveLength(1); + expect(result.current.replies[0].visible).toBe('第一'); + act(() => result.current.receiveText(second.id, second.text)); + const terminal = view([history, first, second]); + act(() => { + result.current.receiveView(terminal); + result.current.receiveView(terminal); + }); + expect(result.current.replies.map((reply) => reply.visible)).toEqual([ + '第一', + '', + ]); + act(() => vi.advanceTimersByTime(500)); + act(() => result.current.receiveView(terminal)); + expect(result.current.replies.map((reply) => reply.visible)).toEqual([ + first.text, + second.text, + ]); +}); + +it('resets only an unpersisted retry attempt and discards failed partial output', () => { + const { result } = renderHook(useDesignReplyAnimation); + act(() => { + result.current.receiveText('t:response:0', '尝试失败'); + vi.advanceTimersByTime(100); + }); + expect(result.current.replies[0].visible).toBe('尝试'); + act(() => result.current.receiveText('t:response:0', '')); + expect(result.current.replies[0].visible).toBe(''); + const saved = { id: 't:response:0', role: 'assistant', text: '成功' }; + act(() => { + result.current.receiveText(saved.id, saved.text); + result.current.receiveView(view([saved], true)); + vi.advanceTimersByTime(100); + result.current.receiveText(saved.id, ''); + result.current.receiveText('t:response:1', '未被接受的文本'); + result.current.receiveView(view([saved])); + }); + expect(result.current.replies).toEqual([ + { messageId: saved.id, target: '成功', visible: '成功', persisted: true }, + ]); + act(() => { + result.current.receiveText('t:response:2', '连接中断'); + result.current.discardUnpersisted(); + }); + expect(result.current.replies).toHaveLength(1); + act(() => result.current.reset([saved.id])); + expect(result.current.replies).toEqual([]); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 56fa9611a..d9b3ca0f6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## 策划回复的重复终态不能重新启动伪流式 + +策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。 + ## Direct 宿主继续请求不能重发原始用户条目 原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 25451c57d..ea2b95d76 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -255,6 +255,11 @@ Rust 分片日志在失败时输出有界 stdout 尾部中的失败段,保留 - 工作区状态条复用现有聊天状态条的字号、间距和状态点。长项目名允许换行,不能撑宽面板;长错误、澄清和批量文件导入结果在受限区域内完整可读,不能挤走输入框。输入编辑器保留原有高度上限,模型/推理菜单继续允许弹出面板。 - 已在消息底部时,实时正文与思考增高后继续跟随;用户主动上滚阅读历史后不自动抢回底部,发送新消息后恢复跟随。阶段/待处理卡片改变消息可用高度时,仍遵守同一跟随意图。滚动仅为临时 UI 状态,不写入正式会话。 - 历史消息只有收到真实发送时间才能显示时间。现有策划持久消息不含逐消息时间,读取或刷新时保持未知,不以当前时刻补造。当前会话刚提交的乐观消息可以显示已知发送时间;正式快照覆盖后不补造或猜配旧消息时间。 +- 策划正文保留伪流式,实时文本与正式消息按后端 `messageId` 共用一个显示位置;事件归属由项目与 `clientTurnId` 约束。同一消息的重复状态事件及命令返回只更新正文目标,不重置播放进度,不能从最后一条历史消息猜测本轮回复。没有流事件的整块新回复也逐步显示;已加载历史不重播。 +- 请求完成立即应用正式视图、释放业务忙碌状态,未完成的正文动画继续播放;动画完成只改变显示状态。一个回合内多条正文分别保留身份;工具状态独立显示,不能覆盖正文。真实 Provider 重试的空文本事件仅清空尚未持久化的对应消息;已经持久化的消息不受迟到文本重置。新回合开始时收起上轮动画并显示完整历史,切换项目清理临时显示;上轮迟到事件和命令返回不得污染新回合。 +- 终态以正式视图为准,丢弃没有进入正式消息的失败尝试文本,错误继续由既有错误区显示。正文呈现回归使用模拟原生事件与真实 React 组件,覆盖动画已追平/未追平时的重复终态、命令先返回、整块输出、工具间多条正文、重试清空、历史恢复和迟到回合。此项不改 Provider 重试、后端协议或持久数据,无迁移要求。 + +正文呈现验收由 `appSurface/design-agent.suite.ts` 与 `designReplyAnimation.test.tsx` 覆盖:整块返回仍播放、同 ID 气泡原位接管、重复终态与命令返回幂等、多正文与重试空串、工具提示不覆盖正文、上轮请求不能结束新回合。重复终态复现用例在修复前实现上失败;`appSurface.test.ts`、动画 hook 和会话恢复测试合计 217 项通过、9 项原有跳过。App typecheck(含配置检查)、定向 ESLint、编码和文档索引检查通过。本次使用模拟原生事件及 React/jsdom,不含真实 Provider 或安装包 GUI 演练。 - ≤760px 时资源区与对话区单列排列,策划工作台在固定外壳内纵向滚动,用户向下滚动可到达输入区。布局使用内容高度,并以同等或更高选择器优先级覆盖外壳的 `height: 100%`;资源区明确为 560px,对话区高度为 `clamp(560px, calc(100dvh - 154px), 900px)`。文件树与消息列表各自内部滚动,长内容不增加两块面板高度。>760px 继续共用外壳剩余高度,不启用工作台整体滚动。正式主窗最小宽度不变,窄屏验收覆盖浏览器响应式布局。 - 审批、澄清、导入期间禁用、错误重试、项目归属和发送权限沿用现有行为。此次调整不改变 Runtime、API、持久协议或数据库,无数据迁移;不重做开发 Agent 的对话布局。 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/22] 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/22] =?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` 包的显示名保持一致。 From 2c778fb027d32cbc352072bfdf4fb9aa00336f42 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 23 Sep 2026 11:03:03 +0000 Subject: [PATCH 16/22] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E5=9F=9F=E9=87=8D=E7=BD=AE=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E5=BF=99=E7=A2=8C=E6=80=81=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重置聊天状态时同步清空 chatAgentBusy,保留旧请求的回合归属检查。 同步更新策划会话的作用域清理约定。 --- apps/ai-game-creator-shell/src/App.tsx | 1 + .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 8a9c7e24b..59635c242 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1292,6 +1292,7 @@ export function App({ * 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。 */ function resetChatState() { + setChatAgentBusy(false); setChatFilesImporting(false); setChatFileImportNotice(''); setProjectChatError(''); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ea2b95d76..3306d27ba 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -256,7 +256,7 @@ Rust 分片日志在失败时输出有界 stdout 尾部中的失败段,保留 - 已在消息底部时,实时正文与思考增高后继续跟随;用户主动上滚阅读历史后不自动抢回底部,发送新消息后恢复跟随。阶段/待处理卡片改变消息可用高度时,仍遵守同一跟随意图。滚动仅为临时 UI 状态,不写入正式会话。 - 历史消息只有收到真实发送时间才能显示时间。现有策划持久消息不含逐消息时间,读取或刷新时保持未知,不以当前时刻补造。当前会话刚提交的乐观消息可以显示已知发送时间;正式快照覆盖后不补造或猜配旧消息时间。 - 策划正文保留伪流式,实时文本与正式消息按后端 `messageId` 共用一个显示位置;事件归属由项目与 `clientTurnId` 约束。同一消息的重复状态事件及命令返回只更新正文目标,不重置播放进度,不能从最后一条历史消息猜测本轮回复。没有流事件的整块新回复也逐步显示;已加载历史不重播。 -- 请求完成立即应用正式视图、释放业务忙碌状态,未完成的正文动画继续播放;动画完成只改变显示状态。一个回合内多条正文分别保留身份;工具状态独立显示,不能覆盖正文。真实 Provider 重试的空文本事件仅清空尚未持久化的对应消息;已经持久化的消息不受迟到文本重置。新回合开始时收起上轮动画并显示完整历史,切换项目清理临时显示;上轮迟到事件和命令返回不得污染新回合。 +- 请求完成立即应用正式视图、释放业务忙碌状态,未完成的正文动画继续播放;动画完成只改变显示状态。一个回合内多条正文分别保留身份;工具状态独立显示,不能覆盖正文。真实 Provider 重试的空文本事件仅清空尚未持久化的对应消息;已经持久化的消息不受迟到文本重置。新回合开始时收起上轮动画并显示完整历史,切换项目清理临时显示;重置聊天作用域时同时清空活动回合与忙碌态,不依赖旧请求返回或新会话恢复来解锁输入。上轮迟到事件和命令返回不得污染新回合。 - 终态以正式视图为准,丢弃没有进入正式消息的失败尝试文本,错误继续由既有错误区显示。正文呈现回归使用模拟原生事件与真实 React 组件,覆盖动画已追平/未追平时的重复终态、命令先返回、整块输出、工具间多条正文、重试清空、历史恢复和迟到回合。此项不改 Provider 重试、后端协议或持久数据,无迁移要求。 正文呈现验收由 `appSurface/design-agent.suite.ts` 与 `designReplyAnimation.test.tsx` 覆盖:整块返回仍播放、同 ID 气泡原位接管、重复终态与命令返回幂等、多正文与重试空串、工具提示不覆盖正文、上轮请求不能结束新回合。重复终态复现用例在修复前实现上失败;`appSurface.test.ts`、动画 hook 和会话恢复测试合计 217 项通过、9 项原有跳过。App typecheck(含配置检查)、定向 ESLint、编码和文档索引检查通过。本次使用模拟原生事件及 React/jsdom,不含真实 Provider 或安装包 GUI 演练。 From c38d07044a57ffa71c22c75cb933a78382799ffa Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:04:24 +0800 Subject: [PATCH 17/22] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E6=89=93=E5=BC=80=E6=97=B6=E8=87=AA=E7=BB=98=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E7=AA=97=E5=8F=A3=E6=8C=89=E9=92=AE=E5=A4=B1?= =?UTF-8?q?=E6=95=88=20-=20ThemedModal=20=E7=9A=84=E7=84=A6=E7=82=B9?= =?UTF-8?q?=E9=99=B7=E9=98=B1=E5=8F=AA=E6=94=BE=E8=A1=8C=E8=90=BD=E5=9C=A8?= =?UTF-8?q?=20[data-window-chrome-bar]=20=E5=86=85=E7=9A=84=E7=82=B9?= =?UTF-8?q?=E5=87=BB=EF=BC=8C=E6=A0=87=E9=A2=98=E6=A0=8F=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E4=B8=8E=E6=9C=80=E5=B0=8F=E5=8C=96/=E6=9C=80=E5=A4=A7?= =?UTF-8?q?=E5=8C=96/=E5=85=B3=E9=97=AD=E6=81=A2=E5=A4=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=EF=BC=8C=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E4=BB=8D=E8=A2=AB=E6=8B=A6=E4=BD=8F=20-=20Wi?= =?UTF-8?q?ndowChrome=20=E6=A0=87=E9=A2=98=E6=A0=8F=E5=8A=A0=20data-window?= =?UTF-8?q?-chrome-bar=20=E6=A0=87=E8=AE=B0=EF=BC=8C=E4=BD=9C=E4=B8=BA?= =?UTF-8?q?=E8=BF=99=E6=9D=A1=E7=BA=A6=E5=AE=9A=E7=9A=84=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E7=82=B9=20-=20styles.css=20=E6=98=8E?= =?UTF-8?q?=E7=A1=AE=E3=80=8C=E5=85=A8=E5=B1=8F=E5=BC=B9=E5=B1=82=E4=B8=80?= =?UTF-8?q?=E5=BE=8B=E4=BB=8E=E6=A0=87=E9=A2=98=E6=A0=8F=E4=B8=8B=E6=96=B9?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E3=80=8D=EF=BC=8C.app-update-overlay=20?= =?UTF-8?q?=E4=BB=8E=20inset:0=20=E6=94=B9=E4=B8=BA=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=A0=8F=E4=B8=8B=E6=96=B9=EF=BC=8C.game-publish-progress-over?= =?UTF-8?q?lay=20=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E=20top=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20tests/windowChromeOverlayContract.test.ts?= =?UTF-8?q?=20=E8=A6=86=E7=9B=96=207=20=E4=B8=AA=E5=85=A8=E5=B1=8F?= =?UTF-8?q?=E5=BC=B9=E5=B1=82=EF=BC=9BthemedModal=20/=20WindowChrome=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E8=A1=A5=E3=80=8C=E6=A0=87=E9=A2=98=E6=A0=8F?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E6=94=BE=E8=A1=8C=20+=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E7=82=B9=E5=87=BB=E4=BB=8D=E8=A2=AB=E6=8B=A6=E3=80=8D?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=EF=BC=9BgamePublishFeedback=20=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=8C=89=E6=96=B0=E5=8F=A3=E5=BE=84=E6=96=AD=E8=A8=80?= =?UTF-8?q?=20-=20pitfalls=20=E8=AE=B0=E5=BD=95=E8=AF=A5=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E7=9A=84=E6=9C=BA=E5=88=B6=E4=B8=8E=E7=8E=B0?= =?UTF-8?q?=E8=A1=8C=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/WindowChrome.tsx | 6 ++- .../src/components/modal/ThemedModal.tsx | 18 +++++++ apps/ai-game-creator-shell/src/styles.css | 20 +++++-- .../tests/WindowChrome.test.tsx | 54 ++++++++++++++++++- .../tests/gamePublishFeedback.test.tsx | 6 ++- .../tests/themedModal.test.tsx | 47 ++++++++++++++++ .../tests/windowChromeOverlayContract.test.ts | 53 ++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 8 +++ 8 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index e12c46cd8..19d87059d 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -149,7 +149,11 @@ export function WindowChrome({ children }: WindowChromeProps) {
{appUpdateCheckEnabled ? : null} -
+
panelRef.current!, returnFocusOnDeactivate: true, + allowOutsideClick: (event) => isWindowChromeBarTarget(event.target), }} >
({ + minimize: vi.fn(), + toggleMaximize: vi.fn(), + isMaximized: vi.fn(), + close: vi.fn(), + label: 'client', +})); + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => nativeWindow, +})); + function TitleSetter({ value }: { value: string }) { const { setTitle } = useWindowChrome(); return ( @@ -36,6 +49,14 @@ function ActiveRunsSetter({ } describe('WindowChrome', () => { + beforeEach(() => { + nativeWindow.minimize.mockReset(); + nativeWindow.toggleMaximize.mockReset(); + nativeWindow.isMaximized.mockReset(); + nativeWindow.close.mockReset(); + delete (window as unknown as Record).__TAURI_INTERNALS__; + }); + it('renders the陶泥儿 brand, default title, and controls', async () => { const user = userEvent.setup(); render( @@ -141,4 +162,35 @@ describe('WindowChrome', () => { ); expect(screen.getAllByRole('menuitem')).toHaveLength(2); }); + + /** + * 回归:发布面板等 ThemedModal 弹窗打开时,标题栏在模态之外,焦点陷阱曾把 + * 标题栏上的点击一起拦下 —— 三个窗口按钮看着正常但点不动。 + */ + it('keeps the window controls working while a modal covers the workspace', async () => { + const user = userEvent.setup(); + nativeWindow.minimize.mockResolvedValue(undefined); + nativeWindow.toggleMaximize.mockResolvedValue(undefined); + nativeWindow.close.mockResolvedValue(undefined); + nativeWindow.isMaximized.mockResolvedValue(false); + (window as unknown as Record).__TAURI_INTERNALS__ = {}; + + render( + + undefined} ariaLabel="测试弹窗"> + + + , + ); + await screen.findByRole('dialog', { name: '测试弹窗' }); + + await user.click(screen.getByRole('button', { name: '最小化' })); + expect(nativeWindow.minimize).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: '最大化' })); + expect(nativeWindow.toggleMaximize).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: '关闭' })); + expect(nativeWindow.close).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index 91b84b4a7..786c790a7 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -266,7 +266,11 @@ describe('客户端发布入口的可见反馈', () => { 1440, ); expect(declaration(overlay, 'position')).toBe('fixed'); - expect(declaration(overlay, 'inset')).toBe('0'); + // 遮罩从自绘标题栏下方开始:发布进行中仍然要能最小化 / 关闭窗口。 + expect(declaration(overlay, 'top')).toBe('var(--window-chrome-height)'); + expect(declaration(overlay, 'right')).toBe('0'); + expect(declaration(overlay, 'bottom')).toBe('0'); + expect(declaration(overlay, 'left')).toBe('0'); expect(declaration(overlay, 'z-index')).toBe('500'); expect(declaration(overlay, 'pointer-events')).toBe('auto'); expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)'); diff --git a/apps/ai-game-creator-shell/tests/themedModal.test.tsx b/apps/ai-game-creator-shell/tests/themedModal.test.tsx index 5242722e0..3d0be715e 100644 --- a/apps/ai-game-creator-shell/tests/themedModal.test.tsx +++ b/apps/ai-game-creator-shell/tests/themedModal.test.tsx @@ -35,6 +35,34 @@ function ModalHarness({ noFocusableContent = false }) { ); } +/** + * 标题栏在模态之外,但它是窗口边框:弹窗打开时最小化 / 最大化 / 关闭必须照常可点。 + * 工作区内容反过来仍要被模态挡住,不能因为放行标题栏就一起漏过去。 + */ +function WindowChromeHarness({ + onMinimize, + onWorkspaceClick, +}: { + onMinimize: () => void; + onWorkspaceClick: () => void; +}) { + return ( + <> +
+ +
+ + undefined} ariaLabel="测试弹窗"> + + + + ); +} + describe('ThemedModal', () => { beforeEach(() => { vi.spyOn(HTMLElement.prototype, 'getClientRects').mockImplementation( @@ -105,4 +133,23 @@ describe('ThemedModal', () => { await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); expect(document.activeElement).toBe(opener); }); + + it('lets window title bar clicks through while workspace clicks stay trapped', async () => { + const user = userEvent.setup(); + const onMinimize = vi.fn(); + const onWorkspaceClick = vi.fn(); + render( + , + ); + await screen.findByRole('dialog', { name: '测试弹窗' }); + + await user.click(screen.getByRole('button', { name: '最小化' })); + expect(onMinimize).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: '工作区按钮' })); + expect(onWorkspaceClick).not.toHaveBeenCalled(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts b/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts new file mode 100644 index 000000000..b9bbb16d9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment jsdom + +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { repoPath } from './repoPath'; +import { parseStyleSheet } from './styleCascade'; + +const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css'); + +/** + * 全屏弹层清单:每一层都必须从自绘标题栏下方开始。 + * + * 标题栏是窗口边框,不是弹层内容 —— 只要有一个全屏遮罩盖住它,弹窗打开时 + * 「最小化 / 最大化 / 关闭」就会被挡住。焦点陷阱那一半的问题见 + * `themedModal.test.tsx` 与 `WindowChrome.test.tsx`;新增全屏弹层时把类名加进这份清单。 + */ +const WINDOW_CHROME_SAFE_OVERLAYS = [ + // ThemedModal 与共享弹层的通用遮罩:top 由这条规则统一抬到标题栏下方。 + '.fixed.inset-0', + '.app-update-overlay', + '.game-publish-progress-overlay', + '.launcher-dialog-backdrop', + '.settings-overlay', + '.game-approval-backdrop', + '.project-chat-settings-backdrop', +] as const; + +function declarationsForSelector(css: string, selector: string) { + const merged = new Map(); + for (const rule of parseStyleSheet(css)) { + if (!rule.selectors.includes(selector)) { + continue; + } + for (const [property, value] of rule.declarations) { + merged.set(property, value); + } + } + return merged; +} + +describe('窗口标题栏与全屏弹层的层叠约定', () => { + const css = readFileSync(STYLES_PATH, 'utf8'); + + it.each(WINDOW_CHROME_SAFE_OVERLAYS)('%s 从标题栏下方开始', (selector) => { + const declarations = declarationsForSelector(css, selector); + expect( + declarations.get('top'), + `${selector} 必须声明 top: var(--window-chrome-height)`, + ).toBe('var(--window-chrome-height)'); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 56fa9611a..e2daabf13 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## 2026-09-23 弹窗打开时自绘标题栏的最小化 / 最大化 / 关闭静默失效 + +- **现象**:AGC 打开「发布到游戏广场」面板(以及其它任何弹窗)后,右上角三个窗口按钮点了没有任何反应,拖拽标题栏也不能移动窗口;关掉弹窗立刻恢复。标题栏看着完全正常,遮罩也明显只压住了下面的工作区,所以很容易误判成「按钮自己坏了」或 Tauri 窗口 API 挂了。 +- **原因**:标题栏在模态之外,但它是窗口边框。`ThemedModal` 用的 `focus-trap-react` 在 **document 捕获阶段**监听 `mousedown`/`touchstart`/`click`:模态外的点击一律 `preventDefault()`,`click` 还会 `stopImmediatePropagation()`。React 的监听挂在 document 内的根容器上,捕获阶段就被掐掉的 `click` 永远到不了 React,于是既不报错也不执行 —— 与「焦点陷阱吞掉模态外点击」是同一类问题(见 2026-09-20 发布面板焦点陷阱那条)。另有一条独立的同类缺陷:`.app-update-overlay` 用 `inset: 0`,把标题栏真的盖住了,更新弹窗期间按钮被遮罩挡住。 +- **处理(现行口径)**:① 全屏弹层一律从标题栏下方开始(`top: var(--window-chrome-height)`),不得用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容点击继续被拦;③ 新增全屏弹层时把类名补进 `apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts` 的清单。 +- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/themedModal.test.tsx apps/ai-game-creator-shell/tests/WindowChrome.test.tsx apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts`(标题栏点击放行、工作区点击仍被拦、7 个全屏弹层都在标题栏下方);两个新增用例去掉修复后确实失败,确认能守住这条约定。 +- **关联**:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/styles.css`。 + ## Direct 宿主继续请求不能重发原始用户条目 原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 From c2c5e1ced549ccb8f7e6a1961ea7ed4ccada506a Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:04:45 +0800 Subject: [PATCH 18/22] =?UTF-8?q?=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E6=8F=90=E5=8D=87=E5=88=B0=20200=20MiB=20=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20AGC=20=E5=88=86=E7=89=87=E7=BB=AD=E4=BC=A0?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20-=20=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A?= =?UTF-8?q?=E9=99=90=20100=E2=86=92200=20MiB=E3=80=81=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E6=80=BB=E9=87=8F=20250=E2=86=92500=20MiB=EF=BC=8C=E6=95=B4?= =?UTF-8?q?=E5=8C=85=E8=B7=AF=E7=94=B1=E8=AF=B7=E6=B1=82=E4=BD=93=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E7=BB=A7=E7=BB=AD=E4=BB=8E=E5=8C=85=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=EF=BC=9B=E5=8F=91=E8=A1=8C=E9=9D=99=E6=80=81?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E8=BF=9B=E7=A8=8B=E5=86=85=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E6=8F=90=E5=88=B0=20256=20MiB=20-=20?= =?UTF-8?q?=E5=8F=8D=E4=BB=A3=E6=94=BE=E8=A1=8C=E9=87=8F=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=94=BE=E5=AE=BD=E5=88=B0=20210=20MiB=EF=BC=9ANginx=20?= =?UTF-8?q?=E4=B8=89=E4=BB=BD=E6=A8=A1=E6=9D=BF=E7=9A=84=20client=5Fmax=5F?= =?UTF-8?q?body=5Fsize=E3=80=81Pingora=20=E7=BD=91=E5=85=B3=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=80=BC=E4=B8=8E=20env=20=E6=A0=B7=E4=BE=8B=E3=80=81?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=AF=B9=E7=85=A7=E7=9F=A9=E9=98=B5=20-=20pl?= =?UTF-8?q?atform-oss=20=E6=96=B0=E5=A2=9E=E5=86=85=E9=83=A8=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E8=BF=BD=E5=8A=A0=E5=86=99=20append=5Finternal=5Fobje?= =?UTF-8?q?ct(=5Fwith=5Fretry)=EF=BC=8C=E4=BB=A5=20OSS=20=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E7=9A=84=20next-append-position=20=E4=BD=9C=E4=B8=BA?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E5=B7=B2=E6=94=B6=E5=AD=97=E8=8A=82=20-=20ap?= =?UTF-8?q?i-server=20=E6=96=B0=E5=A2=9E=20upload-state=20/=20chunk=20/=20?= =?UTF-8?q?complete=20/=20reset=20=E5=9B=9B=E6=9D=A1=E5=88=86=E7=89=87?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=EF=BC=8C=E6=8A=BD=E5=87=BA=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20confirm=5Fvalidated=5Fpackage=EF=BC=9B?= =?UTF-8?q?=E5=81=8F=E7=A7=BB=E4=B8=8D=E7=AC=A6=E8=BF=94=E5=9B=9E=20409=20?= =?UTF-8?q?=E4=B8=8E=E6=9D=83=E5=A8=81=E5=81=8F=E7=A7=BB=EF=BC=8C=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=A4=B1=E8=B4=A5=E5=88=A0=E9=99=A4=E5=8D=8A=E5=8C=85?= =?UTF-8?q?=E5=B9=B6=E8=90=BD=20upload=5Ffailed=20-=20AGC=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=8E=9F=E7=94=9F=E4=B8=8A=E4=BC=A0=E5=99=A8=20game?= =?UTF-8?q?=5Fpackage=5Fupload.rs=EF=BC=88=E5=86=85=E5=AE=B9=E5=AF=BB?= =?UTF-8?q?=E5=9D=80=E6=9A=82=E5=AD=98=E3=80=81=E5=88=86=E7=89=87=E7=BB=AD?= =?UTF-8?q?=E4=BC=A0=E3=80=81=E5=8F=97=E6=8E=A7=E9=87=8D=E8=AF=95=E3=80=81?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E4=BA=8B=E4=BB=B6=EF=BC=89=E4=B8=8E=20prepar?= =?UTF-8?q?e=20/=20upload=20=E4=B8=A4=E6=9D=A1=E5=91=BD=E4=BB=A4=EF=BC=8C?= =?UTF-8?q?=E9=80=80=E5=BD=B9=E6=95=B4=E5=8C=85=E5=9B=9E=E4=BC=A0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=20-=20=E6=B8=B2=E6=9F=93=E8=BF=9B=E7=A8=8B=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20prepare=20=E2=86=92=20=E5=88=9B=E5=BB=BA=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=20=E2=86=92=20=E5=88=9B=E5=BB=BA=E7=89=88=E6=9C=AC=20?= =?UTF-8?q?=E2=86=92=20=E5=8E=9F=E7=94=9F=E5=88=86=E7=89=87=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20=E2=86=92=20=E9=80=81=E5=AE=A1=EF=BC=8CLocalProject?= =?UTF-8?q?ExportPackagePayload=20=E6=95=B4=E5=8C=85=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E9=80=80=E5=BD=B9=20-=20=E5=90=8C=E6=97=B6=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20live=20=E7=94=A8=E4=BE=8B=E6=97=A0=E6=B3=95=E6=8C=87?= =?UTF-8?q?=E5=90=91=E6=9C=AC=E5=9C=B0=E6=A0=88=E7=9A=84=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=E9=97=AE=E9=A2=98=EF=BC=9A?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=9F=BA=E5=9D=80=E6=8C=89=E4=BC=A0=E5=85=A5?= =?UTF-8?q?=20URL=20=E9=80=89=E6=8B=A9=EF=BC=8C=E6=A1=A5=E6=8E=A5=E5=B1=82?= =?UTF-8?q?=E6=8A=8A=20jsdom=20realm=20=E7=9A=84=20Headers=20/=20Blob=20/?= =?UTF-8?q?=20FormData=20=E9=99=8D=E7=BA=A7=E6=88=90=20Node=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E5=80=BC=20-=20=E6=B5=8B=E8=AF=95=EF=BC=9Aplatform-os?= =?UTF-8?q?s=2074=E3=80=81api-server=20game=5Fdistribution=2023=E3=80=81AG?= =?UTF-8?q?C=20=E5=8E=9F=E7=94=9F=204=E3=80=81=E5=8F=91=E5=B8=83=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=89=8D=E7=AB=AF=2020=EF=BC=9Blive=20=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E8=A1=A5=E7=9C=9F=E5=AE=9E=E6=A0=88=E3=80=8C=E4=B8=AD?= =?UTF-8?q?=E6=96=AD=20=E2=86=92=20=E7=BB=AD=E4=BC=A0=20=E2=86=92=20?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E3=80=8D=E6=96=AD=E8=A8=80=EF=BC=88=E5=88=86?= =?UTF-8?q?=E7=89=87=E5=81=8F=E7=A7=BB=E5=BA=8F=E5=88=97=20[0,=208388608]?= =?UTF-8?q?=EF=BC=89=20-=20=E6=96=87=E6=A1=A3=EF=BC=9A=E7=8E=A9=E6=B3=95?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E4=B8=BB=E8=A7=84=E8=8C=83=E7=9A=84=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=90=88=E5=90=8C=E3=80=81=E8=BF=90=E7=BB=B4=E4=B8=8E?= =?UTF-8?q?=20Pingora=20=E6=96=87=E6=A1=A3=E3=80=81=E5=86=B3=E7=AD=96?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E3=80=81=E5=8F=91=E8=A1=8C=E9=87=8C=E7=A8=8B?= =?UTF-8?q?=E7=A2=91=E5=8F=A3=E5=BE=84=EF=BC=8C=E4=BB=A5=E5=8F=8A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9A=84=E7=BB=AD=E4=BC=A0=E9=87=8C=E7=A8=8B=E7=A2=91?= =?UTF-8?q?=E4=B8=8E=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 --- .../src-tauri/src/commands.rs | 73 ++- .../src-tauri/src/game_package_upload.rs | 533 +++++++++++++++++ .../src-tauri/src/main.rs | 4 +- apps/ai-game-creator-shell/src/app/types.ts | 17 +- .../src/services/gameDistributionPublish.ts | 60 +- .../tests/gameDistributionPublish.test.ts | 78 +-- .../tests/gameDistributionPublishLive.test.ts | 296 +++++++++- deploy/container/nginx.conf | 5 +- deploy/nginx/README.md | 4 +- deploy/nginx/genarrative-dev-http.conf | 5 +- deploy/nginx/genarrative.conf | 5 +- deploy/pingora/nginx-route-parity.matrix.json | 4 +- deploy/pingora/pingora-gateway.env.example | 2 +- ...施计划】AGC发行包分片续传上传-2026-09-23.md | 49 ++ ...里程碑】AGC发行包分片续传上传-2026-09-23.md | 58 ++ ...碑】游戏分发目录详情与在线游玩-2026-09-18.md | 2 +- .../shared-memory/decision-log.md | 15 + ...开发运维】Pingora独立网关试点-2026-06-11.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 7 +- .../src/modules/game_distribution.rs | 536 ++++++++++++++++-- .../module-game-distribution/src/package.rs | 49 +- server-rs/crates/pingora-gateway/src/main.rs | 4 +- server-rs/crates/platform-oss/src/lib.rs | 124 +++- .../game-distribution/gameZipPackage.test.ts | 10 +- .../game-distribution/gameZipPackage.ts | 4 +- 26 files changed, 1779 insertions(+), 169 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs create mode 100644 docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md create mode 100644 docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a18bdb369..4dbb39ad7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5935,14 +5935,81 @@ pub(crate) async fn export_local_project_package( export_local_project_package_for_publish_at(root).await } +/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。 +/// +/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过 +/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。 #[tauri::command] -pub(crate) fn read_local_project_export_package( +pub(crate) fn prepare_local_project_game_package( + app: tauri::AppHandle, project_path: String, package_relative_path: String, -) -> Result { +) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.export_package")?; - read_local_project_export_package_at(root, package_relative_path.trim()) + let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; + let staging_dir = game_package_upload_staging_dir(&app)?; + let mut staged = crate::game_package_upload::stage_game_package_bytes( + &staging_dir, + &payload.package_sha256, + &payload.package_bytes, + )?; + staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); + Ok(staged) +} + +/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。 +#[tauri::command] +pub(crate) async fn upload_local_project_game_package( + app: tauri::AppHandle, + staging_path: String, + version_id: String, + api_base_url: String, + access_token: String, + idempotency_key: String, +) -> Result { + let staging_dir = game_package_upload_staging_dir(&app)?; + let resolved_path = + crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; + let client = reqwest::Client::builder() + .build() + .map_err(|error| format!("创建上传客户端失败:{error}"))?; + let version_id = version_id.trim().to_string(); + if version_id.is_empty() { + return Err("缺少发行版本标识".to_string()); + } + let emit_handle = app.clone(); + let progress_version_id = version_id.clone(); + crate::game_package_upload::upload_staged_game_package( + &client, + crate::game_package_upload::GamePackageUploadRequest { + staging_path: &resolved_path, + version_id: &version_id, + api_base_url: api_base_url.trim(), + access_token: access_token.trim(), + idempotency_key: idempotency_key.trim(), + }, + move |received_bytes, total_bytes| { + let _ = emit_handle.emit( + crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, + crate::game_package_upload::progress_event_payload( + &progress_version_id, + received_bytes, + total_bytes, + ), + ); + }, + ) + .await +} + +fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|app_data_root| { + crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) + }) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs new file mode 100644 index 000000000..c52745b45 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -0,0 +1,533 @@ +//! AGC 发行包的分片续传上传。 +//! +//! 一键发布不再把整包字节交给 WebView:这里在原生进程中读取本地(或暂存)的试玩包, +//! 按服务端下发的分片大小顺序发送,并以服务端返回的**权威偏移**续传;进度通过事件 +//! 回传渲染进程。传输失败、应用重启后重新发布都只补传缺失字节,不重放整包。 + +use std::{ + fs::{self, File}, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; + +const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; +const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; +/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。 +const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024; +const CHUNK_MAX_ATTEMPTS: usize = 4; +const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500); +const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging"; + +/// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。 +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StagedGamePackage { + pub(crate) staging_path: String, + pub(crate) package_sha256: String, + pub(crate) package_size_bytes: u64, + pub(crate) package_file_count: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GamePackageUploadOutcome { + pub(crate) version_id: String, + pub(crate) status: String, + pub(crate) uploaded_bytes: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ChunkPlan { + offset: u64, + length: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct PackageUploadState { + #[serde(default)] + received_bytes: u64, + #[serde(default)] + chunk_bytes: u64, + #[serde(default)] + declared_package_bytes: u64, +} + +/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。 +pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf { + app_data_dir.join(UPLOAD_STAGING_DIR_NAME) +} + +/// 把归一化后的发行包暂存到内容寻址文件,返回续传所需的元数据。 +pub(crate) fn stage_game_package_bytes( + staging_dir: &Path, + package_sha256: &str, + package_bytes: &[u8], +) -> Result { + if package_bytes.is_empty() { + return Err("发行包内容为空,请重新导出试玩包".to_string()); + } + if package_sha256.len() != 64 || !package_sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("发行包摘要无效,请重新导出试玩包".to_string()); + } + fs::create_dir_all(staging_dir).map_err(|error| format!("创建发行包暂存目录失败:{error}"))?; + let staging_path = staging_dir.join(format!("{package_sha256}.zip")); + if !staging_path.is_file() { + let temporary_path = staging_dir.join(format!("{package_sha256}.zip.tmp")); + let mut file = File::create(&temporary_path) + .map_err(|error| format!("写入发行包暂存文件失败:{error}"))?; + file.write_all(package_bytes) + .map_err(|error| format!("写入发行包暂存文件失败:{error}"))?; + file.sync_all() + .map_err(|error| format!("刷写发行包暂存文件失败:{error}"))?; + drop(file); + fs::rename(&temporary_path, &staging_path) + .map_err(|error| format!("提交发行包暂存文件失败:{error}"))?; + } + Ok(StagedGamePackage { + staging_path: staging_path.to_string_lossy().to_string(), + package_sha256: package_sha256.to_string(), + package_size_bytes: package_bytes.len() as u64, + package_file_count: 0, + }) +} + +/// 暂存文件必须落在暂存目录内;渲染进程不能借这条命令读任意路径。 +pub(crate) fn ensure_staging_path_in_dir( + staging_dir: &Path, + staging_path: &str, +) -> Result { + let candidate = PathBuf::from(staging_path.trim()); + let parent = candidate + .parent() + .ok_or_else(|| "发行包暂存路径无效".to_string())?; + let canonical_parent = parent + .canonicalize() + .map_err(|error| format!("发行包暂存目录不可用:{error}"))?; + let canonical_dir = staging_dir + .canonicalize() + .map_err(|error| format!("发行包暂存目录不可用:{error}"))?; + if canonical_parent != canonical_dir { + return Err("发行包暂存路径越界".to_string()); + } + if !candidate.is_file() { + return Err("发行包暂存文件不存在,请重新导出试玩包".to_string()); + } + Ok(candidate) +} + +/// 下一次要发送的分片;`None` 表示整包已收齐。 +fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option { + if received >= total || chunk_bytes == 0 { + return None; + } + Some(ChunkPlan { + offset: received, + length: chunk_bytes.min(total - received), + }) +} + +fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result, String> { + file.seek(SeekFrom::Start(plan.offset)) + .map_err(|error| format!("读取发行包失败:{error}"))?; + let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)]; + file.read_exact(&mut buffer) + .map_err(|error| format!("读取发行包失败:{error}"))?; + Ok(buffer) +} + +fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> String { + format!( + "{}/api/game-distribution/versions/{version_id}/package{suffix}", + base_url.trim_end_matches('/') + ) +} + +/// 服务端错误信封:兼容带 envelope 的 `{ error: { code, message } }` 与旧形状。 +fn parse_server_error(status: u16, body: &str) -> (Option, Option) { + let parsed = serde_json::from_str::(body).ok(); + let error = parsed + .as_ref() + .and_then(|value| value.get("error")) + .cloned(); + let code = error + .as_ref() + .and_then(|value| value.get("code")) + .or_else(|| parsed.as_ref().and_then(|value| value.get("code"))) + .and_then(Value::as_str) + .map(str::to_string); + let message = error + .as_ref() + .and_then(|value| value.get("message")) + .or_else(|| parsed.as_ref().and_then(|value| value.get("message"))) + .and_then(Value::as_str) + .map(str::to_string); + let _ = status; + (code, message) +} + +/// 从 409 响应里取权威已收字节;取不到就返回 `None`,由调用方按失败处理。 +fn parse_received_bytes(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok()?; + let error = parsed.get("error").unwrap_or(&parsed); + error + .get("details") + .and_then(|details| details.get("receivedBytes")) + .or_else(|| error.get("receivedBytes")) + .and_then(Value::as_u64) +} + +fn platform_request( + client: &reqwest::Client, + access_token: &str, + method: reqwest::Method, + url: &str, +) -> reqwest::RequestBuilder { + client + .request(method, url) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {access_token}"), + ) +} + +async fn read_upload_state( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::GET, + &upload_url(base_url, version_id, "/upload-state"), + ) + .send() + .await + .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|error| format!("读取上传状态失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &body); + return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})"))); + } + let parsed: Value = serde_json::from_str(&body) + .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + serde_json::from_value(payload.clone()) + .map_err(|error| format!("上传状态响应缺少字段:{error}")) +} + +/// 上传一个分片;返回服务端确认后的已收字节。 +async fn upload_chunk( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, + plan: ChunkPlan, + body: Vec, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::PUT, + &upload_url(base_url, version_id, "/chunk"), + ) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .header(UPLOAD_OFFSET_HEADER, plan.offset.to_string()) + .header("Idempotency-Key", format!("{idempotency_key}:chunk")) + .body(body) + .send() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; + if status == 409 { + let (code, message) = parse_server_error(status, &text); + if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { + let received = parse_received_bytes(&text).ok_or_else(|| { + ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) + })?; + return Ok(received); + } + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), + )); + } + if status >= 500 || status == 408 || status == 429 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Retryable( + message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), + )); + } + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")), + )); + } + let parsed: Value = serde_json::from_str(&text) + .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; + let payload = parsed.get("data").unwrap_or(&parsed); + payload + .get("receivedBytes") + .and_then(Value::as_u64) + .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) +} + +enum ChunkUploadError { + Retryable(String), + Fatal(String), +} + +async fn complete_upload( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::POST, + &upload_url(base_url, version_id, "/complete"), + ) + .header("Idempotency-Key", format!("{idempotency_key}:complete")) + .send() + .await + .map_err(|error| format!("完成发行包上传失败:{error}"))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| format!("读取完成响应失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})"))); + } + let parsed: Value = + serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + Ok(GamePackageUploadOutcome { + version_id: payload + .get("versionId") + .and_then(Value::as_str) + .unwrap_or(version_id) + .to_string(), + status: payload + .get("status") + .and_then(Value::as_str) + .unwrap_or("uploaded") + .to_string(), + uploaded_bytes: 0, + }) +} + +pub(crate) struct GamePackageUploadRequest<'a> { + pub(crate) staging_path: &'a Path, + pub(crate) version_id: &'a str, + pub(crate) api_base_url: &'a str, + pub(crate) access_token: &'a str, + pub(crate) idempotency_key: &'a str, +} + +/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 +pub(crate) async fn upload_staged_game_package( + client: &reqwest::Client, + request: GamePackageUploadRequest<'_>, + mut on_progress: impl FnMut(u64, u64), +) -> Result { + let total_bytes = fs::metadata(request.staging_path) + .map_err(|error| format!("读取发行包暂存文件失败:{error}"))? + .len(); + if total_bytes == 0 { + return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); + } + let state = read_upload_state( + client, + request.api_base_url, + request.version_id, + request.access_token, + ) + .await?; + if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { + return Err(format!( + "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", + total_bytes, state.declared_package_bytes + )); + } + let chunk_bytes = if state.chunk_bytes == 0 { + MAX_CHUNK_BYTES + } else { + state.chunk_bytes.min(MAX_CHUNK_BYTES) + }; + let mut received = state.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + let mut file = File::open(request.staging_path) + .map_err(|error| format!("打开发行包暂存文件失败:{error}"))?; + while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) { + let body = read_chunk(&mut file, plan)?; + let mut attempt = 1_usize; + loop { + match upload_chunk( + client, + request.api_base_url, + request.version_id, + request.access_token, + request.idempotency_key, + plan, + body.clone(), + ) + .await + { + Ok(next_received) => { + received = next_received.min(total_bytes); + on_progress(received, total_bytes); + break; + } + Err(ChunkUploadError::Fatal(error)) => return Err(error), + Err(ChunkUploadError::Retryable(error)) => { + if attempt >= CHUNK_MAX_ATTEMPTS { + return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); + } + attempt += 1; + tokio::time::sleep(CHUNK_RETRY_DELAY).await; + } + } + } + // 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。 + let authoritative = read_upload_state( + client, + request.api_base_url, + request.version_id, + request.access_token, + ) + .await?; + received = authoritative.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + } + let mut outcome = complete_upload( + client, + request.api_base_url, + request.version_id, + request.access_token, + request.idempotency_key, + ) + .await?; + outcome.uploaded_bytes = total_bytes; + Ok(outcome) +} + +/// 进度事件的载荷形状(渲染进程按它显示进度)。 +pub(crate) fn progress_event_payload( + version_id: &str, + received_bytes: u64, + total_bytes: u64, +) -> Value { + json!({ + "versionId": version_id, + "receivedBytes": received_bytes, + "totalBytes": total_bytes, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunk_plan_covers_whole_package_without_gaps_or_overlap() { + let total = 20 * 1024 * 1024 + 3; + let chunk = 8 * 1024 * 1024; + let mut received = 0_u64; + let mut plans = Vec::new(); + while let Some(plan) = next_chunk_plan(received, total, chunk) { + plans.push(plan); + received += plan.length; + } + assert_eq!(received, total); + assert_eq!(plans.len(), 3); + assert_eq!(plans[0].offset, 0); + assert_eq!(plans[1].offset, chunk); + assert_eq!(plans[2].length, 4 * 1024 * 1024 + 3); + assert!(next_chunk_plan(total, total, chunk).is_none()); + assert!(next_chunk_plan(0, total, 0).is_none()); + } + + #[test] + fn offset_mismatch_response_yields_authoritative_position() { + let body = r#"{"error":{"code":"PACKAGE_UPLOAD_OFFSET_MISMATCH","message":"偏移不一致","details":{"provider":"game-distribution","receivedBytes":16777216}}}"#; + assert_eq!(parse_received_bytes(body), Some(16_777_216)); + let (code, message) = parse_server_error(409, body); + assert_eq!(code.as_deref(), Some("PACKAGE_UPLOAD_OFFSET_MISMATCH")); + assert_eq!(message.as_deref(), Some("偏移不一致")); + + let legacy = + r#"{"error":{"code":"PACKAGE_UPLOAD_INCOMPLETE","message":"未收齐"},"meta":{}}"#; + assert_eq!(parse_received_bytes(legacy), None); + assert_eq!( + parse_server_error(409, legacy).0.as_deref(), + Some("PACKAGE_UPLOAD_INCOMPLETE") + ); + } + + #[test] + fn upload_url_joins_base_without_double_slash() { + assert_eq!( + upload_url("https://dev.genarrative.world/", "ver-1", "/chunk"), + "https://dev.genarrative.world/api/game-distribution/versions/ver-1/package/chunk" + ); + assert_eq!( + upload_url("http://127.0.0.1:10001", "ver 1", "/complete"), + "http://127.0.0.1:10001/api/game-distribution/versions/ver 1/package/complete" + ); + } + + #[test] + fn staging_file_is_content_addressed_and_reused() { + let dir = std::env::temp_dir().join(format!( + "agc-game-package-staging-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + let sha = "a".repeat(64); + let first = stage_game_package_bytes(&dir, &sha, b"package-bytes").expect("首次暂存应成功"); + let second = + stage_game_package_bytes(&dir, &sha, b"package-bytes").expect("重复暂存应复用"); + assert_eq!(first.staging_path, second.staging_path); + assert_eq!(first.package_size_bytes, 13); + assert!(Path::new(&first.staging_path).is_file()); + assert_eq!( + fs::read(&first.staging_path).expect("读取暂存文件"), + b"package-bytes" + ); + + let bad = stage_game_package_bytes(&dir, "not-a-sha", b"x"); + assert!(bad.is_err()); + let empty = stage_game_package_bytes(&dir, &sha, b""); + assert!(empty.is_err()); + + let outside = ensure_staging_path_in_dir(&dir, "/etc/passwd"); + assert!(outside.is_err()); + let inside = ensure_staging_path_in_dir(&dir, &first.staging_path); + assert!(inside.is_ok()); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 256c38c3d..3b356fcd6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -132,6 +132,7 @@ mod editor_adapter; mod editor_adapters; mod environment_check; pub mod error_report; +mod game_package_upload; mod git_inspect; mod goal; mod http_client; @@ -2757,7 +2758,8 @@ fn main() { build_local_project_index, create_local_project_checkpoint, export_local_project_package, - read_local_project_export_package, + prepare_local_project_game_package, + upload_local_project_game_package, list_local_project_export_packages, diff_local_project_checkpoint, restore_local_project_checkpoint, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d1a93d69f..73f45325f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -796,12 +796,21 @@ export interface LocalProjectExportPackageFileDigest { sha256: string; } -export interface LocalProjectExportPackagePayload { - packageRelativePath: string; - packageBytes: number[]; +/** + * 已暂存的归一化发行包:发布链路只传递这个摘要与路径,整包字节留在原生进程里, + * 不再经过 WebView IPC。 + */ +export interface StagedGamePackage { + stagingPath: string; packageSha256: string; packageSizeBytes: number; - files: LocalProjectExportPackageFileDigest[]; + packageFileCount: number; +} + +export interface GamePackageUploadOutcome { + versionId: string; + status: string; + uploadedBytes: number; } export interface LocalProjectExportPackageSummary { diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index 5e1a96402..993ccd754 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -7,10 +7,12 @@ import type { GameDistributionOrientation, } from '../../../../packages/shared/src/contracts/gameDistribution'; import type { - LocalProjectExportPackagePayload, + GamePackageUploadOutcome, + StagedGamePackage, TauriInvoke, } from '../app/types'; -import { requestClientApi } from './clientApi'; +import { getStoredAuthAccessToken, requestClientApi } from './clientApi'; +import { getClientServerBaseUrl } from './clientHttp'; export type GameDistributionPublishMetadata = { title: string; @@ -328,20 +330,20 @@ export async function publishLocalProjectGame(args: { if (!projectPath || !packageRelativePath) { throw new Error('发布需要绑定本地项目和试玩包'); } - const payload = await args.invoke( - 'read_local_project_export_package', + // 整包字节只留在原生进程:这里拿到的是归一化后的摘要与内容寻址暂存路径, + // 上传由原生侧按服务端分片大小完成,中断后同一暂存文件可直接续传。 + const staged = await args.invoke( + 'prepare_local_project_game_package', { projectPath, packageRelativePath }, ); if ( - !payload.packageBytes.length || - payload.packageSizeBytes !== payload.packageBytes.length || - payload.files.length === 0 + !staged.stagingPath.trim() || + staged.packageSha256.length !== 64 || + staged.packageSizeBytes <= 0 || + staged.packageFileCount <= 0 ) { throw new Error('本地发行包摘要无效,请重新导出试玩包'); } - if (payload.packageRelativePath !== packageRelativePath) { - throw new Error('本地发行包路径已变化,请重新导出试玩包'); - } const metadata = normalizeMetadata(args.manifest, args.metadata); const localProjectId = args.manifest.projectId.trim(); @@ -369,9 +371,9 @@ export async function publishLocalProjectGame(args: { const versionRequest: GameDistributionCreateVersionRequest = { localProjectId, - packageSha256: payload.packageSha256, - packageBytes: payload.packageSizeBytes, - packageFileCount: payload.files.length, + packageSha256: staged.packageSha256, + packageBytes: staged.packageSizeBytes, + packageFileCount: staged.packageFileCount, packageEntryPath: 'index.html', gameMetadata, }; @@ -391,23 +393,19 @@ export async function publishLocalProjectGame(args: { throw new Error('创建发行版本未返回版本 ID'); } - const packageBody = new Blob([new Uint8Array(payload.packageBytes)], { - type: 'application/zip', - }); - const uploaded = await requestClientApi<{ - versionId: string; - status: string; - }>( - `/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/package`, + const accessToken = getStoredAuthAccessToken(); + if (!accessToken) { + throw new Error('陶泥儿登录凭据缺失,请重新登录'); + } + const uploaded = await args.invoke( + 'upload_local_project_game_package', { - method: 'PUT', - headers: { - 'Content-Type': 'application/zip', - 'Idempotency-Key': `${rootKey}:upload`, - }, - body: packageBody, + stagingPath: staged.stagingPath, + versionId: version.versionId, + apiBaseUrl: getClientServerBaseUrl(), + accessToken, + idempotencyKey: `${rootKey}:upload`, }, - '上传游戏发行包失败', ); const submitted = await requestClientApi<{ game?: { publicationRevision?: number }; @@ -431,8 +429,8 @@ export async function publishLocalProjectGame(args: { versionId: version.versionId, versionNumber: version.versionNumber, status: submitted?.version?.status ?? uploaded?.status ?? 'pending_review', - packageSha256: payload.packageSha256, - packageSizeBytes: payload.packageSizeBytes, - fileCount: payload.files.length, + packageSha256: staged.packageSha256, + packageSizeBytes: staged.packageSizeBytes, + fileCount: staged.packageFileCount, }; } diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts index 9110172ea..e4aa8e332 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts @@ -14,6 +14,12 @@ vi.mock('../src/services/errorReporting', () => ({ captureClientError: vi.fn(), })); +// 原生侧上传需要登录凭据;这里只钉住「取到了 token」这一件事。 +vi.mock('../src/services/clientApi', async (importOriginal) => ({ + ...(await importOriginal()), + getStoredAuthAccessToken: () => 'test-access-token', +})); + import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { generateGameDistributionCover, @@ -29,6 +35,14 @@ const MANIFEST = { goal: '守住轨道城', } as unknown as GameCreationAppManifest; +/** 归一化发行包的暂存摘要;发布链路只应传递它,不再传整包字节。 */ +const STAGED_PACKAGE = { + stagingPath: 'C:/app-data/game-package-staging/aaaa.zip', + packageSha256: 'a'.repeat(64), + packageSizeBytes: 1024, + packageFileCount: 1, +}; + function jsonResponse(payload: unknown) { return new Response( JSON.stringify({ @@ -66,23 +80,25 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台 status: 'awaiting_upload', }), ) - .mockResolvedValueOnce( - jsonResponse({ versionId: 'gamever_1', status: 'uploaded' }), - ) .mockResolvedValueOnce( jsonResponse({ version: { status: 'pending_review' } }), ); + const invokeCalls: Array<{ command: string; args: unknown }> = []; const result = await publishLocalProjectGame({ - invoke: (async (command: string) => { - expect(command).toBe('read_local_project_export_package'); - return { - packageRelativePath: 'exports/playtest-package-1.zip', - packageBytes: [1, 2, 3], - packageSha256: 'a'.repeat(64), - packageSizeBytes: 3, - files: [{ path: 'index.html', sizeBytes: 3, sha256: 'a'.repeat(64) }], - }; + invoke: (async (command: string, args?: Record) => { + invokeCalls.push({ command, args }); + if (command === 'prepare_local_project_game_package') { + return STAGED_PACKAGE; + } + if (command === 'upload_local_project_game_package') { + return { + versionId: 'gamever_1', + status: 'uploaded', + uploadedBytes: STAGED_PACKAGE.packageSizeBytes, + }; + } + throw new Error(`未预期的命令:${command}`); }) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', @@ -113,18 +129,26 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台 expect(result.gameId).toBe('game_1'); expect(result.versionId).toBe('gamever_1'); + + // 关键回归:整包字节不再经过 IPC,上传交给原生侧按版本 ID + 暂存路径完成。 + const uploadCall = invokeCalls.find( + (call) => call.command === 'upload_local_project_game_package', + ); + expect(uploadCall?.args).toMatchObject({ + stagingPath: STAGED_PACKAGE.stagingPath, + versionId: 'gamever_1', + apiBaseUrl: 'https://dev.genarrative.world', + accessToken: 'test-access-token', + }); + expect(Object.keys(uploadCall?.args ?? {})).not.toContain('packageBytes'); + // 三次 HTTP:创建游戏、创建版本、送审;上传不再占用一条 HTTP 调用。 + expect(fetchClientHttp).toHaveBeenCalledTimes(3); }); test('缺少本地项目标识时在发起请求前失败关闭', async () => { await expect( publishLocalProjectGame({ - invoke: (async () => ({ - packageRelativePath: 'exports/playtest-package-1.zip', - packageBytes: [1], - packageSha256: 'a'.repeat(64), - packageSizeBytes: 1, - files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }], - })) as never, + invoke: (async () => STAGED_PACKAGE) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest, @@ -137,13 +161,7 @@ test('缺少本地项目标识时在发起请求前失败关闭', async () => { test('缺少封面时在创建游戏前失败关闭', async () => { await expect( publishLocalProjectGame({ - invoke: (async () => ({ - packageRelativePath: 'exports/playtest-package-1.zip', - packageBytes: [1], - packageSha256: 'a'.repeat(64), - packageSizeBytes: 1, - files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }], - })) as never, + invoke: (async () => STAGED_PACKAGE) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, @@ -156,13 +174,7 @@ test('缺少封面时在创建游戏前失败关闭', async () => { test('截图超过 6 张时在创建游戏前失败关闭', async () => { await expect( publishLocalProjectGame({ - invoke: (async () => ({ - packageRelativePath: 'exports/playtest-package-1.zip', - packageBytes: [1], - packageSha256: 'a'.repeat(64), - packageSizeBytes: 1, - files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }], - })) as never, + invoke: (async () => STAGED_PACKAGE) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index 552b6c26c..b79228dd1 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -7,18 +7,23 @@ * npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts * * 开启后测试会注册一个临时作者,并通过真实的 `clientApi` / `clientHttp`(而不是 - * mock 请求层)调用 AGC 的发布函数,覆盖:本地导出包读取、创建游戏、同 - * `localProjectId` 复用游戏身份、真实 ZIP 上传、送审与版本回读。 + * mock 请求层)调用 AGC 的发布函数,覆盖:本地发行包暂存摘要、创建游戏、同 + * `localProjectId` 复用游戏身份、真实分片上传、送审与版本回读。 + * + * jsdom 里没有 Tauri 运行时,`upload_local_project_game_package` 由本测试按服务端 + * 分片协议(upload-state → chunk → complete)代跑,等同于原生上传器的行为; + * 原生实现自身的分片规划、权威偏移续传与错误分类在 Rust 单测里覆盖。 */ -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import JSZip from 'jszip'; import { expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { LocalProjectExportPackagePayload } from '../src/app/types'; +import type { StagedGamePackage } from '../src/app/types'; import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; import { setStoredAuthAccessToken } from '../src/services/clientAuth'; +import { setClientServerSelection } from '../src/services/clientHttp'; import { publishLocalProjectGame } from '../src/services/gameDistributionPublish'; /** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */ @@ -37,6 +42,12 @@ const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '') .replace(/\/+$/u, ''); const liveTest = liveBaseUrl ? test : test.skip; +// AGC 服务默认按渠道选 dev / release 域名;跑真实链路时把「平台服务器」切到传入的本地栈, +// 否则请求会打到线上域名而不是这台机器上的 api-server。 +if (liveBaseUrl) { + setClientServerSelection({ preset: 'custom', customBaseUrl: liveBaseUrl }); +} + const realFetch = globalThis.fetch.bind(globalThis); const ENVELOPE_HEADERS = { 'x-genarrative-response-envelope': 'v1' }; @@ -55,21 +66,73 @@ function installFetchBridge() { : input instanceof URL ? input.toString() : input; + // jsdom realm 的 Headers / AbortSignal / Blob 都不是 undici 认得的类型(同 2026-09-20 + // 那条「跨 realm BodyInit 被 undici 拒绝」的坑):统一降级成 Node 侧能接受的原生值。 + const headers = init?.headers + ? Object.fromEntries(Array.from(new Headers(init.headers).entries())) + : undefined; + const signal = undefined; const body = init?.body; + if (typeof FormData !== 'undefined' && body instanceof FormData) { + // jsdom 的 FormData 同样不被 undici 接受:这里手工序列化成 multipart 字节。 + const multipart = await serializeFormData(body); + return realFetch(url as string, { + ...init, + headers: { ...headers, 'Content-Type': multipart.contentType }, + signal, + body: multipart.body, + }); + } if (typeof Blob !== 'undefined' && body instanceof Blob) { // jsdom 的 Blob/ArrayBuffer 属于另一个 realm,且旧版 jsdom 没有 // Blob.arrayBuffer;统一读成字节后复制为 Node 侧 Buffer 再转发。 const bytes = await readBlobBytes(body); return realFetch(url as string, { ...init, + headers, + signal, body: Buffer.from(bytes), }); } - return realFetch(url as string, init); + return realFetch(url as string, { ...init, headers, signal }); }, ); } +async function serializeFormData(form: FormData): Promise<{ + body: Buffer; + contentType: string; +}> { + const boundary = `----agcLive${Date.now().toString(16)}`; + const chunks: Buffer[] = []; + for (const [name, value] of form.entries()) { + if (typeof value === 'string') { + chunks.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`, + ), + ); + continue; + } + const bytes = await readBlobBytes(value); + const fileName = + (value as File).name || `agc-live-${Date.now().toString(16)}.bin`; + const contentType = value.type || 'application/octet-stream'; + chunks.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`, + ), + ); + chunks.push(Buffer.from(bytes)); + chunks.push(Buffer.from('\r\n')); + } + chunks.push(Buffer.from(`--${boundary}--\r\n`)); + return { + body: Buffer.concat(chunks), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + async function readBlobBytes(blob: Blob): Promise { const maybeArrayBuffer = ( blob as Blob & { arrayBuffer?: () => Promise } @@ -120,7 +183,10 @@ async function registerAuthor(): Promise { return data.token; } -async function buildExportPayload(): Promise { +async function buildStagedPackage(): Promise<{ + staged: StagedGamePackage; + bytes: Uint8Array; +}> { const zip = new JSZip(); const indexHtml = 'AGC Live' + @@ -129,28 +195,169 @@ async function buildExportPayload(): Promise { 'window.__agcLive=1;document.documentElement.dataset.booted="agc";'; zip.file('index.html', indexHtml); zip.file('assets/app.js', appJs); + // 让发行包超过单个分片(8 MiB):分片续传只有跨片才有意义,随机字节保证不可压缩。 + zip.file('assets/bulk.bin', randomBytes(9 * 1024 * 1024)); const bytes = await zip.generateAsync({ type: 'uint8array' }); const sha256 = createHash('sha256').update(bytes).digest('hex'); return { - packageRelativePath: 'dist/game.zip', - packageBytes: Array.from(bytes), - packageSha256: sha256, - packageSizeBytes: bytes.length, - files: [ - { - path: 'index.html', - sizeBytes: Buffer.byteLength(indexHtml), - sha256: createHash('sha256').update(indexHtml).digest('hex'), - }, - { - path: 'assets/app.js', - sizeBytes: Buffer.byteLength(appJs), - sha256: createHash('sha256').update(appJs).digest('hex'), - }, - ], + staged: { + stagingPath: '/tmp/agc-live-staging/game.zip', + packageSha256: sha256, + packageSizeBytes: bytes.length, + packageFileCount: 3, + }, + bytes, }; } +type PackageUploadState = { + receivedBytes: number; + chunkBytes: number; + declaredPackageBytes: number; +}; + +function packageAuthHeaders(token: string) { + return { + Authorization: `Bearer ${token}`, + ...ENVELOPE_HEADERS, + }; +} + +/** 读取服务端权威已收字节(原生上传器同样以它为准)。 */ +async function readPackageUploadState( + versionId: string, + token: string, +): Promise { + return await unwrap( + await realFetch( + apiUrl( + `/api/game-distribution/versions/${versionId}/package/upload-state`, + ), + { headers: packageAuthHeaders(token) }, + ), + ); +} + +/** 上传一个分片;偏移由调用方按权威偏移给出。 */ +async function uploadPackageChunk(input: { + versionId: string; + token: string; + idempotencyKey: string; + offset: number; + body: Uint8Array; +}): Promise { + const response = await realFetch( + apiUrl(`/api/game-distribution/versions/${input.versionId}/package/chunk`), + { + method: 'PUT', + headers: { + ...packageAuthHeaders(input.token), + 'Content-Type': 'application/octet-stream', + 'x-genarrative-upload-offset': String(input.offset), + 'Idempotency-Key': `${input.idempotencyKey}:chunk`, + }, + body: Buffer.from(input.body), + }, + ); + if (!response.ok) { + throw new Error( + `分片上传失败:${response.status} ${await response.text()}`, + ); + } + const payload = (await response.json()) as { + data?: { receivedBytes?: number }; + receivedBytes?: number; + }; + return payload.data?.receivedBytes ?? payload.receivedBytes ?? input.offset; +} + +async function completePackageUpload(input: { + versionId: string; + token: string; + idempotencyKey: string; +}) { + return await unwrap<{ versionId: string; status: string }>( + await realFetch( + apiUrl( + `/api/game-distribution/versions/${input.versionId}/package/complete`, + ), + { + method: 'POST', + headers: { + ...packageAuthHeaders(input.token), + 'Idempotency-Key': `${input.idempotencyKey}:complete`, + }, + }, + ), + ); +} + +/** 从权威偏移继续发送剩余分片,返回本次实际发送过的偏移序列。 */ +async function uploadRemainingChunks(input: { + versionId: string; + bytes: Uint8Array; + token: string; + idempotencyKey: string; +}): Promise { + const state = await readPackageUploadState(input.versionId, input.token); + const sentOffsets: number[] = []; + let received = state.receivedBytes; + while (received < input.bytes.length) { + const length = Math.min(state.chunkBytes, input.bytes.length - received); + await uploadPackageChunk({ + versionId: input.versionId, + token: input.token, + idempotencyKey: input.idempotencyKey, + offset: received, + body: input.bytes.subarray(received, received + length), + }); + sentOffsets.push(received); + received = (await readPackageUploadState(input.versionId, input.token)) + .receivedBytes; + } + return sentOffsets; +} + +/** + * 按服务端分片协议上传整包:与原生上传器同一套请求形状,用于验证服务端合同。 + * 第一次调用会**只传第一片就停下**,模拟传输中断;后续调用按权威偏移续传, + * 因此这里能直接证明「中断后不重传已收字节」。 + */ +async function uploadStagedPackageViaProtocol(input: { + versionId: string; + bytes: Uint8Array; + token: string; + idempotencyKey: string; + sentOffsets: number[]; +}) { + const state = await readPackageUploadState(input.versionId, input.token); + if (state.receivedBytes === 0) { + const firstLength = Math.min(state.chunkBytes, input.bytes.length); + await uploadPackageChunk({ + versionId: input.versionId, + token: input.token, + idempotencyKey: input.idempotencyKey, + offset: 0, + body: input.bytes.subarray(0, firstLength), + }); + input.sentOffsets.push(0); + } + input.sentOffsets.push( + ...(await uploadRemainingChunks({ + versionId: input.versionId, + bytes: input.bytes, + token: input.token, + idempotencyKey: input.idempotencyKey, + })), + ); + const completed = await completePackageUpload({ + versionId: input.versionId, + token: input.token, + idempotencyKey: input.idempotencyKey, + }); + return { versionId: completed.versionId, status: completed.status }; +} + liveTest( 'AGC 发布函数在真实后端完成创建、上传、送审并在重复发布时复用游戏身份', async () => { @@ -158,22 +365,46 @@ liveTest( const token = await registerAuthor(); setStoredAccessToken(token); - const payload = await buildExportPayload(); + const { staged, bytes } = await buildStagedPackage(); const stamp = String(Date.now()); const manifest = { projectId: `agc-live-${stamp}`, name: `AGC 真实发布${stamp.slice(-4)}`, goal: '验证 AGC 一键发布链路', } as unknown as GameCreationAppManifest; - const invoke = vi.fn(async () => payload); + // 记录本次发布实际发送过的分片偏移,用来证明「中断后不重传已收字节」。 + const sentOffsets: number[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'prepare_local_project_game_package') { + return staged; + } + if (command === 'upload_local_project_game_package') { + const uploaded = await uploadStagedPackageViaProtocol({ + versionId: String(args?.versionId ?? ''), + bytes, + token, + idempotencyKey: String(args?.idempotencyKey ?? ''), + sentOffsets, + }); + return { + versionId: uploaded.versionId, + status: uploaded.status, + uploadedBytes: bytes.length, + }; + } + throw new Error(`未预期的命令:${command}`); + }, + ); // 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。 const uploadedCover = await uploadPlatformMediaAsset({ file: buildLiveCoverFile(), assetKind: 'game_distribution_cover', pathSegments: ['game-distribution', 'cover', stamp], entityId: 'game-distribution-cover', - // jsdom 里没有 Tauri HTTP 插件,复用测试注入的 fetch bridge 直连 dev OSS。 - fetchImpl: (input, init) => realFetch(apiUrl(input), init), + // jsdom 里没有 Tauri HTTP 插件:直传也走同一个桥,跨 realm 的 FormData 会被 + // 先序列化成 Node 侧 multipart 字节,否则 OSS 会以 405 拒绝。 + fetchImpl: (input, init) => globalThis.fetch(input, init), }); expect(uploadedCover.assetObjectId).toMatch(/\S/u); const metadata = { @@ -198,7 +429,18 @@ liveTest( }); expect(first.status).toBe('pending_review'); expect(first.versionNumber).toBe(1); - expect(first.packageSha256).toBe(payload.packageSha256); + expect(first.packageSha256).toBe(staged.packageSha256); + // 分片续传证据:第一片(偏移 0)只发送一次;中断后的续传从权威偏移开始, + // 已收字节不重放、也不跳段。 + expect(sentOffsets[0]).toBe(0); + expect(sentOffsets.filter((offset) => offset === 0)).toHaveLength(1); + expect(sentOffsets[1]).toBeGreaterThan(0); + expect(sentOffsets).toEqual( + Array.from( + { length: Math.ceil(staged.packageSizeBytes / 8 / 1024 / 1024) }, + (_, index) => index * 8 * 1024 * 1024, + ), + ); const readResult = await unwrap<{ version: { versionId: string; status: string; recoveryAction: string }; diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 9e34fbaca..6d3284ce0 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -90,8 +90,9 @@ http { location ~ ^/api(?:/|$) { default_type application/json; - # 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。 - client_max_body_size 64m; + # 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server; + # 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。 + client_max_body_size 210m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index c3c4bff34..6d2b2dffe 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -4,8 +4,8 @@ ## 请求体大小 -- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 64m`。 -- 该值只用于让携带参考图 Data URL 的创作接口抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。 +- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 210m`。 +- 该值只用于让携带参考图 Data URL 的创作接口和游戏发行包 PUT(路由上限 200 MiB + 1 KiB)抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。Pingora 网关侧的 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` 必须同样不低于该值,否则请求会在网关层被 413。 - 若线上看到 `413 Request Entity Too Large`,并且 access log 里 `request_time=0.000 upstream_status=-`,通常是 Nginx 没有加载该模板或未 reload;先执行 `nginx -T | grep client_max_body_size` 和 `nginx -t` 再检查 `api-server`。 ## gzip diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index f210766ab..640ef088c 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -119,8 +119,9 @@ server { # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; - # 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。 - client_max_body_size 64m; + # 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server; + # 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。 + client_max_body_size 210m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 87bf769e3..b7d1c433a 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -139,8 +139,9 @@ server { # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; - # 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。 - client_max_body_size 64m; + # 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server; + # 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。 + client_max_body_size 210m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 59eaaa850..f1c90ee98 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -124,14 +124,14 @@ "nginx": { "production": [ "location ~ ^/api(?:/|$)", - "client_max_body_size 64m;", + "client_max_body_size 210m;", "limit_conn genarrative_api_conn 64;", "limit_req zone=genarrative_api_rps burst=64 nodelay;", "add_header X-Accel-Buffering no always;" ], "development": [ "location ~ ^/api(?:/|$)", - "client_max_body_size 64m;", + "client_max_body_size 210m;", "limit_conn genarrative_api_conn 64;", "limit_req zone=genarrative_api_rps burst=64 nodelay;", "add_header X-Accel-Buffering no always;" diff --git a/deploy/pingora/pingora-gateway.env.example b/deploy/pingora/pingora-gateway.env.example index 467d559d2..d85eb293a 100644 --- a/deploy/pingora/pingora-gateway.env.example +++ b/deploy/pingora/pingora-gateway.env.example @@ -28,7 +28,7 @@ GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE=/var/lib/genarrative/maintenance/en GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE=/var/lib/genarrative/maintenance/page.html GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=http -GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=67108864 +GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960 # gzip 默认开启;等级和最小响应长度对齐 Nginx gzip_comp_level 5 / gzip_min_length 1024。 # Pingora 正式化口径固定为 gzip-only;br / zstd 不进入当前网关,Brotli 继续由 Nginx / 前置代理承担。 GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=gzip diff --git a/docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md b/docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md new file mode 100644 index 000000000..21aa5c760 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC发行包分片续传上传-2026-09-23.md @@ -0,0 +1,49 @@ +# AGC 发行包分片续传上传实施计划 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | runtime-smoke-passed(存储原语、服务端入口、原生上传器、渲染进程接线与真实栈分片续传 smoke 均已落地) | +| Date | 2026-09-23 | +| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md` | + +## 修改边界与顺序 + +1. **存储原语(已完成)**:`server-rs/crates/platform-oss/src/lib.rs` 新增 `append_internal_object` / `append_internal_object_with_retry` 与 `OssAppendInternalObjectRequest` / `OssAppendInternalObjectResponse`;复用现役 V4 签名助手 `signed_request_builder`(查询串已参与签名)与 `run_internal_put_with_retry` 的可重试分类。`position = 0` 追加到末尾,`position > 0` 必须等于对象当前长度;返回 `next_position` 作为权威已收字节。 +2. **服务端入口(已完成)**:`server-rs/crates/api-server/src/modules/game_distribution.rs` + - 新增 `GET .../package/upload-state`、`PUT .../package/chunk`、`POST .../package/complete`、`POST .../package/reset` 四个路由,沿用作者鉴权、`game-distribution:publish` 灰度开关与 `Idempotency-Key` 约定; + - 分片大小 `PACKAGE_UPLOAD_CHUNK_BYTES = 8 MiB`,分片请求体放行量为分片大小 + 1 KiB; + - 从整包 `PUT` 抽出共享收口 `confirm_validated_package`(声明比对 → 确认 → 结构化事件),两种入口共用; + - 新增 `game_distribution_oss_client` / `game_distribution_package_object_key` / `staged_package_bytes` / `require_octet_stream_content_type` / `package_upload_offset` 辅助函数;偏移不一致返回 `409 PACKAGE_UPLOAD_OFFSET_MISMATCH` 与权威偏移;未收齐返回 `409 PACKAGE_UPLOAD_INCOMPLETE`;校验失败删除半包并落 `upload_failed`。 +3. **AGC 原生上传器(已完成)**:新增 `apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs`:内容寻址暂存(`/game-package-staging/.zip`,重启后同包复用同一文件)、`upload-state → chunk → complete` 循环、409 权威偏移续传(响应丢失后按服务端已收字节对齐,不重放不跳段)、仅对传输/超时/408/429/5xx 退避重试(默认 4 次尝试)、`game-package-upload-progress` 进度事件;暂存路径必须落在暂存目录内。命令 `prepare_local_project_game_package` / `upload_local_project_game_package` 已注册,整包回传命令 `read_local_project_export_package` 退役(`read_local_project_export_package_at` 仍供暂存使用)。 +4. **渲染进程接线(已完成)**:`apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts` 改为 `prepare`(拿摘要与暂存路径)→ 创建游戏 → 创建版本 → 原生分片上传 → 送审;`LocalProjectExportPackagePayload` 整包类型退役,改为 `StagedGamePackage` / `GamePackageUploadOutcome`;不再有任何整包字节进 IPC。 +5. **真实栈 smoke(已完成)**:本地 api-server + 真实 OSS bucket 上跑通「中断 → 续传 → 确认」。做法与证据: + - 先用 `npm run dev:spacetime` 把当前模块发布到本地库(`genarrative-game-creator-dev`,自动迁移完成),再用 `npm run dev:api-server` 起 `127.0.0.1:8082`; + - 本地库的 `feature_gate_config` 原本为空(发布开关默认关闭),用 `spacetime call … upsert_feature_gate_config` 写入 `game-distribution:publish enabled=true rollout=100`; + - `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:8082 npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts` → **1 passed / 3.9s**(发行包 9.0 MiB,跨 8 MiB 分片边界); + - 用例断言实际发送过的分片偏移序列等于 `[0, 8388608]`:第一片只发一次,中断后的续传从权威偏移开始,不重放也不跳段; + - api-server 侧同一轮日志:`package_chunk_stored offset=0 chunk_bytes=8388608 received_bytes=8388608 elapsed_ms=201`、`package_chunk_stored offset=8388608 chunk_bytes=1049210 received_bytes=9437818 elapsed_ms=82`、`package_confirmed package_bytes=9437818 file_count=3 oss_put_skipped=true elapsed_ms=884`。 + - 为了能指向本地栈,用例还补了两处基础设施修正:把客户端平台基址切到传入的 base URL(`setClientServerSelection({preset:'custom'})`),以及桥接层把 jsdom realm 的 `Headers` / `Blob` / `FormData` 降级成 Node 侧原生值(`FormData` 手工序列化为 multipart 字节,否则 OSS 直传回 405)。 + +## 不改的部分 + +- 网页端发布路径与整包 `PUT` 语义不变;`MAX_PACKAGE_BYTES`、展开量、单文件与文件数上限不变。 +- 未新增 SpacetimeDB 表或字段:已收字节的事实来源是 OSS 对象长度,版本状态机沿用既有 `awaiting_upload → uploaded → …`。 +- 未引入半包定时清理任务。 + +## 验证命令 + +- `cargo test -p platform-oss`(74 passed) +- `cargo test -p api-server game_distribution`(23 passed,含新增 `package_chunk_size_stays_inside_declared_limits`、`package_upload_offset_requires_non_negative_integer`、`package_chunk_content_type_must_be_octet_stream`) +- `cargo fmt --all -- --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` +- `cargo test game_package_upload`(AGC 原生侧 4 passed:分片规划无缝无重叠、409 权威偏移解析、URL 拼接、内容寻址暂存与路径校验) +- `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run --workspace apps/ai-game-creator-shell typecheck`(含 `check-config.mjs` 的命令登记门禁) +- `npx vitest run`(发布函数 6 passed、发布面板 9 passed、发布反馈 5 passed;真实链路用例在无 `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL` 时按设计跳过) +- 待做:真实栈 smoke(本地 api-server + 真实 OSS bucket 上跑「中断 → 续传 → 完成」,含 `x-oss-next-append-position` 语义确认) + +## 风险与回滚点 + +- **对象可追加性**:`platform-oss` 之前没有追加写,首次真实调用需要在真实 bucket 上确认 `x-oss-next-append-position` 语义;失败时回滚点是 `platform-oss` 新增函数与四条路由(整包 `PUT` 不受影响,可独立回退)。 +- **半包对象**:分片写入直接落在版本键上,未完成时是半包。它不进公开目录、不服务发行网关;失败或作者重置时删除。若删除失败会记录 `package_staging_delete_failed` 告警,需要人工确认对象键状态。 +- **重置语义**:只有 `awaiting_upload` / `upload_failed` 允许重置,避免破坏已确认事实。 +- **内存**:完成动作按 200 MiB 上限回读整包再校验,峰值与整包 `PUT` 同量级;分片路径不再让整包驻留客户端。 diff --git a/docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md b/docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md new file mode 100644 index 000000000..a881e7e88 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md @@ -0,0 +1,58 @@ +# AGC 发行包分片续传上传 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | runtime-smoke-passed(真实栈「中断 → 续传 → 确认」已通过;AGC 真机一键发布与 200 MiB 档容量数据未验证) | +| Date | 2026-09-23 | +| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(真实发行包与资料合同第 10 条、幂等并发与恢复) | + +## 背景与触发 + +AGC 一键发布今天把整包字节从 WebView 侧送出:`read_local_project_export_package` 先把 `packageBytes` 整包过一遍 IPC 回到渲染进程,渲染进程再用 `@tauri-apps/plugin-http` 发整包 `PUT`,而该插件会把 body 序列化成 `Array.from(new Uint8Array(buffer))` 再走一次 IPC。两次整包 IPC 决定了 AGC 实际可发布的包远小于服务端 200 MiB 上限,失败时表现为客户端侧传输错误(例如「无法连接登录服务」),服务端访问日志里没有这次请求;断流后也只能整包白传。本里程碑把上传下沉到原生侧并支持分片续传。 + +## 目标 + +1. AGC 一键发布由原生进程直接读取本地试玩包、按服务端下发的固定分片大小上传,整包字节不再经过 WebView IPC。 +2. 传输中断、网络失败、客户端进程退出或应用重启后,同一 `versionId` 只补传缺失字节,不白传整包。 +3. 分片入口与现役整包 `PUT` 共用同一版本状态机、摘要口径、幂等键与包校验;网页端发布路径不变。 + +## 不在本里程碑内 + +- 不改网页端发布路径(继续整包 `PUT`),不为浏览器实现续传。 +- 不做并行分片上传、不做客户端直传 OSS(分片仍经 `api-server` 转发,与今天整包路径同一出口)。 +- 不做「后台自动续传」:续传只在下一次发布动作或应用重启后的重试里发生,不引入常驻重传任务。 +- 不做未完成分片会话的定时清理任务;半包对象的回收单独开里程碑。 +- 不改发行包上限、展开量、单文件与文件数上限。 + +## 合同要点 + +- **入口与状态**:分片续传对既有 `versionId` 生效,版本状态沿用 `awaiting_upload → uploaded → …`;分片入口与整包入口互斥,同一版本同时只能有一个写入者,第二个写入返回 `409 UPLOAD_IN_PROGRESS`。 +- **权威偏移**:服务端记录的已收字节是唯一权威。客户端分片偏移与之不符时返回 `409` 与权威偏移,客户端按权威偏移续传;重复分片不得造成重复写入。 +- **完成动作**:全部字节到齐后才执行校验与确认;校验失败删除半包对象并把版本落到 `upload_failed`(`recoveryAction=reupload`)。重新上传同一版本前必须显式重置分片会话,重置后偏移归零,不允许在半包之上续写不同字节。 +- **可见性**:半包对象不进入公开目录、不服务发行网关、不改变当前公开版本;与既有「未通过审核不改变 `activeVersionId`」口径一致。 +- **原生侧边界**:原生上传只读本地试玩包并逐片发送,进度以事件回传渲染进程;渲染进程不再持有整包字节。 + +## 依赖 + +- `platform-oss`:需要一组可续写的对象写入原语(追加语义或等价的分片会话),以及读取已收字节的探测能力;现役只有整对象 `PUT`。 +- `api-server`:`modules/game_distribution.rs` 新增分片入口与完成动作,复用既有 `validate_release_zip`、OSS 上传重试分类、`package_confirmed` / `package_rejected` 可观测事件。 +- AGC:`src-tauri` 新增原生上传命令与进度事件,`src/services/gameDistributionPublish.ts` 改为调用原生命令;`read_local_project_export_package` 不再为发布回传整包字节。 +- 反代/网关:分片请求体远小于现役 210 MiB 放行量,沿用现有配置,不改限额。 + +## 验收标准 + +1. **不再整包过 IPC**:发布 200 MiB 档包时,渲染进程侧不出现整包字节(对照 `read_local_project_export_package` 的返回体与 IPC 报文大小),上传由原生进程完成。 +2. **续传生效**:上传中途断开传输后重发同一版本,只补传缺失分片;分片请求数、已传字节与最终包摘要三项均可复核。 +3. **跨重启续传**:上传中断时退出应用并重启,重新发布时服务端返回权威已收字节,客户端从该偏移继续,最终确认成功。 +4. **偏移与重复**:分片偏移不符返回 `409` 与权威偏移;重复提交同一分片不产生重复写入;同版本第二个写入者返回 `409 UPLOAD_IN_PROGRESS`。 +5. **失败关闭**:完成动作里校验失败(非法 ZIP、超限、压缩比越界等)删除半包对象、版本落 `upload_failed`,半包不出现在公开目录,也不影响当前公开版本。 +6. **兼容与回归**:整包 `PUT` 路径与既有测试保持绿;`npm run check:doc-index`、`npm run check:encoding`、`git diff --check` 通过;`check:spacetime-schema` 按是否新增持久字段决定是否纳入。 +7. **运行时证据(已获得)**:本地 api-server(`127.0.0.1:8082`,库 `genarrative-game-creator-dev`)+ 真实 OSS bucket 上跑通 `gameDistributionPublishLive.test.ts`:9.0 MiB 发行包跨 8 MiB 分片边界,第一片只发送一次,中断后续传从权威偏移 `8388608` 继续、第二片 `received_bytes=9437818`,最后 `package_confirmed`(`oss_put_skipped=true`);整轮 3.9s。**未获得**:AGC 真机(Tauri 运行时)一键发布的端到端运行,以及 200 MiB 档的耗时 / 内存容量数据。 + +## 待评审的决策点 + +1. **续写原语**:OSS 追加写(顺序、单对象、续传只需回读当前长度)对比 OSS Multipart(可并行、更通用但需要多组新操作)。建议追加写,顺序续传已满足本里程碑目标。 +2. **分片大小**:建议 8 MiB(200 MiB 上限 → 最多 25 片,单片请求体远低于现役放行量)。 +3. **重置语义**:建议只有显式重置(作者点「重新上传」或 `reupload` 恢复动作)才删除半包并归零;其余情况一律按权威偏移续传。 +4. **半包回收**:本里程碑只标记未完成会话,不做定时清理;回收另立里程碑(涉及「不得删除仍被公开版本引用的对象」口径)。 diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 226855815..e55f03b43 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -120,7 +120,7 @@ ### 行为与验收 - [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。 -- [ ] 100 MiB 包与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。 +- [ ] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。已有证据覆盖 100 MiB 档,上限提升后的档位待复跑。 - [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。 - [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。 - [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 265ec8690..a11c2b6ff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,20 @@ # 决策记录 +## 2026-09-23 自绘标题栏是窗口边框:弹层从它下方开始,焦点陷阱放行它 + +- 背景:AGC 打开任意一个 `ThemedModal` 弹窗(发布面板、发布进度、资源预览、账本、错误报告等)后,右上角「最小化 / 最大化 / 关闭」点击没有任何反应,标题栏拖拽也不能移动窗口;关掉弹窗立刻恢复。原因是标题栏在模态之外,而 `focus-trap-react` 在 document 捕获阶段监听 `mousedown`/`touchstart`/`click`,模态外的点击被 `preventDefault()` 且 `click` 直接 `stopImmediatePropagation()` —— React 的监听在更内层,事件到不了它,所以表现是「点了没反应」而不是报错。另有 `.app-update-overlay` 用 `inset: 0` 真的把标题栏盖住了。 +- 决策:把自绘标题栏定为**窗口边框**,不属于弹层内容:① portal 到 body 的全屏弹层一律 `top: var(--window-chrome-height)`,禁止用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容的点击继续被拦住;③ `WindowChrome` 的标题栏加 `data-window-chrome-bar` 标记,作为这条约定的唯一契约点。 +- 影响范围:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/styles.css`(`:root` 注释、`.app-update-overlay`、`.game-publish-progress-overlay`)。 +- 验证方式:`tests/themedModal.test.tsx`(标题栏点击放行、工作区点击仍被拦)、`tests/WindowChrome.test.tsx`(弹窗打开时三个窗口按钮仍调用原生窗口 API)、`tests/windowChromeOverlayContract.test.ts`(7 个全屏弹层都从标题栏下方开始)、`tests/gamePublishFeedback.test.tsx` 与 appSurface(208 passed);两处新增用例都做过「去掉修复即失败」的反向确认。`npm run --workspace apps/ai-game-creator-shell typecheck`、eslint、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-09-23 游戏发行包上限提升到 200 MiB(反代放行量与发行缓存同步) + +- 背景:游戏广场发行包上限原为 100 MiB(`module-game-distribution` 的 `MAX_PACKAGE_BYTES` 与网页端 `GAME_PACKAGE_MAX_BYTES`),而 Nginx 三份模板与 Pingora 网关的通用 `/api` 放行量是 64 MiB。上限只改一层没有意义:包体超过 100 MiB 时先在反代层被 413,`api-server` 的 ZIP 校验根本不会执行。 +- 决策:发行包上限 100 MiB → 200 MiB;展开总量 250 MiB → 500 MiB(保持 2.5 倍余量);单文件 64 MiB、最多 10,000 个文件、展开/压缩比 100 三条内容规则不变;发行包路由请求体上限继续从包上限派生(200 MiB + 1 KiB)。反代放行量统一放宽到 210 MiB:`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf` 使用 `client_max_body_size 210m`,Pingora `DEFAULT_MAX_API_BODY_BYTES` 改为 `220200960` 并同步 `deploy/pingora/pingora-gateway.env.example`。发行静态资源进程内缓存字节预算 200 MiB → 256 MiB,让 200 MiB 档发行包仍能进缓存、且不独占整份预算。 +- 边界:包内单个文件仍不得超过 64 MiB;线上 Pingora 环境文件若仍写 `67108864`,必须在重启网关前同步改值,否则发行包 PUT 会在网关层被 413。AGC 一键发布经 `@tauri-apps/plugin-http` 传整包字节,实际可发布体积还受该传输方式限制,200 MiB 档的客户端容量需要单独验证。 +- 影响范围:`server-rs/crates/module-game-distribution/src/package.rs`、`server-rs/crates/api-server/src/modules/game_distribution.rs`、`server-rs/crates/pingora-gateway/src/main.rs`、`src/components/game-distribution/gameZipPackage.ts`、`deploy/{nginx,container,pingora}`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`。 +- 验证方式:`cargo test -p module-game-distribution`(13 passed,其中 `accepts_package_above_the_previous_hundred_mib_limit` 用两个 50 MiB 存储型条目构造 100 MiB 出头的包;把上限临时改回 100 MiB 时该用例确实失败,证明它能守住新上限)、`cargo test -p api-server game_distribution`(20 passed,含新增的请求体上限覆盖包上限断言)、`cargo test -p pingora-gateway`(38 passed,含 `matches_nginx_route_parity_matrix`)、`npx vitest run src/components/game-distribution`(46 passed)、`cargo fmt --all -- --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。`npm run check:pingora-route-parity` 仍在 dev-http / 容器模板缺少 `/games` 等 SPA 路由处失败,改动前同样失败,与本次口径无关。200 MiB 档真实栈容量证据(上传耗时、api-server 峰值内存、超限 413 口径)尚未复跑,发布前需按阶段 D 脚本重跑一轮。 + ## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName` - 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index 74b666676..d5d5e3f34 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -488,7 +488,7 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | `GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT` | `/var/www/html` | ACME challenge 静态目录。 | | `GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE` | `/var/lib/genarrative/maintenance/enabled` | 存在即进入维护模式。 | | `GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO` | `http` | 写入 `X-Forwarded-Proto` 的值。 | -| `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` | `67108864` | `/api` 通用路由的 `Content-Length` 上限。 | +| `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` | `220200960` | `/api` 通用路由的 `Content-Length` 上限(210 MiB,覆盖游戏发行包 PUT 的 200 MiB + 1 KiB 路由上限)。 | | `GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS` | `gzip` | 当前唯一允许的压缩算法白名单;Pingora 正式化口径固定为 gzip-only,Brotli 继续由 Nginx / 前置代理承担。 | | `GENARRATIVE_PINGORA_GATEWAY_GZIP_ENABLED` | `true` | 是否启用 gzip 响应压缩。 | | `GENARRATIVE_PINGORA_GATEWAY_GZIP_LEVEL` | `5` | gzip 压缩等级,必须在 `0..=9`。 | diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 8fcaf11f0..ee9e75881 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -893,7 +893,7 @@ worker 被硬杀或断电后,lease 过期任务只有尚未耗尽 `max_attempt - Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:SpacetimeDB 必须同时匹配运行版本 `2.8.3` 和 commit `8e410d28...` 才能复用;只有缺失或版本 / commit 不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备官方 `v2.8.3` 资产。`SPACETIME_EXPECTED_COMMIT` 与下载根必须成对调整,安装结果也执行同一 commit 门禁。otelcol-contrib 当前锁定 `0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。 - 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源,目标部署 agent 也不再需要访问源码 Git remote。 - `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。 -- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 64m` 作为编辑器图片、视频和文档请求的反代兜底,真实大小仍由路由与业务校验负责。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。 +- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 210m` 作为编辑器图片、视频、文档请求与游戏发行包 PUT(路由上限 200 MiB + 1 KiB)的反代兜底,真实大小仍由路由与业务校验负责;使用 Pingora 网关时 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` 必须同步不低于该值,否则网关侧会先返回 413。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。 容器化隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟现有的 Linux release + Nginx + OTLP Collector 非 BgFilter 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前 compose 没有 `bgfilter-worker`,不构成完整 BgFilter 预发拓扑,也不覆盖任何会触发 BgFilter 的现役任务;它只用于非 BgFilter 路径,或通过下述 unsupported job smoke 验证外部生成队列的 claim / fail 回写和 API-only 更新。当前容器模拟参数保留 `genarrative-release` 的 CPU、`nofile=4096` 与 `worker_connections=768` 采样口径,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=2g`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`。完整模块首次实例化会超过旧 `896m` cgroup 上限,因此 SpacetimeDB 必须使用 `2g`;这不改变生产服务资源合同。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额;容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,可用 `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验证不经过 BgFilter 的外部生成 worker 动态扩缩容,`inline` 模式不参与该验证: diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index accee82c1..e30e1e175 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -84,13 +84,14 @@ 1. AGC 发布取当前 npm 工程已成功构建的 `dist/` 内容,重新检查入口和实际字节;ZIP 内部必须把 `dist/index.html` 归一化为根 `index.html`,其余路径相对发行根保持不变。不得上传整个项目、源码快照或仅发送本地路径。网页 ZIP 同样要求根 `index.html`,不猜测并自动剥离多层目录。 2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。 -3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。 +3. 现行限额:压缩包 200 MiB、展开总量 500 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。压缩包上限同时决定 `api-server` 的发行包路由请求体上限(200 MiB + 1 KiB)与反代放行量:Nginx 通用 `/api` location 为 `client_max_body_size 210m`,Pingora 网关为 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960`;三者必须同时满足,否则合法包会在反代或路由层被 413。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。超过约 100 MiB 的包在 `api-server` 会带来数百 MB 的瞬时内存占用,发布窗口与实例规格需按容量验证基线预留。 4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。 5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。 6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。 7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。 8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。 9. 审核通过时必须提交绝对 HTTPS `entryUrl`,且不接受凭据、query 和 fragment;服务端不根据请求 Host 或本地路径拼默认发行地址,避免把内网地址或主站来源写进公开投影。 非生产环境额外允许 http 回环地址(`127.0.0.1` / `localhost` / `[::1]`),口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。 +10. 上传有两条等价入口,共用同一版本状态机、摘要口径、幂等键与校验规则:① 整包入口——网页端与旧客户端对 `versionId` 直接 `PUT` 整包字节,原子、不可续传,仍受发行包上限与请求体限制约束;② 分片续传入口——AGC 原生一键发布对同一 `versionId` 顺序上传固定大小的分片,再以单独的完成动作收口。分片大小由服务端下发且固定(现为 8 MiB,随发行包上限 200 MiB 取整到 25 片以内),客户端不得自行改变;分片续传入口只补传缺失字节,任何分片重复或乱序都不得造成重复写入。AGC 侧必须由原生进程直接读取本地试玩包并按分片发送,整包字节不得经过 WebView IPC 往返,也不得整包驻留宿主内存。 ### 身份、状态、审核与更新 @@ -106,7 +107,9 @@ ### 幂等、并发与恢复 - 所有创建、提交、审核、撤销和下架动作携带 `Idempotency-Key`。服务端以认证主体、动作和 key 保存请求摘要与结果;同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`。至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。 -- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。首版整包重传,不宣称支持分片断点续传。 +- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。 +- 分片续传以「服务端已收字节」为唯一权威偏移:客户端带上自己认为的偏移上传分片,与服务端记录不一致时服务端返回 `409` 与权威偏移,客户端按权威偏移继续,不重放也不跳段。传输失败、网络中断、客户端进程退出或应用重启后,同一 `versionId` 重新发布只补传缺失分片;已收字节数由服务端持久化事实决定,不依赖客户端本地记录。 +- 分片会话在全部字节到齐并执行完成动作之前,不进入包校验、不确认版本、不改变任何公开可见性,半包对象也不服务给发行网关。完成动作里校验失败时删除该半包对象并把版本落到 `upload_failed`(`recoveryAction=reupload`);作者要重新上传同一版本时必须先显式重置分片会话,重置后已收字节归零,不允许在半包之上续写不同字节。 - 重复提交同一次 AGC 操作不得创建第二个游戏或版本;原生端持久保存操作 ID、目标游戏/版本和 key,网页保存恢复标识并以服务端回读为准。相同 ZIP 用于不同资料修订时允许新版本,不能仅按包摘要吞掉新的发布意图。 - 公开版本切换、作者下架和管理员审核必须带 `expectedPublicationRevision`,在持久化事务中比较并推进。并发变化返回 `409 PUBLICATION_CONFLICT`;旧送审版本不能在用户已发布更新或下架之后静默覆盖状态。审核员重新查看现状后才能提交新的明确动作。 - 网络中断或响应丢失后先查询原操作/版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。 diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 108f29425..ac56a40c2 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -19,7 +19,10 @@ use module_game_distribution::{ validate_release_zip, }; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; -use platform_oss::{OssGetObjectRequest, OssInternalPutObjectRequest, OssObjectAccess}; +use platform_oss::{ + OssAppendInternalObjectRequest, OssDeleteObjectRequest, OssGetObjectRequest, + OssInternalPutObjectRequest, OssObjectAccess, +}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::game_distribution::{ @@ -49,6 +52,13 @@ use crate::{ }; pub(crate) const MAX_PACKAGE_REQUEST_BODY_BYTES: usize = MAX_PACKAGE_BYTES as usize + 1024; +/// 分片续传的固定分片大小:200 MiB 上限下最多 25 片,单片远低于反代放行量。 +/// 客户端只能使用服务端下发的值,不得自行改变分片边界,否则权威偏移会立刻对不上。 +pub(crate) const PACKAGE_UPLOAD_CHUNK_BYTES: usize = 8 * 1024 * 1024; +/// 分片路由的请求体放行量:分片大小 + 1 KiB 头部余量。 +pub(crate) const MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES: usize = PACKAGE_UPLOAD_CHUNK_BYTES + 1024; +/// 分片偏移由客户端显式声明,服务端以对象当前长度为唯一权威。 +const PACKAGE_UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; const MAX_LIST_LIMIT: u32 = 48; const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128; const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024; @@ -60,7 +70,9 @@ const GAME_DISTRIBUTION_PUBLISHED_STATUS: &str = "published"; const GAME_DISTRIBUTION_OSS_PUT_MAX_ATTEMPTS: usize = 3; const GAME_DISTRIBUTION_OSS_PUT_RETRY_DELAYS_MS: [u64; 2] = [250, 500]; const RELEASE_PACKAGE_CACHE_MAX_ENTRIES: usize = 4; -const RELEASE_PACKAGE_CACHE_MAX_BYTES: usize = 200 * 1024 * 1024; +/// 缓存字节预算必须比单个发行包上限大出一档,否则 200 MiB 档的包只能刚好自占整份预算, +/// 任何并发的小包都会被立刻挤掉。 +const RELEASE_PACKAGE_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024; /// 发行静态资源的进程内缓存。 /// @@ -179,6 +191,23 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/versions/{version_id}/package", put(upload_package).layer(DefaultBodyLimit::max(MAX_PACKAGE_REQUEST_BODY_BYTES)), ) + .route( + "/api/game-distribution/versions/{version_id}/package/upload-state", + get(package_upload_state), + ) + .route( + "/api/game-distribution/versions/{version_id}/package/chunk", + put(upload_package_chunk) + .layer(DefaultBodyLimit::max(MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES)), + ) + .route( + "/api/game-distribution/versions/{version_id}/package/complete", + post(complete_package_upload), + ) + .route( + "/api/game-distribution/versions/{version_id}/package/reset", + post(reset_package_upload), + ) .route( "/api/game-distribution/versions/{version_id}/submit", post(submit_version), @@ -630,46 +659,10 @@ async fn upload_package( return Err(mapped); } }; - if manifest.package_sha256 != expected.package_sha256 - || manifest.package_bytes != expected.package_bytes - || u32::try_from(manifest.files.len()).unwrap_or(u32::MAX) != expected.package_file_count - || expected.package_entry_path != "index.html" - { - warn!( - request_id = ctx.request_id(), - operation = "package_rejected", - game_id = %expected.game_id, - version_id = %version_id, - code = "PACKAGE_MISMATCH", - declared_bytes = expected.package_bytes, - actual_bytes = manifest.package_bytes, - declared_file_count = expected.package_file_count, - actual_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), - elapsed_ms = ctx.elapsed(), - "发行包与版本声明不一致" - ); - let error = AppError::from_status(StatusCode::CONFLICT) - .with_code("PACKAGE_MISMATCH") - .with_details(json!({ - "provider": "game-distribution", - "message": "发行包摘要、体积、文件数或入口与版本声明不一致", - })); - record_upload_failure( - &state, - &owner_user_id, - &version_id, - &idempotency_key, - "PACKAGE_MISMATCH", - "发行包摘要、体积、文件数或入口与版本声明不一致".to_string(), - ) - .await; - return Err(error); - } let package_object_key = format!( "{GAME_DISTRIBUTION_OBJECT_PREFIX}{}/{version_id}.zip", expected.game_id ); - let package_manifest_json = package_manifest_json(&manifest)?; let oss = state.project_snapshot_oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("游戏发行包 OSS 未配置") })?; @@ -697,7 +690,8 @@ async fn upload_package( None => false, }; if !skipped { - // 单次 100 MiB PUT 在本机实测 12 秒上下,偶发传输失败会让作者白传一次; + // 单次 100 MiB 档 PUT 在本机实测 12 秒上下(上限提升到 200 MiB 后单次耗时与失败 + // 暴露面同步放大),偶发传输失败会让作者白传一次; // 这里按 platform-oss 既有的可重试分类做受控重试(只重试传输/超时/408/429/5xx)。 oss.put_internal_object_with_retry( state.editor_oss_http_client(), @@ -714,28 +708,93 @@ async fn upload_package( .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; } + confirm_validated_package( + &state, + &ctx, + &owner_user_id, + &version_id, + &expected, + &manifest, + package_object_key, + &idempotency_key, + skipped, + ) + .await +} + +/// 校验通过后的共同收口:声明比对 → 确认 → 结构化事件。 +/// +/// 整包 `PUT` 与分片续传的完成动作共用这条路径,两种入口的校验、幂等与事件口径必须一致; +/// 任何入口都不得绕过它直接写版本状态。 +#[allow(clippy::too_many_arguments)] +async fn confirm_validated_package( + state: &AppState, + ctx: &RequestContext, + owner_user_id: &str, + version_id: &str, + expected: &GameDistributionVersionRecord, + manifest: &ReleasePackageManifest, + package_object_key: String, + idempotency_key: &str, + oss_put_skipped: bool, +) -> Result, AppError> { + if manifest.package_sha256 != expected.package_sha256 + || manifest.package_bytes != expected.package_bytes + || u32::try_from(manifest.files.len()).unwrap_or(u32::MAX) != expected.package_file_count + || expected.package_entry_path != "index.html" + { + warn!( + request_id = ctx.request_id(), + operation = "package_rejected", + game_id = %expected.game_id, + version_id = %version_id, + code = "PACKAGE_MISMATCH", + declared_bytes = expected.package_bytes, + actual_bytes = manifest.package_bytes, + declared_file_count = expected.package_file_count, + actual_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), + elapsed_ms = ctx.elapsed(), + "发行包与版本声明不一致" + ); + let error = AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_MISMATCH") + .with_details(json!({ + "provider": "game-distribution", + "message": "发行包摘要、体积、文件数或入口与版本声明不一致", + })); + record_upload_failure( + state, + owner_user_id, + version_id, + idempotency_key, + "PACKAGE_MISMATCH", + "发行包摘要、体积、文件数或入口与版本声明不一致".to_string(), + ) + .await; + return Err(error); + } + let package_manifest_json = package_manifest_json(manifest)?; let request_digest = compute_request_digest( - &serde_json::to_vec(&(version_id.as_str(), manifest.package_sha256.as_str())) + &serde_json::to_vec(&(version_id, manifest.package_sha256.as_str())) .map_err(|error| internal(error.to_string()))?, ); let log_game_id = expected.game_id.clone(); let log_package_bytes = manifest.package_bytes; let log_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX); let log_sha_prefix = manifest.package_sha256.chars().take(12).collect::(); - let log_oss_put_skipped = skipped; let confirmed = state .spacetime_client() .confirm_game_distribution_package( spacetime_client::GameDistributionConfirmPackageRecordInput { - version_id, - owner_user_id, - package_sha256: manifest.package_sha256, + version_id: version_id.to_string(), + owner_user_id: owner_user_id.to_string(), + package_sha256: manifest.package_sha256.clone(), package_bytes: manifest.package_bytes, package_file_count: u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), package_entry_path: "index.html".to_string(), package_object_key, package_manifest_json, - idempotency_key, + idempotency_key: idempotency_key.to_string(), request_digest, updated_at_micros: now_micros(), }, @@ -750,16 +809,341 @@ async fn upload_package( package_bytes = log_package_bytes, file_count = log_file_count, sha256_prefix = %log_sha_prefix, - oss_put_skipped = log_oss_put_skipped, + oss_put_skipped, elapsed_ms = ctx.elapsed(), "发行包已确认" ); Ok(json_success_body( - Some(&ctx), + Some(ctx), json!({ "versionId": confirmed.0.version_id, "status": confirmed.0.status }), )) } +/// 分片续传的状态查询:客户端拿到的「已收字节」来自 OSS 对象事实,不依赖本地记录, +/// 因此进程重启、换机器或换网络后都能从权威偏移继续。 +async fn package_upload_state( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + Ok(json_success_body( + Some(&ctx), + json!({ + "versionId": version_id, + "status": version.status, + "chunkBytes": PACKAGE_UPLOAD_CHUNK_BYTES, + "declaredPackageBytes": version.package_bytes, + "receivedBytes": received_bytes, + }), + )) +} + +/// 分片写入。 +/// +/// 客户端声明的偏移必须等于服务端已收字节;不一致时返回 409 与权威偏移, +/// 由客户端按权威偏移续传 —— 这样重放与乱序都不会造成重复写入。 +async fn upload_package_chunk( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, + body: Bytes, +) -> Result, AppError> { + require_octet_stream_content_type(&headers)?; + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + // 分片级重放由偏移语义保证,这里仍要求幂等键,保持与其它写入口一致的调用约定。 + let _idempotency_key = idempotency_key(&headers)?; + let offset = package_upload_offset(&headers)?; + if body.is_empty() { + return Err(bad_request("发行包分片内容不能为空")); + } + if body.len() > PACKAGE_UPLOAD_CHUNK_BYTES { + return Err(AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE) + .with_code("PACKAGE_CHUNK_TOO_LARGE") + .with_message("发行包分片超过服务端下发的大小")); + } + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + let chunk_bytes = u64::try_from(body.len()).unwrap_or(u64::MAX); + let end = offset + .checked_add(chunk_bytes) + .ok_or_else(|| bad_request("发行包分片偏移溢出"))?; + if end > version.package_bytes { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_EXCEEDS_DECLARED") + .with_details(json!({ + "provider": "game-distribution", + "declaredPackageBytes": version.package_bytes, + "receivedBytes": offset, + "message": "分片写入会超过版本声明的发行包大小", + }))); + } + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + if offset != received_bytes { + warn!( + request_id = ctx.request_id(), + operation = "package_chunk_offset_mismatch", + game_id = %version.game_id, + version_id = %version_id, + declared_offset = offset, + received_bytes, + "发行包分片偏移与服务端已收字节不一致" + ); + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_OFFSET_MISMATCH") + .with_message("分片偏移与服务端已收字节不一致,请按权威偏移续传") + .with_details(json!({ + "provider": "game-distribution", + "receivedBytes": received_bytes, + }))); + } + let appended = oss + .append_internal_object_with_retry( + state.editor_oss_http_client(), + OssAppendInternalObjectRequest { + object_key, + content_type: Some("application/zip".to_string()), + position: offset, + body: body.to_vec(), + }, + GAME_DISTRIBUTION_OSS_PUT_MAX_ATTEMPTS, + &GAME_DISTRIBUTION_OSS_PUT_RETRY_DELAYS_MS, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + info!( + request_id = ctx.request_id(), + operation = "package_chunk_stored", + game_id = %version.game_id, + version_id = %version_id, + offset, + chunk_bytes = appended.appended_bytes, + received_bytes = appended.next_position, + elapsed_ms = ctx.elapsed(), + "发行包分片已写入" + ); + Ok(json_success_body( + Some(&ctx), + json!({ + "versionId": version_id, + "chunkBytes": PACKAGE_UPLOAD_CHUNK_BYTES, + "receivedBytes": appended.next_position, + }), + )) +} + +/// 分片续传的完成动作:全部字节到齐后才回读整包、校验并确认。 +/// +/// 校验失败时删除半包对象并把版本落到 `upload_failed`,避免半包留在对象键上拖住后续重传。 +async fn complete_package_upload( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let idempotency_key = idempotency_key(&headers)?; + let version = + load_owner_version_or_404(&state, owner_user_id.clone(), version_id.clone()).await?; + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + if received_bytes == 0 { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_NOT_STARTED") + .with_details(json!({ + "provider": "game-distribution", + "declaredPackageBytes": version.package_bytes, + "receivedBytes": 0, + "message": "该版本还没有任何已收分片", + }))); + } + if received_bytes != version.package_bytes { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_INCOMPLETE") + .with_message("发行包分片尚未收齐") + .with_details(json!({ + "provider": "game-distribution", + "declaredPackageBytes": version.package_bytes, + "receivedBytes": received_bytes, + }))); + } + let body = oss + .get_object( + state.editor_oss_http_client(), + OssGetObjectRequest { + object_key: object_key.clone(), + max_bytes: MAX_PACKAGE_BYTES as usize, + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let manifest = match validate_release_zip(&body) { + Ok(manifest) => manifest, + Err(error) => { + let reason = format!("{error:?}"); + warn!( + request_id = ctx.request_id(), + operation = "package_rejected", + game_id = %version.game_id, + version_id = %version_id, + code = "PACKAGE_VALIDATION_FAILED", + reason = %reason, + uploaded_bytes = body.len(), + elapsed_ms = ctx.elapsed(), + "发行包校验失败" + ); + let mapped = map_package_error(error); + if let Err(delete_error) = oss + .delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + { + warn!( + request_id = ctx.request_id(), + operation = "package_staging_delete_failed", + version_id = %version_id, + error = %delete_error, + "校验失败的半包对象删除失败,需要人工确认对象键状态" + ); + } + record_upload_failure( + &state, + &owner_user_id, + &version_id, + &idempotency_key, + "PACKAGE_VALIDATION_FAILED", + reason, + ) + .await; + return Err(mapped); + } + }; + confirm_validated_package( + &state, + &ctx, + &owner_user_id, + &version_id, + &version, + &manifest, + object_key, + &idempotency_key, + true, + ) + .await +} + +/// 显式重置分片会话:删除半包对象并把已收字节归零。 +/// +/// 只有尚未确认过发行包的版本能重置;已确认的版本必须新建版本,不能在半包之上续写不同字节。 +async fn reset_package_upload( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let _idempotency_key = idempotency_key(&headers)?; + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + if !matches!(version.status.as_str(), "awaiting_upload" | "upload_failed") { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_RESET_NOT_ALLOWED") + .with_message("该版本已经确认过发行包,重新上传请新建版本")); + } + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + oss.delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + info!( + request_id = ctx.request_id(), + operation = "package_upload_reset", + game_id = %version.game_id, + version_id = %version_id, + elapsed_ms = ctx.elapsed(), + "发行包分片会话已重置" + ); + Ok(json_success_body( + Some(&ctx), + json!({ "versionId": version_id, "receivedBytes": 0 }), + )) +} + +fn game_distribution_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { + state.project_snapshot_oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("游戏发行包 OSS 未配置") + }) +} + +fn game_distribution_package_object_key(game_id: &str, version_id: &str) -> String { + format!("{GAME_DISTRIBUTION_OBJECT_PREFIX}{game_id}/{version_id}.zip") +} + +/// 已收字节的权威来源:对象存在时的长度;确定不存在时是 0,其它失败按上游错误上报。 +async fn staged_package_bytes( + state: &AppState, + oss: &platform_oss::OssClient, + object_key: &str, +) -> Result { + let head = oss + .head_internal_object(state.editor_oss_http_client(), object_key) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + Ok(head.map(|object| object.content_length).unwrap_or(0)) +} + +fn require_octet_stream_content_type(headers: &HeaderMap) -> Result<(), AppError> { + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + }); + if content_type.as_deref() != Some("application/octet-stream") { + return Err(bad_request("发行包分片必须使用 application/octet-stream")); + } + Ok(()) +} + +fn package_upload_offset(headers: &HeaderMap) -> Result { + let raw = headers + .get(PACKAGE_UPLOAD_OFFSET_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| bad_request("缺少发行包分片偏移"))?; + raw.parse::() + .map_err(|_| bad_request("发行包分片偏移必须是非负整数")) +} + async fn submit_version( State(state): State, Extension(ctx): Extension, @@ -1969,6 +2353,64 @@ mod tests { } } + #[test] + fn package_request_body_limit_covers_max_package_bytes() { + // 口径约束:发行包路由的请求体放行量必须覆盖包体上限,否则合法包会在 + // `DefaultBodyLimit` 处被 413,而 ZIP 校验根本没机会执行。 + assert!(MAX_PACKAGE_REQUEST_BODY_BYTES > MAX_PACKAGE_BYTES as usize); + } + + #[test] + fn package_chunk_size_stays_inside_declared_limits() { + // 分片必须能整除式地覆盖 200 MiB 档发行包(最多 25 片),且分片放行量要留出头部余量。 + assert_eq!(PACKAGE_UPLOAD_CHUNK_BYTES, 8 * 1024 * 1024); + assert!(PACKAGE_UPLOAD_CHUNK_BYTES < MAX_PACKAGE_BYTES as usize); + assert!(MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES > PACKAGE_UPLOAD_CHUNK_BYTES); + assert!( + (MAX_PACKAGE_BYTES as usize).div_ceil(PACKAGE_UPLOAD_CHUNK_BYTES) <= 25, + "200 MiB 档发行包的分片数必须不超过 25 片" + ); + } + + #[test] + fn package_upload_offset_requires_non_negative_integer() { + let mut headers = HeaderMap::new(); + assert!(package_upload_offset(&headers).is_err()); + + headers.insert(PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static(" ")); + assert!(package_upload_offset(&headers).is_err()); + + headers.insert(PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static("-1")); + assert!(package_upload_offset(&headers).is_err()); + + headers.insert( + PACKAGE_UPLOAD_OFFSET_HEADER, + HeaderValue::from_static("8388608"), + ); + assert_eq!( + package_upload_offset(&headers).expect("合法偏移"), + PACKAGE_UPLOAD_CHUNK_BYTES as u64 + ); + } + + #[test] + fn package_chunk_content_type_must_be_octet_stream() { + let mut headers = HeaderMap::new(); + assert!(require_octet_stream_content_type(&headers).is_err()); + + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/zip"), + ); + assert!(require_octet_stream_content_type(&headers).is_err()); + + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + assert!(require_octet_stream_content_type(&headers).is_ok()); + } + #[test] fn metadata_rejects_mobile_games_without_touch_support() { let mut payload = metadata(); diff --git a/server-rs/crates/module-game-distribution/src/package.rs b/server-rs/crates/module-game-distribution/src/package.rs index 5e8faf9f5..feb85a54e 100644 --- a/server-rs/crates/module-game-distribution/src/package.rs +++ b/server-rs/crates/module-game-distribution/src/package.rs @@ -6,8 +6,13 @@ use std::{ use sha2::{Digest, Sha256}; -pub const MAX_PACKAGE_BYTES: u64 = 100 * 1024 * 1024; -pub const MAX_EXPANDED_BYTES: u64 = 250 * 1024 * 1024; +/// 发行包体积上限。反代放行量与路由请求体上限都从它派生:Nginx +/// `client_max_body_size`、Pingora `MAX_API_BODY_BYTES` 必须同步放宽,否则合法包会在 +/// 到达 `api-server` 之前被拒。 +pub const MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024; +/// 展开总量上限保持压缩包上限的 2.5 倍余量:包体本身基本不可再压时展开量约等于包体, +/// 纯文本 / JSON 资源占比高的包仍要有足够空间。 +pub const MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; pub const MAX_FILE_COUNT: usize = 10_000; pub const MAX_COMPRESSION_RATIO: u64 = 100; @@ -187,6 +192,23 @@ mod tests { output.into_inner() } + /// 存储型条目的压缩包;用于构造体积可控且不参与 deflate 的大包。 + fn stored_archive(files: &[(&str, &[u8])]) -> Vec { + let mut output = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(&mut output); + for (path, content) in files { + writer + .start_file( + *path, + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored), + ) + .expect("zip entry"); + writer.write_all(content).expect("zip content"); + } + writer.finish().expect("finish zip"); + output.into_inner() + } + #[test] fn accepts_root_entry_and_returns_file_manifest() { let bytes = archive(&[("index.html", b""), ("assets/a.txt", b"a")]); @@ -222,4 +244,27 @@ mod tests { Err(ReleasePackageError::InvalidPath) ); } + + #[test] + fn keeps_expansion_headroom_over_package_limit() { + // 口径约束:发行包上限调整时,展开总量至少要留出两倍余量, + // 否则高文本占比的合法包会在展开量检查处被误拒。 + assert!(MAX_EXPANDED_BYTES >= MAX_PACKAGE_BYTES.saturating_mul(2)); + } + + #[test] + fn accepts_package_above_the_previous_hundred_mib_limit() { + // 上限从 100 MiB 提到 200 MiB 的回归防护:两个 50 MiB 存储型条目组成 100 MiB + // 出头的包,旧上限会在这里判 PackageTooLarge,新上限必须放行并给出完整清单。 + let chunk = vec![0_u8; 50 * 1024 * 1024]; + let bytes = stored_archive(&[ + ("index.html", b""), + ("assets/a.bin", chunk.as_slice()), + ("assets/b.bin", chunk.as_slice()), + ]); + assert!(bytes.len() as u64 > 100 * 1024 * 1024); + let manifest = validate_release_zip(&bytes).expect("package above 100 MiB"); + assert_eq!(manifest.package_bytes, bytes.len() as u64); + assert_eq!(manifest.files.len(), 3); + } } diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 88af466f2..9c1a6e877 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -37,7 +37,9 @@ const DEFAULT_WEB_ROOT: &str = "/srv/genarrative/web"; const DEFAULT_ACME_ROOT: &str = "/var/www/html"; const DEFAULT_MAINTENANCE_FILE: &str = "/var/lib/genarrative/maintenance/enabled"; const DEFAULT_MAINTENANCE_PAGE_FILE: &str = "/var/lib/genarrative/maintenance/page.html"; -const DEFAULT_MAX_API_BODY_BYTES: u64 = 64 * 1024 * 1024; +// 通用 /api 路由的放行上限;必须覆盖游戏发行包 PUT(路由 `DefaultBodyLimit` 200 MiB + 1 KiB), +// 具体接口的真实上限仍由 api-server 逐路由校验。 +const DEFAULT_MAX_API_BODY_BYTES: u64 = 210 * 1024 * 1024; const DEFAULT_GZIP_LEVEL: u32 = 5; const DEFAULT_GZIP_MIN_LENGTH_BYTES: u64 = 1024; const DEFAULT_UPSTREAM_CONNECT_TIMEOUT_MS: u64 = 3_000; diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 32b3d02f6..4cce2d31a 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -132,6 +132,29 @@ pub struct OssInternalPutObjectRequest { pub body: Vec, } +/// 内部对象的追加写请求(OSS AppendObject)。 +/// +/// `position = 0` 表示追加到当前末尾;`position > 0` 必须等于对象当前长度, +/// 否则上游直接失败 —— 分片续传依赖这条语义保证重放不会重复写入。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssAppendInternalObjectRequest { + pub object_key: String, + pub content_type: Option, + pub position: u64, + pub body: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssAppendInternalObjectResponse { + pub provider: &'static str, + pub bucket: String, + pub endpoint: String, + pub object_key: String, + pub appended_bytes: u64, + /// 下一次可写位置,由 OSS 返回,是「已收字节」的权威值。 + pub next_position: u64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct OssPutObjectRequest { pub prefix: LegacyAssetPrefix, @@ -1018,6 +1041,105 @@ impl OssClient { .await } + /// 内部对象追加写,返回 OSS 给出的下一次可写位置(已收字节的权威值)。 + /// + /// 与整对象 PUT 的区别是「可续写」:`position = 0` 追加到当前末尾, + /// `position > 0` 必须与对象当前长度一致(不一致时 OSS 判失败,不会重复写入)。 + /// 因此续传只需要回读对象长度,再从未写位置继续。 + pub async fn append_internal_object( + &self, + client: &reqwest::Client, + request: OssAppendInternalObjectRequest, + ) -> Result { + let object_key = normalize_internal_object_key(&request.object_key)?; + if request.body.is_empty() { + return Err(OssError::InvalidRequest( + "服务端内部对象追加内容不能为空".to_string(), + )); + } + let content_type = normalize_optional_value(request.content_type); + let mut target_url = + build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err( + |error| { + request_error( + OssRequestOperation::Put, + &format!("构造 OSS 对象 URL 失败:{error}"), + ) + }, + )?; + target_url + .query_pairs_mut() + .append_pair("append", "") + .append_pair("position", &request.position.to_string()); + let appended_bytes = u64::try_from(request.body.len()) + .map_err(|_| OssError::InvalidRequest("追加内容大小超出可支持范围".to_string()))?; + let headers = BTreeMap::new(); + let builder = signed_request_builder( + client, + &self.config, + Method::POST, + Some(&object_key), + target_url, + content_type.as_deref(), + &headers, + )? + .header(reqwest::header::CONTENT_LENGTH, appended_bytes) + .body(request.body); + let response = builder + .send() + .await + .map_err(|error| request_error_from_reqwest(OssRequestOperation::Put, error))?; + if !response.status().is_success() { + return Err(request_status_error( + OssRequestOperation::Put, + response.status().as_u16(), + format!("OSS AppendObject 失败,状态码:{}", response.status()), + )); + } + let next_position = response + .headers() + .get("x-oss-next-append-position") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .ok_or_else(|| { + OssError::InvalidRequest("OSS AppendObject 未返回 next-append-position".to_string()) + })?; + Ok(OssAppendInternalObjectResponse { + provider: OSS_PROVIDER, + bucket: self.config.bucket.clone(), + endpoint: self.config.endpoint.clone(), + object_key, + appended_bytes, + next_position, + }) + } + + /// 内部对象追加写的受控重试,判定与退避口径和 `put_internal_object_with_retry` 一致。 + pub async fn append_internal_object_with_retry( + &self, + client: &reqwest::Client, + request: OssAppendInternalObjectRequest, + max_attempts: usize, + retry_delays_ms: &[u64], + ) -> Result { + if max_attempts == 0 { + return Err(OssError::InvalidConfig( + "内部对象追加重试次数至少为 1".to_string(), + )); + } + if retry_delays_ms.len() < max_attempts.saturating_sub(1) { + return Err(OssError::InvalidConfig( + "内部对象追加重试缺少退避配置".to_string(), + )); + } + let log_key = request.object_key.clone(); + run_internal_put_with_retry(max_attempts, retry_delays_ms, &log_key, move || { + let request = request.clone(); + async move { self.append_internal_object(client, request).await } + }) + .await + } + async fn put_internal_object_bytes( &self, client: &reqwest::Client, @@ -1709,7 +1831,7 @@ where max_attempts, retry_delay_ms = delay_ms, error = %error, - "OSS 内部对象 PUT 失败,按退避重试" + "OSS 内部对象写入失败,按退避重试" ); sleep(std::time::Duration::from_millis(delay_ms)).await; attempt += 1; diff --git a/src/components/game-distribution/gameZipPackage.test.ts b/src/components/game-distribution/gameZipPackage.test.ts index d4f4181a0..ca2a4a826 100644 --- a/src/components/game-distribution/gameZipPackage.test.ts +++ b/src/components/game-distribution/gameZipPackage.test.ts @@ -3,7 +3,7 @@ import JSZip from 'jszip'; import { describe, expect, it } from 'vitest'; -import { prepareGamePackage } from './gameZipPackage'; +import { GAME_PACKAGE_MAX_BYTES, prepareGamePackage } from './gameZipPackage'; async function buildZip(files: Record) { const zip = new JSZip(); @@ -52,4 +52,12 @@ describe('prepareGamePackage', () => { ), ).rejects.toThrow('发行包不是有效的 ZIP'); }); + + it('超过上限时在读取字节之前就失败关闭', async () => { + // 只伪造体积,不真的分配 200 MiB;预检必须在读字节之前拦下。 + const oversized = { size: GAME_PACKAGE_MAX_BYTES + 1 } as unknown as File; + await expect(prepareGamePackage(oversized)).rejects.toThrow( + '发行包不能超过 200 MiB', + ); + }); }); diff --git a/src/components/game-distribution/gameZipPackage.ts b/src/components/game-distribution/gameZipPackage.ts index eb8497f18..fcc25c8da 100644 --- a/src/components/game-distribution/gameZipPackage.ts +++ b/src/components/game-distribution/gameZipPackage.ts @@ -1,6 +1,6 @@ import JSZip from 'jszip'; -export const GAME_PACKAGE_MAX_BYTES = 100 * 1024 * 1024; +export const GAME_PACKAGE_MAX_BYTES = 200 * 1024 * 1024; export const GAME_PACKAGE_MAX_FILE_COUNT = 10_000; export type PreparedGamePackage = { @@ -59,7 +59,7 @@ export async function prepareGamePackage( throw new Error('请选择非空的发行包 ZIP'); } if (file.size > GAME_PACKAGE_MAX_BYTES) { - throw new Error('发行包不能超过 100 MiB'); + throw new Error('发行包不能超过 200 MiB'); } const bytes = await readGamePackageBytes(file); let archive: JSZip; From f0ef0689109c87dc0043f8eaf0304e30aff5594b Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:07:37 +0800 Subject: [PATCH 19/22] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=A0=8F=E5=93=81=E7=89=8C=E6=A0=87=E7=AD=BE=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E5=AF=B9=E6=B8=A0=E9=81=93=E4=BA=A7=E5=93=81=E5=90=8D=E7=9A=84?= =?UTF-8?q?=E6=96=AD=E8=A8=80=20-=20WindowChrome=20=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20appMetadata=20=E7=9A=84=20APP=5FNAME?= =?UTF-8?q?=EF=BC=88dev=20=E6=B8=A0=E9=81=93=E4=B8=BA=E9=99=B6=E6=B3=A5?= =?UTF-8?q?=E5=84=BF=E5=BC=80=E5=8F=91=E7=89=88=EF=BC=89=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=A1=AC=E7=BC=96=E7=A0=81=E6=97=A7=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/tests/WindowChrome.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/WindowChrome.test.tsx b/apps/ai-game-creator-shell/tests/WindowChrome.test.tsx index 5f3578646..72b8169eb 100644 --- a/apps/ai-game-creator-shell/tests/WindowChrome.test.tsx +++ b/apps/ai-game-creator-shell/tests/WindowChrome.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { APP_NAME } from '../src/app/appMetadata'; import type { GameCreatorDirectActiveTurn } from '../src/app/types'; import { ThemedModal } from '../src/components/modal/ThemedModal'; import { WindowChrome } from '../src/components/WindowChrome'; @@ -66,7 +67,7 @@ describe('WindowChrome', () => { ); expect(screen.getByRole('banner', { name: '窗口标题栏' })).toBeTruthy(); - expect(screen.getByLabelText('陶泥儿 GameAgent')).toBeTruthy(); + expect(screen.getByLabelText(`${APP_NAME} GameAgent`)).toBeTruthy(); expect(screen.queryByLabelText('本地工作区')).toBeNull(); expect(screen.getByText('创作工作台')).toBeTruthy(); expect(screen.getByText('工作区内容')).toBeTruthy(); From eb192eb161b22bd4dc6bd1b8436433bc7bda69b8 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:53:45 +0800 Subject: [PATCH 20/22] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=9B=BF=E6=8D=A2=E7=AA=97=E5=8F=A3=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E5=AF=BC=E8=87=B4=E7=9A=84=E7=B3=BB=E7=BB=9F=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E5=9B=9E=E5=BD=92=E4=B8=8E=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E8=A2=AB=E6=8B=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 渠道 --config 改为从基线 tauri.conf.json 读取完整 client 窗口对象后展开、只覆盖 title,避免 Tauri JSON Merge Patch 整体替换 app.windows 丢掉 label / decorations / 尺寸 - build-release.test.mjs 新增合并守卫用例:按同一 merge patch 语义复现 Tauri 合并,断言 label=client、decorations=false、1280x800、min 1280x720,且承载 http:default 的 capability 必须包含该 label - check-config.mjs 增补基线 client 窗口 decorations 必须为 false 的门禁 - 更新 AGC 客户端更新检查与下载技术方案,写明渠道配置必须下发完整窗口对象的约定 - pitfalls.md 记录本次回归的现象、根因、现行口径与验证证据 --- .../scripts/build-release.mjs | 21 ++++- .../scripts/build-release.test.mjs | 93 ++++++++++++++++++- .../scripts/check-config.mjs | 8 ++ docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 3 +- 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 38d6d1c5c..fe13fade7 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -373,6 +373,24 @@ export function buildTauriBuildArguments( ]; } +/** + * 基线 client 窗口契约:渠道配置只允许覆盖标题,其余字段必须逐字沿用。 + * + * `tauri build --config` 走 JSON Merge Patch(tauri-utils 用 `json_patch::merge`): + * 对象递归合并,**数组整体替换**。只下发 `{ title }` 会让 + * `label` / `decorations` / 尺寸全部回落到 Tauri 默认值(label=main、 + * decorations=true、800x600),结果是原生系统标题栏重新出现,并且按 label + * 绑定的 capability(平台 HTTP 权限等)一起失效。 + */ +function readBaseClientWindow() { + const base = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8')); + const clientWindow = base.app?.windows?.[0]; + if (!clientWindow || typeof clientWindow.label !== 'string') { + throw new Error('AGC 基线配置缺少 client 主窗口,渠道配置无法安全合并'); + } + return clientWindow; +} + /** * 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道, * 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录, @@ -381,13 +399,14 @@ export function buildTauriBuildArguments( export function createChannelConfig( channel = resolveReleaseChannel(), target = defaultTarget(), + baseClientWindow = readBaseClientWindow(), ) { const { productName, identifier } = resolveChannelInstallIdentity(channel); return { productName, identifier, app: { - windows: [{ title: productName }], + windows: [{ ...baseClientWindow, title: productName }], }, plugins: { updater: { 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 4c62cd8b4..3cdd735c1 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1,5 +1,11 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; @@ -180,7 +186,18 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { productName: `${AGC_PRODUCT_NAME}开发版`, identifier: AGC_APP_IDENTIFIER, app: { - windows: [{ title: `${AGC_PRODUCT_NAME}开发版` }], + windows: [ + { + label: 'client', + title: `${AGC_PRODUCT_NAME}开发版`, + url: 'index.html', + width: 1280, + height: 800, + decorations: false, + minWidth: 1280, + minHeight: 720, + }, + ], }, plugins: { updater: { @@ -245,6 +262,78 @@ test('channel install identity is baked into the same build-time config as the e }); }); +/** + * RFC 7386(tauri-utils 用 `json_patch::merge`)语义:对象递归合并,数组整体替换。 + * 这里按同样语义复现 Tauri CLI 的 `--config` 合并,用来守住"渠道配置不得丢窗口契约"。 + */ +function applyJsonMergePatch(base, patch) { + if (Array.isArray(patch) || typeof patch !== 'object' || patch === null) { + return patch; + } + const merged = + typeof base === 'object' && base !== null && !Array.isArray(base) + ? { ...base } + : {}; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete merged[key]; + else merged[key] = applyJsonMergePatch(merged[key], value); + } + return merged; +} + +function readBaseTauriConfig() { + return JSON.parse( + readFileSync( + new URL('../src-tauri/tauri.conf.json', import.meta.url), + 'utf8', + ), + ); +} + +test('channel config keeps the client window contract across the Tauri config merge', () => { + const base = readBaseTauriConfig(); + const merged = applyJsonMergePatch(base, { + ...createChannelConfig('release', windowsTarget), + version: base.version, + }); + const [clientWindow] = merged.app.windows; + assert.deepEqual(clientWindow, { + ...base.app.windows[0], + title: '陶泥儿 Release', + }); + // 原生标题栏、尺寸与默认窗口标签都是回归点:任何一项回落都会让自绘标题栏失效, + // 并让按 label 绑定的 capability(平台 HTTP 权限)不再命中。 + assert.equal(clientWindow.label, 'client'); + assert.equal(clientWindow.decorations, false); + assert.equal(clientWindow.width, 1280); + assert.equal(clientWindow.height, 800); + assert.equal(clientWindow.minWidth, 1280); + assert.equal(clientWindow.minHeight, 720); + + const capabilitiesDirectory = new URL( + '../src-tauri/capabilities/', + import.meta.url, + ); + const capabilities = readdirSync(capabilitiesDirectory) + .filter((name) => name.endsWith('.json')) + .map((name) => + JSON.parse(readFileSync(new URL(name, capabilitiesDirectory), 'utf8')), + ); + const httpCapability = capabilities.find((capability) => + (capability.permissions ?? []).some( + (permission) => + permission === 'http:default' || + (typeof permission === 'object' && + permission?.identifier === 'http:default'), + ), + ); + assert.ok(httpCapability, '客户端必须保留承载平台 HTTP 权限的 capability'); + assert.ok( + (httpCapability.windows ?? []).includes(clientWindow.label), + `平台 HTTP capability 必须绑定 ${clientWindow.label} 窗口,实际:${httpCapability.windows}`, + ); +}); + test('channel products keep first-install selection working under the channel product name', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-')); try { diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 31b7d52f4..b22a44081 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1567,6 +1567,14 @@ if ( ); } +// 窗口外壳由前端 WindowChrome 自绘:基线配置一旦放开 decorations, +// 打包产物会出现系统标题栏与自绘标题栏并存。 +if (clientWindow.decorations !== false) { + throw new Error( + 'AI game creator shell client window must keep native decorations disabled', + ); +} + if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') { throw new Error( 'AI game creator shell Tauri config must retain the non-launcher fallback devUrl', diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 877437f62..2efa4c511 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5939,3 +5939,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:模板清单里的封面 URL 失效(或离线)时,卡片封面上出现浏览器的破碎图片图标,比没有封面更难看。 - **处理**:`TemplateCard` 的 `img` 加 `onError` 直接把自身 `visibility` 设为 `hidden`(不进 state,卡片是 memo 的纯展示组件),留下封面容器本身的中性底色;单测用 `fireEvent.error(cover)` 钉住。 - **关联**:`apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx`、`apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`。 + +## 2026-09-23 渠道 `--config` 只写窗口标题,打包产物系统标题栏回来了且登录请求被 ACL 拒绝 + +- **现象**:dev 渠道 0.1.129 安装包启动后,窗口顶部同时出现系统标题栏(浅蓝条 + 原生最小化/最大化/关闭)与前端自绘 `WindowChrome`;窗口缩到 816x639(约 800x600 客户区);登录页常驻「无法连接登录服务,请确认配套后端或 API 代理已启动后重试」。应用日志同一秒出现 `startup.window-title.failed: 缺少 client 主窗口`,而 `https://dev.genarrative.world` 在浏览器/curl 下可正常响应。 +- **原因**:`ebb288a6a`(2026-09-23 18:49)为统一渠道产品名,在渠道配置里加了 `app: { windows: [{ title: productName }] }`。Tauri 的 `--config` 合并是 JSON Merge Patch(`tauri-utils/build.rs` 用 `json_patch::merge`):对象递归合并、**数组整体替换**。基线窗口数组被整条换掉后,`label` 回落到默认 `main`(不是 `client`)、`decorations` 回落到 `true`、尺寸回落到 800x600。三条症状同源:① `decorations: true` → 系统标题栏;② 尺寸回落 → 816x639;③ label 不再是 `client` → `capabilities/main.json`(`windows: ["client"]`,承载 `http:default` 与平台 API scope、dialog/opener/updater/剪贴板权限)整条不命中,前端 `fetchClientHttp` 走 `@tauri-apps/plugin-http` 时被 ACL 拒绝并抛错,登录状态检查就报成"连不上服务器"。判断关键:**这类"连不上服务"是权限拒绝,不是网络故障——先看窗口 label 与 capability 的 `windows` 是否还对得上,别去查后端与代理**。 +- **处理(现行口径)**:`createChannelConfig()` 从基线 `src-tauri/tauri.conf.json` 读完整 client 窗口对象后展开、只覆盖 `title`(`readBaseClientWindow()`),渠道配置不得再出现"只写 `title`"的窗口对象。新增守卫:`build-release.test.mjs` 用同语义的 merge patch 复现 Tauri 合并并断言 `label=client` / `decorations=false` / 1280x800 / min 1280x720 且承载 `http:default` 的 capability 必须包含该 label;`check-config.mjs` 增补基线 `decorations !== false` 失败关闭。 +- **验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs scripts/cargo-features.test.mjs scripts/release-oss.test.mjs scripts/prepare-macos-codex.test.mjs`(60/60)、`node apps/ai-game-creator-shell/scripts/check-config.mjs` 通过;`createChannelConfig('dev', …)` 实测输出含 `label: client` 与 `decorations: false`。修复后的安装包尚未重新构建与安装,真机观感与登录链未复核。 +- **关联**:`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/src-tauri/capabilities/main.json`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 66fe3e5cb..8bbdbe95a 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -136,7 +136,8 @@ - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。 - 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。 -- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier` 与 updater 端点:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 +- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier`、updater 端点与窗口标题:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 +- 渠道配置走 Tauri 的 JSON Merge Patch 语义:对象递归合并,**数组整体替换**。因此 `app.windows` 必须按基线 `tauri.conf.json` 的完整 client 窗口对象下发、只覆盖 `title`(脚本从基线读取后展开);任何"只写 `{ title }`"的写法都会让 `label` / `decorations` / 尺寸回落成 Tauri 默认值(`label=main`、`decorations=true`、800x600),表现为打包产物重新出现系统标题栏,并按 label 连带失效承载平台 HTTP 权限等 capability。守卫用例:`build-release.test.mjs` 的渠道配置合并用例与 `check-config.mjs` 的 `decorations` 门禁。 - 定时调度分别判断服务端与客户端 scope:dev 小时调度在提交含 AGC 相关路径时发布对应渠道,纯文档或流水线自身的提交仍只跑 Full Build;release 每日调度在服务端相关路径变化时发布正式 Full Build,在 AGC 相关路径变化时发布 release 客户端,并在同一调度内等待、汇总各 lane 结果,失败 lane 下一轮补发。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要不再自动生成:发布脚本不读取提交记录生成 `notes`;只有 `AGC_UPDATE_RELEASE_NOTES` 非空时,才把显式手动文案写入渠道清单和旧协议清单的 `releaseNotes`。未设置时清单不携带更新说明,归档文件 `release-notes.txt` 记录“本次没有可用的更新摘要”。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段;发布脚本只为线上排障保留源码 revision,不驱动更新摘要。 From 0dbd279dfc79e586630a9bdb22854ffe891e540c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:56:24 +0800 Subject: [PATCH 21/22] =?UTF-8?q?=E5=90=8E=E5=8F=B0=20Dashboard=20?= =?UTF-8?q?=E6=B6=88=E8=80=97=E6=B3=A5=E7=82=B9=E6=94=B9=E4=B8=BA=E5=AF=B9?= =?UTF-8?q?=E5=86=B2=E9=80=80=E8=BF=98=E5=90=8E=E7=9A=84=E5=87=80=E6=B6=88?= =?UTF-8?q?=E8=80=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consumedMudPoints 改为净额:按北京时间业务日先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的丢弃,每日净额非负且区间合计严格等于「消耗 − 退还」 - 退还口径并入 asset_operation_refund 正向流水(生成失败退还、精选审核返还)与 llm_router_consume 正向冲正流水;充值退款追回、余额重置、赠送和 hold 仍不计入 - 契约新增 refundedMudPoints,后端新增「退还泥点」趋势图,后台时段数据并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出 - 新增 4 条净额单测(同日对冲 / 回溯抵扣 / 超额归零 / 无退还保原值)与 Dashboard 用例断言 - 同步 Dashboard 运营看板方案指标口径与决策记录 - 验证:cargo test -p api-server --bin api-server admin(136 passed / 0 failed / 1 ignored)、npm run admin-web:typecheck、npx vitest run apps/admin-web/src(218 passed)、cargo fmt --all --check、npm run check:encoding、npm run check:doc-index、git diff --check 全部通过 --- apps/admin-web/src/api/adminApiTypes.ts | 3 + .../src/pages/AdminDashboardPage.test.tsx | 3 + .../src/pages/AdminDashboardPage.tsx | 6 + .../shared-memory/decision-log.md | 8 + ...台管理】Dashboard运营看板方案-2026-06-23.md | 4 +- server-rs/crates/api-server/src/admin.rs | 160 +++++++++++++++--- .../crates/shared-contracts/src/admin.rs | 3 + 7 files changed, 167 insertions(+), 20 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 71159500c..85ca7961a 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -193,7 +193,10 @@ export interface AdminDashboardRangePayload { export interface AdminDashboardMetricsPayload { generatedAssets: number; + /** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */ consumedMudPoints: number; + /** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */ + refundedMudPoints: number; totalRegisteredUsers: number; newRegisteredUsers: number; newUserPaymentConversion: AdminDashboardPaymentConversionPayload; diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index a278a19dc..3653df930 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -34,6 +34,7 @@ const dashboardResponse: AdminDashboardResponse = { metrics: { generatedAssets: 12, consumedMudPoints: 88, + refundedMudPoints: 24, totalRegisteredUsers: 1200, newRegisteredUsers: 16, newUserPaymentConversion: { @@ -105,6 +106,8 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(await screen.findByText('总计数据')).toBeTruthy(); expect(screen.getByText('时段数据')).toBeTruthy(); expect(screen.getByText('本日生产素材数')).toBeTruthy(); + expect(screen.getByText('本日消耗泥点数')).toBeTruthy(); + expect(screen.getByText('本日退还泥点数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); expect(screen.getByText('本日新增用户数')).toBeTruthy(); expect(screen.getByText('新增用户转化与留存')).toBeTruthy(); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index 933abf45a..111e7e9a0 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -128,6 +128,12 @@ export function AdminDashboardPage({ value: metrics?.consumedMudPoints ?? 0, unit: '泥点', }, + { + id: 'refunded-mud-points', + label: `${rangePrefix(granularity)}退还泥点数`, + value: metrics?.refundedMudPoints ?? 0, + unit: '泥点', + }, { id: 'new-registered-users', label: `${rangePrefix(granularity)}新增用户数`, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a11c2b6ff..a7cfa58de 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9269,3 +9269,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}` 与 `apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}`。 - 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿。 - 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx` 与 `DirectChatTurnState` 注释里的 A 项)。 + +## 2026-09-23 后台 Dashboard「消耗泥点」改为对冲退还后的净消耗 + +- 背景:Dashboard 的「消耗泥点数」只累计负向消费流水,生成失败退还、精选审核返还和 LLM Router 正向冲正都不参与抵扣,运营看到的「总消耗」明显高于用户实际花费(用户现场反馈)。 +- 决策:`GET /admin/api/dashboard` 的 `consumedMudPoints` 改为净消耗。先按北京时间业务日累计 `asset_operation_consume` / `llm_router_consume` 的负向流水绝对值,再用同期 `asset_operation_refund` 正向流水和 `llm_router_consume` 正向冲正流水按日抵扣:先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的退还丢弃。因此每日净额非负,区间合计严格等于「消耗 − 退还」。新增 `refundedMudPoints` 与「退还泥点」趋势图,前台「消耗泥点数」卡旁并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出,不把退还金额藏进净额。 +- 边界(本次不改):用户详情「历史花费」(`profile_wallet_consumption_total` 投影与手动对账)维持既有「退款不冲减」决策,仍只累计负向消费流水;若要改成净额,必须单独走投影语义 + 对账口径变更,不能顺手改这一处。充值退款追回、余额重置、赠送和 hold 继续不计入消耗。 +- 影响范围:`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/AdminDashboardPage.tsx`、对应用例与 `docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server dashboard_consumption`(新增 4 条净额用例全绿);`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin`(136 passed / 0 failed / 1 ignored);`npm run admin-web:typecheck`;`npx vitest run apps/admin-web/src`(218 passed);`npm run check:encoding`、`git diff --check`。 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index 0c951c3d2..b26f58358 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -22,7 +22,8 @@ ## 指标口径 - 生产素材数:`editor_project_resource` 中 `source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。 -- 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。 +- 消耗泥点数(净消耗):`profile_wallet_ledger` 中 `source_type` 为 `asset_operation_consume` / `llm_router_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日后,再按日对冲同期退还泥点——先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的退还丢弃,因此每日净额非负,区间合计严格等于「消耗 − 退还」。充值退款追回、余额重置、赠送和 hold 都不计入。 +- 退还泥点数:`source_type = asset_operation_refund` 的正向流水(生成失败退还、精选审核返还)与 `llm_router_consume` 的正向冲正流水,按 `created_at` 映射到北京时间业务日。它只对冲本看板的消耗口径,不改变用户详情「历史花费」按既有决策的退款不冲减口径。 - 总注册用户:`profile_dashboard_state` 行数。 - 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。 - 新增用户付费率:分母为当前筛选时间窗内的新增用户,分子为这些用户中截至本次查询时已至少完成一次真实支付的去重人数。真实支付以 `profile_recharge_order.paid_at` 存在且不晚于本次查询时刻为准;只创建订单或未支付订单不计,已退款订单仍表示曾经发生过付费转化,因此保留在分子。返回付费人数、新增用户数和四舍五入后的基点率;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。 @@ -33,6 +34,7 @@ - 留存活跃:用户在注册日恰好 `D+1` / `D+7` 的 `tracking_daily_stat` 中存在上述有效 user scope 行;同一用户同日多个事件只计一次,不按“1 / 7 天内累计回访”计算。筛选范围约束注册 cohort,观察日允许晚于筛选结束日。 - 留存成熟条件:目标观察日必须早于当前北京时间业务日;观察日为今天时因当天尚未完整结束而排除。D1、D7 的可观察人数通常不同,分别返回 `eligibleUsers`、`retainedUsers` 与 `rateBasisPoints = round(retainedUsers * 10000 / eligibleUsers)`;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。汇总率按总人数加权,不平均每日百分比。 - 运营汇总页签:复用同一时间窗,展示运营指标卡、素材类型分布和访问模块分布。 +- 趋势图:`消耗泥点` 图展示按日对冲之后的净额,区间合计与「消耗泥点数」指标一致;`退还泥点` 图展示同期退还金额,毛消耗可由「消耗 + 退还」核出,避免把退还金额藏进净额里。 ## 前后端文件 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 2f9838531..176c0e39a 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2383,6 +2383,7 @@ struct AdminDashboardAssetStats { #[derive(Default)] struct AdminDashboardWalletStats { consumed_mud_points: AdminDashboardSeries, + refunded_mud_points: AdminDashboardSeries, recharged_mud_points: u64, warnings: Vec, } @@ -2419,12 +2420,22 @@ async fn build_admin_dashboard( let user_stats = fetch_admin_dashboard_user_stats(state, &range, now_micros).await?; + let day_keys = range.day_keys(); + // 消耗泥点按日对冲退还:每日净额不为负,区间合计等于「消耗 − 退还」。 + let net_consumed_mud_points = net_admin_dashboard_consumed_mud_points( + &wallet_stats.consumed_mud_points, + &wallet_stats.refunded_mud_points, + &day_keys, + ); + let generated_assets = asset_stats.generated_assets.total(); - let consumed_mud_points = wallet_stats.consumed_mud_points.total(); + let consumed_mud_points = net_consumed_mud_points.total(); + let refunded_mud_points = wallet_stats.refunded_mud_points.total(); let new_registered_users = user_stats.new_registered_users.total(); let metrics = AdminDashboardMetricsPayload { generated_assets, consumed_mud_points, + refunded_mud_points, total_registered_users: user_stats.total_registered_users, new_registered_users, day1_retention: user_stats.day1_retention.clone(), @@ -2450,8 +2461,16 @@ async fn build_admin_dashboard( "consumed-mud-points", "消耗泥点", "泥点", - &wallet_stats.consumed_mud_points, - wallet_stats.consumed_mud_points.total(), + &net_consumed_mud_points, + net_consumed_mud_points.total(), + &range, + ), + build_admin_dashboard_chart( + "refunded-mud-points", + "退还泥点", + "泥点", + &wallet_stats.refunded_mud_points, + refunded_mud_points, &range, ), build_admin_dashboard_chart( @@ -2719,6 +2738,10 @@ async fn fetch_admin_dashboard_wallet_stats( .consumed_mud_points .add(day_key, amount_delta.unsigned_abs()); } + // 生成失败 / 精选审核返还,以及 LLM Router 的正向冲正流水,都按退还对冲消耗。 + "asset_operation_refund" | "llm_router_consume" if amount_delta > 0 => { + stats.refunded_mud_points.add(day_key, amount_delta as u64); + } "points_recharge" if amount_delta > 0 => { stats.recharged_mud_points = stats .recharged_mud_points @@ -2982,6 +3005,40 @@ impl AdminDashboardSeries { } } +/// 消耗泥点按日对冲退还:先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日。 +/// 抵扣不完的退还直接丢弃,保证每日净额非负,且区间合计等于「消耗 − 退还」。 +fn net_admin_dashboard_consumed_mud_points( + consumed: &AdminDashboardSeries, + refunded: &AdminDashboardSeries, + day_keys: &[i64], +) -> AdminDashboardSeries { + let mut remaining: Vec = day_keys + .iter() + .map(|day_key| consumed.value(*day_key)) + .collect(); + for (index, day_key) in day_keys.iter().enumerate() { + let mut pending = refunded.value(*day_key); + if pending == 0 { + continue; + } + for back in (0..=index).rev() { + if pending == 0 { + break; + } + let applied = remaining[back].min(pending); + remaining[back] -= applied; + pending -= applied; + } + } + let mut series = AdminDashboardSeries::default(); + for (index, day_key) in day_keys.iter().enumerate() { + if remaining[index] > 0 { + series.add(*day_key, remaining[index]); + } + } + series +} + fn build_spacetime_schema_url(server_root: &str, database: &str) -> String { format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}") } @@ -4786,28 +4843,29 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ - AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord, - admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, - admin_editor_asset_group_payload, admin_editor_asset_payload_from_record, - admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, - apply_admin_database_table_filters, build_admin_asset_read_url_audit, - build_admin_dashboard_chart, build_admin_database_table_row, - build_admin_editor_showcase_campaign_image_confirm_request, + AdminDashboardGranularity, AdminDashboardSeries, AdminDisplayNameDirectory, + EditorShowcaseAssetRecord, admin_dashboard_user_stats_from_record, + admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload, + admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record, + append_spacetime_sql_response_chunk, apply_admin_database_table_filters, + build_admin_asset_read_url_audit, build_admin_dashboard_chart, + build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request, build_admin_external_api_key_sql, build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, enforce_admin_request_permission, fetch_admin_database_table_rows, finalize_admin_database_table_rows_response, group_admin_editor_asset_records, hash_admin_password, is_admin_account_not_found, - is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error, - paginate_admin_editor_asset_records, parse_admin_database_table_rows_sql_response, - parse_admin_external_api_key_row, parse_admin_tracking_event_keys_sql_response, - parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, - parse_timestamp_text_to_micros, resolve_admin_dashboard_range, - resolve_admin_dashboard_range_at, resolve_admin_database_table_sql_limit, - resolve_admin_editor_asset_filters, timestamp_value_to_micros, trim_preview, - validate_admin_editor_asset_cursor, validate_admin_external_api_key_query, - verify_admin_password, wallet_ledger_source_type_to_string, + is_safe_spacetime_table_name, net_admin_dashboard_consumed_mud_points, + normalize_debug_path, normalize_table_count_error, paginate_admin_editor_asset_records, + parse_admin_database_table_rows_sql_response, parse_admin_external_api_key_row, + parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response, + parse_spacetime_sql_count_response, parse_timestamp_text_to_micros, + resolve_admin_dashboard_range, resolve_admin_dashboard_range_at, + resolve_admin_database_table_sql_limit, resolve_admin_editor_asset_filters, + timestamp_value_to_micros, trim_preview, validate_admin_editor_asset_cursor, + validate_admin_external_api_key_query, verify_admin_password, + wallet_ledger_source_type_to_string, }; use axum::{ http::{Method, StatusCode}, @@ -5732,6 +5790,70 @@ mod tests { ); } + /// 消耗泥点必须按退还对冲,且每日净额非负、区间合计等于「消耗 − 退还」。 + #[test] + fn dashboard_consumption_offsets_refunds_in_the_same_day() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 40); + let mut refunded = AdminDashboardSeries::default(); + refunded.add(100, 15); + + let net = net_admin_dashboard_consumed_mud_points(&consumed, &refunded, &[100, 101]); + + assert_eq!(net.value(100), 25); + assert_eq!(net.total(), 25); + } + + #[test] + fn dashboard_consumption_refund_offsets_earlier_consumption_day() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 40); + consumed.add(101, 10); + // 精选审核返还发生在消耗之后的业务日,必须回溯抵扣仍有净额的最近一天。 + let mut refunded = AdminDashboardSeries::default(); + refunded.add(102, 30); + + let net = net_admin_dashboard_consumed_mud_points(&consumed, &refunded, &[100, 101, 102]); + + assert_eq!(net.value(100), 20); + assert_eq!(net.value(101), 0); + assert_eq!(net.value(102), 0); + assert_eq!(net.total(), 20); + } + + #[test] + fn dashboard_consumption_refund_beyond_consumption_stops_at_zero() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 6); + consumed.add(101, 4); + let mut refunded = AdminDashboardSeries::default(); + refunded.add(101, 40); + + let net = net_admin_dashboard_consumed_mud_points(&consumed, &refunded, &[100, 101]); + + assert_eq!(net.value(100), 0); + assert_eq!(net.value(101), 0); + assert_eq!(net.total(), 0); + } + + #[test] + fn dashboard_consumption_without_refunds_keeps_daily_values() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 40); + consumed.add(101, 8); + + let net = net_admin_dashboard_consumed_mud_points( + &consumed, + &AdminDashboardSeries::default(), + &[100, 101, 102], + ); + + assert_eq!(net.value(100), 40); + assert_eq!(net.value(101), 8); + assert_eq!(net.value(102), 0); + assert_eq!(net.total(), 48); + } + #[test] fn timestamp_value_to_micros_accepts_sql_shapes() { assert_eq!( diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index f9730feb4..a86e202c7 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -731,7 +731,10 @@ pub struct AdminDashboardRangePayload { #[serde(rename_all = "camelCase")] pub struct AdminDashboardMetricsPayload { pub generated_assets: u64, + /// 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 pub consumed_mud_points: u64, + /// 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 + pub refunded_mud_points: u64, pub total_registered_users: u64, pub new_registered_users: u64, pub day1_retention: AdminDashboardRetentionPayload, From 1b05a1d05ef2ffabd958d1e85be39c39f05e2008 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:10:20 +0800 Subject: [PATCH 22/22] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=85=85=E5=80=BC?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=AE=9E=E4=BB=98=E5=8F=A3=E5=BE=84=E3=80=81?= =?UTF-8?q?=E5=8F=91=E6=94=BE=E6=B3=A5=E7=82=B9=E5=88=97=E3=80=81=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=B4=AF=E8=AE=A1=E5=85=85=E5=80=BC=E4=B8=8E=E5=85=91?= =?UTF-8?q?=E6=8D=A2=E7=A0=81=E5=8D=95=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AdminRechargeOrderEntryPayload.paidAmountCents:只有 paid_at 存在的订单才有实付,未支付 / 已关闭 / 已过期固定为 0 - 充值管理列表把「金额 / 泥点」拆成「实付」「发放泥点」两列,未支付行实付显示「未支付」并附订单金额小字;退款面板「订单实付」改读同一字段 - 用户详情充值订单表新增「发放泥点」列,商品列只保留商品名,实付同样按 paidAmountCents 展示 - 用户详情新增 cumulativeRechargedCents:api-server 按 user_id 读取 profile_recharge_order,只累加 paid_at 存在的订单金额(退款不回减),单次上限 500 行,读取失败或命中上限返回 null - 用户详情身份区新增「累计充值」,读不到时显示「未知」,不用用户详情最多 20 条订单在 BFF 或前端近似重算 - 兑换码奖励单位收口为泥点:输入标签与列表列头改「奖励泥点」,单元格带「泥点」单位,避免被当成元 - 新增后端 2 条累计充值口径单测与前端 2 条用例(未支付不显示实付且发放泥点独立成列、累计充值未知态) - 同步后端架构数据契约(实付口径 / 累计充值来源与上限 / 兑换码奖励单位)与决策记录 - 验证:cargo check -p api-server;cargo test -p api-server --bin api-server admin(138 passed / 0 failed / 1 ignored);npm run admin-web:typecheck;npx vitest run apps/admin-web/src(220 passed);cargo fmt --all --check、check:encoding、check:doc-index、git diff --check 通过 --- apps/admin-web/src/api/adminApiTypes.ts | 4 + .../components/AdminUserDetailDialog.test.tsx | 29 ++++++ .../src/components/AdminUserDetailDialog.tsx | 17 +++- .../src/pages/AdminRechargeOrderPage.test.tsx | 35 ++++++++ .../src/pages/AdminRechargeOrderPage.tsx | 21 ++++- .../src/pages/AdminRedeemCodePage.tsx | 6 +- .../shared-memory/decision-log.md | 11 +++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- server-rs/crates/api-server/src/admin.rs | 89 ++++++++++++++++++- .../crates/api-server/src/admin_recharge.rs | 15 +++- .../crates/shared-contracts/src/admin.rs | 4 + 11 files changed, 219 insertions(+), 16 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 85ca7961a..e42df5be4 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -958,6 +958,8 @@ export interface AdminRechargeOrderEntryPayload { productTitle: string; productKind: string; amountCents: number; + /** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */ + paidAmountCents: number; status: string; paymentChannel: string; paidAtMicros?: number | null; @@ -997,6 +999,8 @@ export interface AdminUserDetailResponse { phoneBound: boolean; wechatBound: boolean; historicalConsumedPoints: number; + /** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */ + cumulativeRechargedCents?: number | null; canReconcileConsumption: boolean; wallet: AdminProfileWalletPayload; rechargeOrders: AdminRechargeOrderEntryPayload[]; diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx index a8ba9f380..0c14b58be 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx @@ -51,6 +51,7 @@ const detail: AdminUserDetailResponse = { phoneBound: true, wechatBound: true, historicalConsumedPoints: 1234, + cumulativeRechargedCents: 128800, canReconcileConsumption: true, wallet, rechargeOrders: [ @@ -62,6 +63,7 @@ const detail: AdminUserDetailResponse = { productTitle: '60泥点', productKind: 'points', amountCents: 600, + paidAmountCents: 600, status: 'paid', paymentChannel: 'wechat_native', paidAtMicros: 1_720_000_000_000_000, @@ -124,7 +126,11 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy(); expect(screen.getByText('历史花费')).toBeTruthy(); expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy(); + expect(screen.getByText('累计充值')).toBeTruthy(); + expect(screen.getByText('¥1288.00')).toBeTruthy(); expect(screen.getByText('order-1')).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy(); await user.keyboard('{Escape}'); await waitFor(() => @@ -133,6 +139,29 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 await waitFor(() => expect(document.activeElement).toBe(trigger)); }); +test('累计充值读取不到时展示未知,不用订单列表近似', async () => { + vi.mocked(getAdminUserDetail).mockResolvedValue({ + ...detail, + cumulativeRechargedCents: null, + }); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '查看用户信息' })); + await screen.findByText('陶泥用户'); + + expect(screen.getByText('累计充值')).toBeTruthy(); + expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe( + '未知', + ); +}); + test('只有陶泥号时按 publicUserCode 查询用户', async () => { const user = userEvent.setup(); render( diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index 8aa07d1be..66725d952 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -364,6 +364,7 @@ export function AdminUserDetailDialog({ 订单 商品 实付 + 发放泥点 退款 状态 @@ -377,11 +378,13 @@ export function AdminUserDetailDialog({ {formatMicros(order.createdAtMicros)} + {order.productTitle || order.productId} - {order.productTitle || order.productId} - 发放 {order.pointsDelta} 泥点 + {order.paidAmountCents > 0 + ? formatMoney(order.paidAmountCents) + : '未支付'} - {formatMoney(order.amountCents)} + {order.pointsDelta} 泥点 {formatMoney(order.cumulativeSuccessRefundCents)} 欠账 {order.unrecoveredPoints} 泥点 @@ -435,6 +438,14 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
登录方式
{detail.loginMethod || '-'}
+
+
累计充值
+
+ {typeof detail.cumulativeRechargedCents === 'number' + ? formatMoney(detail.cumulativeRechargedCents) + : '未知'} +
+
绑定状态
diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 81e4fcbde..79f150653 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -64,6 +64,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = { productTitle: '60泥点', productKind: 'points', amountCents: 600, + paidAmountCents: 600, status: 'paid', paymentChannel: 'wechat_native', paidAtMicros: 1_720_000_000_000_000, @@ -129,6 +130,40 @@ beforeEach(() => { ); }); +test('未支付订单不显示实付金额,发放泥点单独成列', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + orderId: 'order-pending', + status: 'pending', + paidAtMicros: null, + paidAmountCents: 0, + pointsDelta: 0, + }, + { ...baseOrder, orderId: 'order-paid' }, + ], + }); + renderPage(); + + expect( + await screen.findByRole('columnheader', { name: '实付' }), + ).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy(); + + const unpaidRow = (await screen.findByText('order-pending')).closest( + 'tr', + ) as HTMLElement; + const unpaidCells = within(unpaidRow).getAllByRole('cell'); + expect(unpaidCells[3]?.textContent).toContain('未支付'); + expect(unpaidCells[4]?.textContent).toBe('0 泥点'); + + const paidRow = screen.getByText('order-paid').closest('tr') as HTMLElement; + const paidCells = within(paidRow).getAllByRole('cell'); + expect(paidCells[3]?.textContent).toBe('¥6.00'); + expect(paidCells[4]?.textContent).toBe('60 泥点'); +}); + test('充值订单查询传递全部筛选字段', async () => { const user = userEvent.setup(); renderPage(); diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index d1f4848b0..04188469c 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -658,7 +658,8 @@ export function AdminRechargeOrderPage({ 用户 订单 支付 - 金额 / 泥点 + 实付 + 发放泥点 退款与追回 钱包 状态 @@ -715,7 +716,7 @@ export function AdminRechargeOrderPage({
- {formatMoney(order.amountCents)} - 发放 {order.pointsDelta} 泥点 + {formatOrderPaidAmount(order)} + {order.paidAmountCents > 0 ? null : ( + 订单 {formatMoney(order.amountCents)} + )} + + + {order.pointsDelta} 泥点 累计 {formatMoney(order.cumulativeSuccessRefundCents)} @@ -1312,6 +1318,13 @@ function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } +/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */ +function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) { + return order.paidAmountCents > 0 + ? formatMoney(order.paidAmountCents) + : '未支付'; +} + function formatCentsInput(cents: number) { return (cents / 100).toFixed(2); } diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index 0e745a308..1519bb867 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -217,7 +217,7 @@ export function AdminRedeemCodePage({