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] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8C=BA=E6=94=AF=E6=8C=81=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=20@=E6=98=BE=E7=A4=BA=E5=90=8D=20/=20$?= =?UTF-8?q?=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 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。