+
- 陶泥儿
+ {APP_NAME}
GameAgent
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..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
@@ -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,
@@ -60,11 +64,18 @@ import {
chatReferenceKey,
chatReferenceMentionToken,
chatReferenceToContentPart,
+ contentPartText,
dedupeChatReferences,
joinMentionText,
} 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;
@@ -226,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,其余文本逐字保留。
*
@@ -256,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);
@@ -286,8 +313,81 @@ 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));
+ });
+}
+
+/**
+ * 取当前可用的选区:选区缺失、或指向已被重建掉的节点时(跨会话恢复草稿后就是这种),
+ * 统一回落到草稿末尾,避免把内容插到一个已经不存在的位置。取不到时返回 `null`。
+ */
+function $selectionOrRootEnd() {
+ let selection = $getSelection();
+ if (
+ !$isRangeSelection(selection) ||
+ !selection.anchor.getNode().isAttached()
+ ) {
+ $getRoot().selectEnd();
+ selection = $getSelection();
+ }
+ return $isRangeSelection(selection) ? selection : null;
+}
+
+/**
+ * 粘贴插入:在光标处就地插入 content 对应的节点,正文其余部分逐字不动。
+ *
+ * 与 `applyContentToRoot`(整根替换,供初始草稿与润色回写使用)的区别只在替换范围:
+ * 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成
+ * chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。
+ *
+ * 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本;连 provider 的
+ * `mentionToken` 都答不出来时退到 `contentPartText` 的通用文本形态。两条兜底合起来保证
+ * 「粘贴进来的内容一个字符都不会凭空消失」,这个分支不存在什么都不插的出路。
+ *
+ * 返回「这次到底插进去没有」:调用方据此决定要不要接管这次粘贴,插入为空时必须放行
+ * 编辑器的默认粘贴,否则这段文字两边都不管。
+ */
+function $insertContentAtSelection(
+ content: readonly DirectCodexUserContentPart[],
+ providers: readonly ReferenceProvider[],
+): boolean {
+ // 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。
+ 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();
+ inserted = true;
+ }
+ if (line) {
+ $selectionOrRootEnd()?.insertText(line);
+ inserted = true;
+ }
+ });
+ return;
+ }
+ const reference = referenceFromPart(providers, part);
+ if (reference) {
+ $selectionOrRootEnd()?.insertNodes([
+ $createResourceReferenceNode(reference),
+ ]);
+ inserted = true;
+ return;
+ }
+ // 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。
+ const token = mentionTokenOrText(providers, part);
+ if (token) {
+ $selectionOrRootEnd()?.insertText(token);
+ inserted = true;
}
});
+ return inserted;
}
/**
@@ -361,24 +461,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();
},
@@ -390,19 +480,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();
},
@@ -522,6 +604,53 @@ 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;
+ // 只认「此刻就绪」的候选:数据还没到的种类本次按文本保留,输入区不等待也不补读。
+ const references = providers.flatMap(
+ (provider) => provider.lookup?.() ?? [],
+ );
+ const content = buildContentFromPastedText(text, references);
+ if (!content) return false;
+ // 先真的插进去,再决定接管这次粘贴:插入为空(取不到选区)时必须放行默认粘贴,
+ // 否则这段文字既没进我们的插入、又被 preventDefault 挡掉了默认导入,静默消失。
+ // 编辑器已经在一次更新里时 `editor.update` 会把回调排队,这时 `ranSync` 仍是 false,
+ // 按原口径先接管,等队列里的那次插入落地。
+ let ranSync = false;
+ let inserted = false;
+ editor.update(
+ () => {
+ ranSync = true;
+ inserted = $insertContentAtSelection(content, providersRef.current);
+ },
+ // 与编辑器默认粘贴同一口径:粘贴是它自己的一条撤销记录。
+ { tag: PASTE_TAG },
+ );
+ if (ranSync && !inserted) return false;
+ event.preventDefault();
+ return true;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ );
+ }, [editor]);
+
// —— C8 AI 润色与发送前提醒 ——
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
@@ -796,16 +925,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/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/resourceReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts
index cf7c9b5be..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,12 +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),
+ // 精确查找用的全量候选:与菜单同一份「可提及」清单,只去掉模糊过滤与截断。
+ 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 e3c34da47..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,12 +47,15 @@ function loadSkillCatalog(): Promise {
item.enabled &&
item.status === 'enabled',
)
- .map((item) => ({ name: item.name })),
+ .map((item) => ({ name: normalizeMentionName(item.name) })),
),
]).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,13 +64,29 @@ function matchesSkillQuery(skill: SkillCatalogItem, query: string) {
);
}
+/** 目录项 → 引用:同名只留第一条(与 `fuzzyLookup` 同一份去重口径)。 */
+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)。
*
* 与资源 provider 不同,Skill 候选是**异步**的应用级读取,所以它必须是一份 React 状态:
* 用户敲出 `$` 打开候选菜单时(`onMenuQueryChange` 收到非 `null`,由输入区在 effect 里回调)
* 发起读取,结果到了之后宿主重渲染,输入区随之拿到新的候选。
- * `match` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。
+ * `fuzzyLookup` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。
* 读取本身不进输入区,只有宿主才知道这条路该不该存在——
* 目前只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill。
*/
@@ -96,31 +118,26 @@ export function useSkillReferenceProvider(): ReferenceProvider {
onMenuQueryChange: (query) => {
if (query !== null) ensureCatalog();
},
- 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,
- }),
- );
+ // 精确查找:目录还没就绪(用户还没敲过 `$`)时返回空数组,本次 `$名称` 逐字保留。
+ lookup: () => skillReferences(skills),
+ fuzzyLookup: (query) => {
+ 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'
- ? { 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/reference-source/types.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts
index ca138d75f..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,12 +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[];
+ /**
+ * 精确查找用的全量候选:某一刻 provider 真正能解析出的所有引用,不做模糊过滤、不截断。
+ *
+ * 与 `fuzzyLookup` 的区别只有「模糊与截断」,两者共用同一份候选来源。没有触发符的静默
+ * provider 不实现。
+ *
+ * **同样是纯函数**:输入区在粘贴事件里同步调用它;只返回已就绪的快照,不发起任何读取。
+ * 数据还没到时返回空数组(或省略不实现),解析不出的 token 逐字保留——例如 Skill 目录的就绪
+ * 时机仍是用户第一次敲出 `$`,冷启动时粘贴 `$名称` 就是字面文本,不会被猜成别的引用。
+ */
+ lookup?: () => ChatReference[];
/**
* 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在
* 带触发符的 provider 上调用**。
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..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
@@ -154,6 +154,26 @@ export function resourceLabelResolver(
};
}
+/**
+ * 引用名(资源显示名 / Skill 名 / 附件名)的空白不变量:`@显示名`、`$名称`、`@附件名` 里
+ * 不允许出现空白,内部空白统一折成 `-`。
+ *
+ * 引用的名字同时就是它在正文里的 token,而 token 的边界规则是「前后为空白或行首行尾」
+ * (`isMentionTokenBoundary`)。名字里一旦有空白,`@hero v2` 在反解析时会被切成 `@hero` +
+ * 文本 `v2`:短名字抢先命中,真正的引用反而变成补在末尾的孤儿。空白折成 `-` 之后 token 自带
+ * 边界,`@hero` 不会再命中 `@hero-v2`(后一个字符是 `-`,不是空白)。
+ *
+ * 这里只做归一化、不做 `resourceId` 之类的兜底:兜底属于名字的来源侧(例如
+ * `resourceDisplayName` 在文件名词干为空时回退 `asset.id`),归一化本身保持是个纯函数。
+ *
+ * 归一化不保证名字唯一:`hero v2` 与 `hero-v2` 会折成同一个 token,两条引用因此在候选菜单里
+ * 显示同一个标签。归一化后的碰撞由 `buildContentFromPastedText` 的「同名多候选一律按文本保留」
+ * 兜住(不认错,但两者都成不了 chip);候选菜单侧不做冲突检测,标签重复是这条取舍的可见残留。
+ */
+export function normalizeMentionName(value: string) {
+ return value.trim().replace(/\s+/gu, '-');
+}
+
/**
* canonical content → 可读文本;每个引用 part 经 `tokenOf` 展开,文本 part 逐字保留。
*
@@ -207,13 +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 `@${part.name}`;
- if (part.type === 'agc_skill_reference') return `$${part.name}`;
- if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
- return `@${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 合法。 */
@@ -290,8 +341,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}`;
}
@@ -417,6 +472,74 @@ 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(粘贴侧的唯一解析口径)。
+ *
+ * 候选来自调用方传入的引用清单(输入区给的是 `ReferenceProvider.lookup()` 此刻就绪的全量
+ * 候选),token 就是
+ * `chatReferenceMentionToken`——与显示口径逐字同一个字符串,所以「从气泡复制再粘贴」不需要
+ * 任何兼容别名:`@显示名` / `$名称` 认得出,`@hero.png`、resourceId、大小写变体一律不认。
+ *
+ * 三条保守规则:
+ *
+ * - 同名多候选(同一个 token 对应多条引用身份)一律按文本保留——宁可不成 chip,也不能认错引用。
+ * - 退化 token(只有触发符的裸 `@` / `$`,即引用名为空)不参与解析:边界规则会把正文里
+ * 任何一处裸 `@` 当成它,等于把无关文字错认成一条引用。
+ * - 只把正文里真的出现过的 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) {
+ // 名字为空的引用只会给出一个裸触发符:它能在正文里匹配到任何一处 `@` / `$`,
+ // 认下来就是错认。空名字是上游数据问题,这里按「宁可不成 chip」的同一口径跳过。
+ if (token.length <= 1) continue;
+ 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,不另立第二份事实源。
@@ -540,9 +663,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 fileName.replace(/\.[^.]+$/u, '').trim() || asset.id;
+ const stem = fileName.replace(/\.[^.]+$/u, '');
+ return normalizeMentionName(stem || asset.id);
}
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/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/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts
index d7e6bf23c..6cfb4cd75 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,6 +127,45 @@ describe('资源引用 provider', () => {
);
});
+ it('`lookup` 给精确查找用的全量清单:与菜单同一份可提及素材,但不受模糊过滤与截断影响', () => {
+ 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.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
+ .lookup?.()
+ .some(
+ (item) => 'resourceId' in item && item.resourceId === 'asset-orphan',
+ ),
+ ).toBe(false);
+ // 菜单就是「同一份候选 + query 过滤 + 截断」,所以截断后的前缀逐字一致。
+ expect(scoped.lookup?.().slice(0, 8)).toEqual(scoped.fuzzyLookup?.(''));
+ });
+
+ it('清单为空时 `lookup` 是空数组:粘贴不会把任何 token 当成引用', () => {
+ expect(createResourceReferenceProvider({ assets: [] }).lookup?.()).toEqual(
+ [],
+ );
+ });
+
it('`toReference` 只认资源 part:资产已删除时不合成引用', () => {
expect(provider.toReference({ type: 'input_text', text: '看素材' })).toBe(
null,
@@ -147,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({
@@ -191,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', () => {
@@ -305,7 +360,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' }];
@@ -319,9 +374,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();
@@ -333,7 +388,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);
@@ -342,12 +397,81 @@ 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('目录里的名字带空白时折成 `-`:候选与 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') {
+ 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.lookup?.()).toEqual([]);
+ });
+ expect(invoke).not.toHaveBeenCalled();
+
+ // 菜单第一次打开才读目录,读回之后精确查找立刻可用。
+ act(() => {
+ result.current.onMenuQueryChange?.('');
+ });
+ await waitFor(() => {
+ expect(result.current.lookup?.()).toHaveLength(1);
+ });
+ expect(result.current.lookup?.().map(chatReferenceMentionToken)).toEqual([
+ '$agc-test-skill',
+ ]);
+ });
+
it('内置目录与已启用客户端 Skill 合并后按名字去重,并截断到 8 条', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
@@ -394,17 +518,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 () => {
@@ -432,7 +556,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 37a5bc8a7..723689942 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,
@@ -67,6 +68,65 @@ 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');
+
+/**
+ * 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,
@@ -231,6 +291,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 +434,213 @@ 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();
+ // provider 认得出这个 token(lookup 有候选),但插入那一刻已经解析不出引用(例如资产刚被删)。
+ const ghostProvider: ReferenceProvider = {
+ trigger: '@',
+ 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();
+ // provider 契约被破坏:lookup 有候选,但 toReference 与 mentionToken 都答不出来。
+ // 这时必须落一段正常文本(资源退回 resourceId),粘贴进来的字不许凭空消失。
+ const brokenProvider: ReferenceProvider = {
+ trigger: '@',
+ 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(
+ ,
+ );
+
+ // 项目里没有名为 `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(
+ ,
+ );
+
+ // 冷目录:精确查找没有候选,`$名称` 逐字保留,也不替用户去读目录。
+ pasteComposerText('用 $agc-test-skill 出图');
+ await waitFor(() => {
+ expect(draftText(ref.current?.getDraft())).toBe(
+ '用 $agc-test-skill 出图',
+ );
+ });
+ expect(invoke).not.toHaveBeenCalled();
+
+ // 用户敲出 `$`(菜单懒加载)之后目录才就绪,此后粘贴才重建 chip。
+ act(() => {
+ ref.current?.clear();
+ });
+ insertComposerText('$');
+ await waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
+ });
+ 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',
@@ -553,6 +862,67 @@ 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: '@',
+ 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/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts
index ed38f7d4a..12c1c1abb 100644
--- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts
+++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts
@@ -1,12 +1,21 @@
import { describe, expect, it } from 'vitest';
+import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
+ buildContentFromPastedText,
type ChatComposerDraft,
chatComposerDraftToDirectCodexUserItem,
+ type ChatReference,
+ chatReferenceMentionToken,
+ contentPartText,
directCodexContentToPromptText,
hasMeaningfulDirectCodexContent,
+ normalizeMentionName,
+ resourceDisplayName,
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 +117,215 @@ 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('引用名内部空白折成 `-`:资源显示名 / 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('整名就是扩展名(`.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('退化 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('单 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: '看 ' },
+ { 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..47cadf7df 100644
--- a/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md
+++ b/docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md
@@ -16,6 +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 的两个查询能力按「模糊 / 精确」分开命名,各自说清自己的语义:
+
+- `fuzzyLookup(query)`:候选菜单那条路——按 query 做包含匹配、大小写不敏感,并在 provider 内部截断到候选上限。名字写明它是模糊的,避免被拿去反查 token。
+- `lookup()`:精确查找那条路——某一刻 provider 真正能解析出的全部引用,不做模糊过滤、不截断,与菜单共用同一份候选来源;仍是纯函数,由输入区在粘贴事件里同步调用,数据没到就是空数组。
+
+这不推翻本 ADR 的懒加载结论:`onMenuQueryChange` 仍是唯一的懒加载入口,粘贴解析**只用此刻就绪的候选**,不等待、不补读。Skill 目录因此还是「用户第一次敲出 `$` 才读」——冷启动时粘贴 `$名称` 就按字面文本保留(看得见、不是猜错),不为了粘贴去提前读盘。
+
+附件与运行画面区域仍是静默 provider:它们没有候选,所以粘贴解析不认 `@附件名` / `@区域标签`,这两类 token 粘贴时逐字保留(附件与运行区域的身份来自文件与 run,纯文本重建不出来)。
+
+歧义口径:同一个 token 对应多条引用身份(同名素材)时一律按文本保留;解析只认显示名逐字一致(不认扩展名、resourceId、大小写变体),未命中的 token 与其余文字逐字保留。
+
+(本次把上一条同名决策里的 `match` / `candidates` 改名为 `fuzzyLookup` / `lookup`,语义不变;旧名不再保留。)
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` 包的显示名保持一致。
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..c90010981
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md
@@ -0,0 +1,63 @@
+# 引用粘贴解析实施计划
+
+对应:[引用候选由宿主注入](../../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 目录冷启动不阻塞粘贴:`lookup()` 是纯函数,目录没到就是空数组——冷启动时粘贴 `$名称` 保留为文本(不等待、不补读),用户敲过一次 `$` 后即可解析。
+
+## 流程判定
+
+本次是共享组件的一处行为增量 + provider 契约加两个可选能力,不动 schema、不动公开 API/DTO、不动后端;按轻量流程只建本实施计划,不新建主规范与里程碑规范(与上一步重构同一判定)。
+
+## 提交切分
+
+1. **反解析口径**:`resourceReferences.ts` 新增 `buildContentFromPastedText(text, references)`(粘贴侧唯一反解析;与 `buildContentFromTextTokens` 同一套边界规则),配规则矩阵单测。
+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` 与决策记录。
+
+## 验证
+
+定向:`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 的 `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` 兜底这一条按评审校正:词干为空(`.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)只在解析侧兜住、候选菜单不提示冲突,这一条涉及「哪些素材能被 @ 到」的产品取舍,未改代码,留给下一轮决定。
+- 润色回写与粘贴共用同一条落点兜底:新增 `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 d8175423c..265ec8690 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -1,5 +1,29 @@
# 决策记录
+## 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` 一并去掉。
+- 校正(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` 不再解析,重试 / 润色回填时那条引用会退化成末尾孤儿(内容不丢、位置可能不对)。
+- 验证方式:`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 的形态)。
+- 决策(口径):新增粘贴侧唯一反解析 `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 契约按「模糊 / 精确」分两个口):上一条里的 `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 的 `fuzzyLookup` / `lookup` 用例、输入区集成用例(真 Lexical `paste` 事件 → 芯片、未命中等价于默认粘贴、Skill 冷启动保持字面且敲过 `$` 后可解析);另跑 `npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。
+
## 2026-09-23 最近项目检查失败不进终态
- 背景:最近项目列表把一次性的目录检查失败当成终态——5s 超时被吞成 `null`,增量投影又把上一轮的 `null` 原样搬进下一轮,且没有重试或重查入口。AGC 一次 IPC 停顿之后,整张列表会永久停在「检查失败 + 待识别」,首页「最近项目」同时因 `canOpen` 过滤变空,只能重启客户端恢复(issue #490)。
diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md
index 940c49ff5..fcff22137 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 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。
@@ -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,不再出现退化成正文文本的 `$` 误导入口(已裁决的缺陷修复)。
@@ -31,6 +31,29 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
输入校验按整条消息判断是否有内容:每个 `input_text` 片段都允许是空字符串、空格或换行,不逐片段拒绝,也不合并、删除或改写片段;原始文字、分段和 `content[]` 顺序保持不变。整条消息必须至少包含一段非空白文字,或至少一个非文本 part(素材引用 / 运行画面引用 / Skill 引用 / 附件引用),否则返回“聊天内容不能为空”。各类引用继续执行原有字段、数量、manifest 归属和路径安全校验;即使消息同时带有正文,非法引用也必须拒绝,不能由正文绕过。
+## 粘贴解析(2026-09-22)
+
+把含引用 token 的纯文本粘进输入区时,可以逐字命中的 token 会原位变回引用芯片:
+
+- 只认与显示口径逐字一致的 token:`@显示名`(素材)与 `$名称`(Skill)。`@hero.png`、资源 ID、大小写变体、全角 `@` 都不解析;token 前后必须是行首 / 行尾或空白(与出站文本「token 前后各留一个空白」自洽),所以从用户消息气泡复制出来的那段文字粘回来会重建同一批芯片。
+- 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、也不猜文件名或路径。
+- 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。
+- 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。
+- 只有真的解析出引用、并且这一整段真的插进了正文时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。取不到选区时插入为空,这时也放行默认粘贴,粘贴的文字不会「两边都不管」。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。
+- part 落进正文时解析不出引用也不留空:先退回 provider 的 `mentionToken`(`@显示名` / `$名称`),provider 连 token 都答不出来(契约被破坏)时退到 `contentPartText` 的通用文本形态——资源拿不到显示名就落 `@resourceId`,Skill / 附件 / 运行区域各落自己的名字或标签。粘贴就地插入与润色回写的整根替换共用这一条兜底链(`mentionTokenOrText`),所以「认不出的引用走文本、绝不静默丢」没有例外分支。
+- 候选来源按「模糊 / 精确」分两个口:菜单走 `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`(后一个字符是 `-`)。
+- 词干为空的素材名回退 `asset.id`:整名就是扩展名时(`.env` / `.gitignore`)文件名去掉扩展名会得到空串,空名字会让 token 退化成只有触发符的裸 `@`——候选菜单里是一枚空芯片,粘贴解析还会拿它认领正文里任何一处裸 `@`。所以 `resourceDisplayName()` 在这一种情况下回退 `asset.id`,回退值同样过一遍 `normalizeMentionName`;`normalizeMentionName` 自身仍然只做归一化、不做兜底。
+- 归一化不保证名字唯一:`hero v2` 与 `hero-v2` 会折成同一个 token,候选菜单里两条候选显示同一个标签,从气泡复制出来的那段文字也重建不成芯片(解析侧按「同名多候选一律按文本保留」处理,不认错)。菜单侧目前不做冲突检测,这是这条不变量的已知残留。
+- 已存在的老文本(本次改动前生成的回合 prompt、历史气泡)里的 `@hero v2` 不再解析——粘贴时按字面保留,重试 / 润色回填时该引用会退化成末尾孤儿。内容不丢,位置可能不对。
+
## 拖拽引用(2026-09-21)
除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。