diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx
index f7f4a6ef6..a81c44304 100644
--- a/apps/ai-game-creator-shell/src/App.tsx
+++ b/apps/ai-game-creator-shell/src/App.tsx
@@ -550,6 +550,19 @@ type ExecuteChatAgentReplyInput = {
references?: ChatReference[];
};
+/**
+ * `conversation.write` 策略确认后重跑同一轮直连回合的入参。
+ *
+ * 确认弹窗会在**同一轮输入**上二次进入 `executeChatAgentReply`。这里从首轮的入参整体派生,
+ * 而不是手写一遍字段:一旦重跑时漏掉某一项(历史缺陷就是漏了 `references`),
+ * 用户在确认之后拿到的就不是他原本提交的那一轮——`@` 引用会被静默丢掉。
+ */
+export function directCodexPolicyRetryInput(
+ input: ExecuteChatAgentReplyInput,
+): ExecuteChatAgentReplyInput {
+ return { ...input, directPolicyChecked: true };
+}
+
export function App({
initialProjectPath: initialProjectPathOverride = '',
initialProjectManifest,
@@ -6090,6 +6103,14 @@ export function App({
!directPolicyChecked &&
projectConversationWriteConfirmedRef.current !== directProjectPath
) {
+ // 首轮的入参整体留一份给「确认后重跑」用,不在回调里重列字段。
+ const policyRetryInput = directCodexPolicyRetryInput({
+ prompt,
+ clientTurnId,
+ creationType,
+ attachments,
+ references,
+ });
try {
const policyPaused = await queueProjectPolicyConfirmationIfNeeded(
directInvoke,
@@ -6100,13 +6121,7 @@ export function App({
() => {
projectConversationWriteConfirmedRef.current =
directProjectPath;
- void executeChatAgentReply({
- prompt,
- clientTurnId,
- creationType,
- attachments,
- directPolicyChecked: true,
- });
+ void executeChatAgentReply(policyRetryInput);
},
);
if (policyPaused) {
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
index eeb91795a..bda55ddca 100644
--- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
+++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
@@ -208,9 +208,21 @@ export function WorkspaceLauncherShell({
heldRevision: number;
snapshotRevision: number;
}) => {
+ /**
+ * 只改「这一次恢复对应」的那条提示。
+ *
+ * 恢复在飞期间可能又到了一次拒收:`applyManifestSnapshot` 会无条件把提示换成新的
+ * 那一对 revision(同项目的第二次恢复被跳过),此时把本次恢复的 `recovered` /
+ * `unresolved` 盖上去,就是在报告一件从未重读过的事实。
+ */
const patchStage = (stage: ProjectManifestMergeRecoveryStage) =>
setManifestMergeNotice((current) =>
- current ? { ...current, stage } : current,
+ current &&
+ current.projectId === input.projectId &&
+ current.heldRevision === input.heldRevision &&
+ current.snapshotRevision === input.snapshotRevision
+ ? { ...current, stage }
+ : current,
);
const invoke = resolveTauriInvoke();
if (!invoke) {
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts b/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts
index a9c37a2fc..bc77a6861 100644
--- a/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts
+++ b/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts
@@ -75,9 +75,10 @@ export async function resolveSessionPreviewOnProjectOpen({
{ projectPath: trimmedProjectPath },
);
} catch {
- // 读不到 registry 就按"没有在跑"处理:宁可让用户点一下「运行」,也不要进一个
- // 打不开的运行界面。
- status = null;
+ // 读不到 registry 不等于"确认没有在跑":`stop_local_game_preview` 会停掉
+ // 真在跑的活体预览,读失败(权限拒绝 / 瞬时 IPC 错误)时不能顺手当清理动作。
+ // 只按"不带预览进入"处理,让用户自己点「运行」。
+ return { sessionPreview: null, manifestPreviewPatch: null };
}
const sessionPreview = livePreviewFromStatus(status);
if (sessionPreview) {
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
index a94b1f532..3955eeca6 100644
--- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
+++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
@@ -117,6 +117,15 @@ export function useHomeProjectCreation({
(state) => state.reset,
);
const approvedGddStartInFlightRef = useRef(false);
+ /**
+ * 进项目流程的代次。
+ *
+ * `enterProjectDevelopment` 在写项目上下文之前会 await 一次会话预览核验;项目页的两条
+ * 入口(打开 / 在项目页新建)由 `projectActionRef` 串行化,但首页新建那条没有同样的闸门,
+ * 可能和另一次进项目重叠。慢的那一次若后到,会把工作区绑到旧项目上,所以每次进项目自增
+ * 一次代次,await 回来时已经不是最新代次的结果整体丢弃。
+ */
+ const projectEntryTokenRef = useRef(0);
function validateProjectPath(nextProjectPath: string) {
const trimmedProjectPath = nextProjectPath.trim();
@@ -177,6 +186,7 @@ export function useHomeProjectCreation({
}
async function enterProjectDevelopment(context: LauncherProjectContext) {
+ const entryToken = (projectEntryTokenRef.current += 1);
/**
* 会话预览只认"内存 registry 里真的还在跑"的那一个(见
* `resolveSessionPreviewOnProjectOpen`)。项目 manifest 里的 `preview` 是**落盘记录**、
@@ -188,6 +198,11 @@ export function useHomeProjectCreation({
projectPath: context.projectPath,
recordedPreview: context.manifest.preview ?? null,
});
+ if (entryToken !== projectEntryTokenRef.current) {
+ // 更晚的一次进项目已经接管工作区:这一次的结果(预览与项目上下文)全部丢弃,
+ // 否则慢请求后到会把新项目覆盖回旧项目。
+ return;
+ }
setCurrentProjectContext(
session.manifestPreviewPatch
? {
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ChatPromptPolishReminder.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ChatPromptPolishReminder.tsx
index 0492646ea..75a4e00e5 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/ChatPromptPolishReminder.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/ChatPromptPolishReminder.tsx
@@ -47,7 +47,12 @@ export function ChatPromptPolishReminder({
aria-modal="true"
className="launcher-dialog chat-prompt-polish-reminder"
role="dialog"
- onKeyDown={(event) => closeDialogOnEscape(event, onClose)}
+ onKeyDown={(event) => {
+ // 在飞期间与「关闭」按钮、遮罩点击保持一致:不许把面板关掉,
+ // 否则润色回来还会在用户已经取消之后继续提交。
+ if (busy) return;
+ closeDialogOnEscape(event, onClose);
+ }}
>
发送前提醒
{error ? (
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx
index c121c6449..59faebc5e 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx
@@ -92,6 +92,20 @@ function sanitizeInspectIdentifier(value: unknown, maxChars: number) {
return /^[A-Za-z0-9._-]+$/u.test(sanitized) ? sanitized : undefined;
}
+/**
+ * 资源 id 的净化:比元素标识宽松。
+ *
+ * 资源 id 里允许 `:`(画布规范形态 `local-asset:`)与可打印非 ASCII
+ * (`persisted-角色草图.png` 这类落盘文件名派生的 id),用 `sanitizeInspectIdentifier`
+ * 会把它们整条过滤掉,运行画面引用就丢了素材关联。仍然拒绝空白与路径分隔符:
+ * `../secret` / `a/b` 不是资源 id,带上只会让下游按 manifest 校验时报「引用的素材已变化」。
+ */
+function sanitizeInspectResourceId(value: unknown, maxChars: number) {
+ const sanitized = sanitizeInspectText(value, maxChars);
+ if (!sanitized) return undefined;
+ return /[\s/\\]/u.test(sanitized) ? undefined : sanitized;
+}
+
function sanitizeInspectSourcePath(value: unknown) {
const sanitized = sanitizeInspectText(value, 512);
if (!sanitized) return undefined;
@@ -126,7 +140,7 @@ export function parseLocalGamePreviewInspectMessage(
if (!label) return null;
const resourceIds = Array.isArray(selection.resourceIds)
? selection.resourceIds
- .map((value) => sanitizeInspectIdentifier(value, 200))
+ .map((value) => sanitizeInspectResourceId(value, 200))
.filter((value): value is string => Boolean(value))
.slice(0, 32)
: [];
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 5a9d59a71..1971660b5 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
@@ -208,28 +208,126 @@ function collectDraftParts(
}
}
-function readDraftFromEditorState(editorState: EditorState): ChatComposerDraft {
- return editorState.read(() => {
- const textParts: string[] = [];
- const references: ChatReference[] = [];
- collectDraftParts($getRoot(), textParts, references);
- return {
- text: textParts.join('').trim(),
- references: dedupeChatReferences(references),
- };
- });
+/** 按编辑器自己的口径读草稿;只能在 Lexical 的读 / 更新上下文里调用。 */
+function readDraftFromNodes(): ChatComposerDraft {
+ const textParts: string[] = [];
+ const references: ChatReference[] = [];
+ collectDraftParts($getRoot(), textParts, references);
+ return {
+ text: textParts.join('').trim(),
+ references: dedupeChatReferences(references),
+ };
}
-function appendTextParagraphs(
- paragraphs: string[],
- append: (nodes: LexicalNode[]) => void,
-) {
- paragraphs.forEach((paragraphText) => {
- const paragraph = $createParagraphNode();
- if (paragraphText) {
- paragraph.append($createTextNode(paragraphText));
+function readDraftFromEditorState(editorState: EditorState): ChatComposerDraft {
+ return editorState.read(readDraftFromNodes);
+}
+
+type DraftBuildSegment =
+ | { kind: 'text'; text: string }
+ | { kind: 'reference'; reference: ChatReference };
+
+function isDraftMentionBoundary(character: string | undefined) {
+ return character === undefined || character === '' || /\s/u.test(character);
+}
+
+/**
+ * 在 `from` 之后找 `token` 的整标记位置:前后必须是行首 / 行尾或空白,
+ * 避免 `@hero` 命中 `@hero2` 的前缀。
+ */
+function findDraftMentionToken(line: string, token: string, from: number) {
+ let index = line.indexOf(token, from);
+ while (index >= 0) {
+ if (
+ isDraftMentionBoundary(index === 0 ? undefined : line[index - 1]) &&
+ isDraftMentionBoundary(line[index + token.length])
+ ) {
+ return index;
}
- append([paragraph]);
+ index = line.indexOf(token, index + 1);
+ }
+ return -1;
+}
+
+/**
+ * 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
+ *
+ * 不变量:切完写进编辑器后,编辑器读回来的草稿必须与 props 等价。`collectDraftParts`
+ * 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
+ * 不能另起一段堆在末尾——否则读回来的文本每重建一轮就多一段 `@显示名`,
+ * `sameDraft` 永远判定为不相等,编辑器就会一轮轮重建、文本一轮轮变长。
+ *
+ * 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾:
+ * 引用不会凭空消失,而且只补一次——下一轮 props 里就带上这个 token,重建随即收敛。
+ */
+function buildDraftSegments(
+ value: string,
+ references: ChatReference[],
+): DraftBuildSegment[][] {
+ const pending = references.map((reference) => ({
+ reference,
+ token: `@${reference.label}`,
+ used: false,
+ }));
+ const lines: DraftBuildSegment[][] = [];
+ for (const line of value.split(/\r?\n/u)) {
+ const segments: DraftBuildSegment[] = [];
+ let cursor = 0;
+ for (;;) {
+ const match = pending
+ .filter((item) => !item.used)
+ .map((item) => ({
+ item,
+ index: findDraftMentionToken(line, item.token, cursor),
+ }))
+ .filter((candidate) => candidate.index >= 0)
+ .sort((left, right) => left.index - right.index)
+ .at(0);
+ if (!match) break;
+ if (match.index > cursor) {
+ segments.push({ kind: 'text', text: line.slice(cursor, match.index) });
+ }
+ match.item.used = true;
+ segments.push({ kind: 'reference', reference: match.item.reference });
+ cursor = match.index + match.item.token.length;
+ }
+ if (cursor < line.length) {
+ segments.push({ kind: 'text', text: line.slice(cursor) });
+ }
+ lines.push(segments);
+ }
+ const orphans = pending
+ .filter((item) => !item.used)
+ .map(
+ (item): DraftBuildSegment => ({
+ kind: 'reference',
+ reference: item.reference,
+ }),
+ );
+ if (orphans.length > 0) {
+ const lastLine = lines.at(-1);
+ if (!lastLine) return [orphans];
+ if (lastLine.length > 0) {
+ lastLine.push({ kind: 'text', text: ' ' });
+ }
+ lastLine.push(...orphans);
+ }
+ return lines;
+}
+
+function applyDraftToRoot(value: string, references: ChatReference[]) {
+ const root = $getRoot();
+ root.clear();
+ buildDraftSegments(value, references).forEach((segments) => {
+ const paragraph = $createParagraphNode();
+ segments.forEach((segment) => {
+ if (segment.kind === 'text') {
+ if (segment.text) paragraph.append($createTextNode(segment.text));
+ return;
+ }
+ paragraph.append($createResourceReferenceNode(segment.reference));
+ });
+ root.append(paragraph);
});
}
@@ -265,6 +363,36 @@ function mentionableAssetReferences(
.map((asset) => resourceReferenceFromAsset(asset, source));
}
+/**
+ * 版本列表的内容签名:「当前版本素材」只由版本 id、顺序与绑定资源决定,
+ * 按签名取依赖才能跳过与版本无关的渲染。
+ */
+function iterationsSignature(
+ versions: readonly GameIterationVersion[] | undefined,
+) {
+ return (versions ?? [])
+ .map(
+ (version) =>
+ `${version.versionId}\u0002${version.resourceBindings
+ .map((binding) => binding.resourceId)
+ .join(',')}`,
+ )
+ .join('\u0001');
+}
+
+/** `@` 面板空态文案:先分「这个范围本来就没有素材」和「筛完没有命中」两件事。 */
+function pickerEmptyMessage(
+ scopeReferenceCount: number,
+ scope: ResourceReferenceScope,
+) {
+ if (scopeReferenceCount > 0) {
+ return '没有匹配的素材';
+ }
+ return scope === 'current-version'
+ ? '当前版本还没有绑定素材'
+ : '当前项目还没有已登记素材';
+}
+
/**
* 资源改名后把编辑区里已有的引用 chip 刷成 manifest 的最新显示名。
* 只改写仍能找到对应资产的引用;已删除资源保持原引用,不合成资源卡。
@@ -321,6 +449,16 @@ function ResourceReferenceEditor({
});
const [query, setQuery] = useState(null);
const [pickerOpen, setPickerOpen] = useState(false);
+ // Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
+ // query / pickerOpen 变化反复重注册 HIGH 优先级命令。
+ const mentionMenuOpenRef = useRef(false);
+ const pickerVisibleRef = useRef(false);
+ useEffect(() => {
+ mentionMenuOpenRef.current = query !== null;
+ }, [query]);
+ useEffect(() => {
+ pickerVisibleRef.current = pickerOpen;
+ }, [pickerOpen]);
const [pickerScopeStates, setPickerScopeStates] = useState<
Record
>(createResourcePickerScopeStates);
@@ -333,9 +471,13 @@ function ResourceReferenceEditor({
} | null>(null);
const rootRef = useRef(null);
+ const assetsContentSignature = assetsSignature(assets);
+ const versionsContentSignature = iterationsSignature(versions);
const assetReferences = useMemo(
() => mentionableAssetReferences(assets, 'asset-picker'),
- [assets],
+ // 依赖内容签名:调用方每次渲染都会重建 assets 数组,内容不变时没必要重算。
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [assetsContentSignature],
);
const currentVersionAssetReferences = useMemo(
() =>
@@ -343,7 +485,9 @@ function ResourceReferenceEditor({
currentIterationVersionAssets(assets, versions, activeVersionId),
'version-asset',
),
- [activeVersionId, assets, versions],
+ // 同上:versions 数组身份同样每次渲染都换,按内容签名取依赖才真的能跳过。
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [activeVersionId, assetsContentSignature, versionsContentSignature],
);
// 两个页签的默认落点:当前版本确实绑定了已登记素材时优先展示「当前版本素材」,
// 否则落到「全部画布素材」,避免没有版本的项目一打开就是空列表。
@@ -448,30 +592,16 @@ function ResourceReferenceEditor({
return;
}
editor.update(() => {
- const root = $getRoot();
- root.clear();
- appendTextParagraphs(value.split(/\r?\n/u), (nodes) => {
- root.append(...nodes);
- });
- if (references.length > 0) {
- const referenceParagraph = $createParagraphNode();
- referenceParagraph.append(
- $createTextNode(' '),
- ...references.flatMap((reference) => [
- $createResourceReferenceNode(reference),
- $createTextNode(' '),
- ]),
- );
- root.append(referenceParagraph);
- }
+ applyDraftToRoot(value, references);
+ // 立刻按编辑器自己的口径读一遍刚写进去的内容,并记为「已同步草稿」:
+ // `OnChangePlugin` 稍后读到的就是这一份,两边一致才不会触发下一轮重建。
+ lastEmittedDraftRef.current = readDraftFromNodes();
// 程序化重建草稿(切会话 / 重开会话恢复草稿)后把光标收回草稿末尾:
// 既保证恢复后光标落在文本末尾,也保证后续 @ 引用按顺序追加而不是插到旧位置。
- root.selectEnd();
+ $getRoot().selectEnd();
});
- lastEmittedDraftRef.current = nextDraft;
}, [editor, references, value]);
- const assetsContentSignature = assetsSignature(assets);
const assetsById = useMemo(
() => new Map(assets.map((asset) => [asset.id, asset])),
// 依赖内容签名:assets 数组身份每次渲染都会变,内容不变时没必要重建索引。
@@ -511,7 +641,16 @@ function ResourceReferenceEditor({
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
- if (!event || event.shiftKey || event.isComposing) {
+ if (
+ !event ||
+ event.shiftKey ||
+ event.isComposing ||
+ // 候选菜单 / 选择器开着时 Enter 属于它们:返回 false 把按键让给
+ // LexicalTypeaheadMenuPlugin(它在 NORMAL 优先级选候选),
+ // 而不是在 HIGH 优先级抢先提交表单。
+ mentionMenuOpenRef.current ||
+ pickerVisibleRef.current
+ ) {
return false;
}
event.preventDefault();
@@ -961,11 +1100,7 @@ function ResourceReferenceEditor({
{visiblePickerReferences.length === 0 ? (
- {scopeReferences.length === 0
- ? pickerScope === 'current-version'
- ? '当前版本还没有绑定素材'
- : '当前项目还没有已登记素材'
- : '没有匹配的素材'}
+ {pickerEmptyMessage(scopeReferences.length, pickerScope)}
) : (
visiblePickerReferences.map((reference) => {
@@ -1073,14 +1208,6 @@ function ResourcePickerThumbnail({
const mediaType = asset?.mediaType.toLowerCase() ?? '';
const kind = asset?.kind.toLowerCase() ?? '';
- useEffect(
- () => () => {
- cancelLocalProjectResourcePreviewScope(previewScopeIdRef.current);
- previewScopeIdRef.current = createProjectResourcePreviewScopeId();
- },
- [],
- );
-
useEffect(() => {
setPreviewUrl(null);
setFailed(false);
@@ -1104,6 +1231,10 @@ function ResourcePickerThumbnail({
});
return () => {
cancelled = true;
+ // 只靠 cancelled 标记只是忽略迟到的结果,请求本身还在跑;换素材 / 卸载时
+ // 主动作废上一个 scope,避免快速切换素材叠出一串在飞预览请求。
+ cancelLocalProjectResourcePreviewScope(previewScopeIdRef.current);
+ previewScopeIdRef.current = createProjectResourcePreviewScopeId();
};
}, [asset, mediaType, projectPath]);
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 b195a61ed..320fd9a15 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
@@ -277,13 +277,29 @@ export function refreshResourceReference(
: nextReference;
}
+/**
+ * 运行画面引用的判别字段指纹。
+ *
+ * 选点身份不能只看「同一 run 里的同一段文字」:同一个 run 里两个都叫「Play」的元素、
+ * 或者重新选点后绑定素材变了的同一块区域,都是不同的引用。因此把绑定素材(排序后取集合口径)、
+ * 版本、元素角色与尺寸一起算进 key,避免去重与草稿比对把它们当成同一条。
+ */
+function runtimeRegionReferenceDiscriminators(
+ reference: RuntimeRegionReference,
+): string {
+ const resourceIds = [...reference.resourceIds].sort().join(',');
+ return `${reference.runId ?? ''}:${reference.label}:${
+ reference.elementTag ?? ''
+ }:${reference.text ?? ''}:${reference.versionId ?? ''}:${
+ reference.elementRole ?? ''
+ }:${reference.width ?? ''}:${reference.height ?? ''}:${resourceIds}`;
+}
+
function chatReferenceKey(reference: ChatReference) {
if (reference.type === 'resource') {
return `resource:${reference.resourceId}:${reference.source}`;
}
- return `runtime-region:${reference.runId ?? ''}:${reference.label}:${
- reference.elementTag ?? ''
- }:${reference.text ?? ''}`;
+ return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
}
/**
@@ -295,9 +311,7 @@ export function chatReferenceListKey(references: ChatReference[]) {
.map((reference) =>
reference.type === 'resource'
? `resource:${reference.resourceId}:${reference.source}:${reference.label}`
- : `runtime-region:${reference.runId ?? ''}:${reference.label}:${
- reference.elementTag ?? ''
- }:${reference.text ?? ''}`,
+ : `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`,
)
.join('\u0001');
}
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts
index 38b32f9c1..76b02b421 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts
@@ -75,6 +75,8 @@ export function usePromptPolish({
const [originalText, setOriginalText] = useState
(null);
// 同一次润色不允许重入:按钮 disabled 之外再挡一道,避免连点发出两次计费请求。
const runningRef = useRef(false);
+ // 请求代次:`reset()` 递增它来作废在飞请求,避免迟到的回填写进已经清空 / 换过的草稿。
+ const requestIdRef = useRef(0);
const polish = useCallback(
async (options?: PromptPolishRunOptions) => {
@@ -85,6 +87,7 @@ export function usePromptPolish({
if (!prompt.trim()) {
return null;
}
+ const requestId = requestIdRef.current;
runningRef.current = true;
setPolishing(true);
setError(null);
@@ -93,6 +96,10 @@ export function usePromptPolish({
prompt,
resolveContext?.() ?? null,
);
+ if (requestIdRef.current !== requestId) {
+ // 在飞期间草稿被 reset 过:结果已经不属于当前草稿,直接丢弃。
+ return null;
+ }
if (!polished) {
// 失败 / 超时 / 未配置模型:保留原文,只给出可重试提示。
setError(options?.failureMessage ?? failureMessage);
@@ -106,6 +113,13 @@ export function usePromptPolish({
setNotice(normalized.notice ?? null);
applyPrompt(normalized.text);
return normalized.text;
+ } catch {
+ // 注入的 `requestPolish` / 规范化 / 回填都可能抛:兜成失败提示,
+ // 而不是把这个 promise 变成未处理拒绝、让用户看不到任何反馈。
+ if (requestIdRef.current === requestId) {
+ setError(options?.failureMessage ?? failureMessage);
+ }
+ return null;
} finally {
runningRef.current = false;
setPolishing(false);
@@ -137,6 +151,8 @@ export function usePromptPolish({
}, []);
const reset = useCallback(() => {
+ // 作废在飞请求:宿主清空草稿 / 换资源后,迟到的润色结果不许再回填。
+ requestIdRef.current += 1;
setOriginalText(null);
setError(null);
setNotice(null);
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts
index 6643aede5..3b5404295 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts
@@ -1,4 +1,7 @@
-import { isDirectCodexTurnAlreadyRunningError } from '../../src/App';
+import {
+ directCodexPolicyRetryInput,
+ isDirectCodexTurnAlreadyRunningError,
+} from '../../src/App';
import {
act,
agentRuntimeUserInputRequest,
@@ -44,6 +47,42 @@ export function registerProjectConversationTests() {
).toBe(false);
});
+ it('carries the whole direct turn input, including @ references, into the policy-confirmation retry', () => {
+ // 确认 `conversation.write` 之后重跑的是同一轮输入:漏掉任何一项都会让用户
+ // 在确认之后拿到另一轮内容。历史缺陷正是漏了 `references`(@ 引用被静默丢掉),
+ // 所以这里把「首轮入参整体带过去」钉成硬约束。
+ const firstTurn = {
+ prompt: '用这张图改一下',
+ clientTurnId: 'direct-turn-1',
+ creationType: 'game' as const,
+ attachments: [
+ { name: '角色草图.png', mediaType: 'image/png', size: 128 },
+ ],
+ references: [
+ {
+ type: 'resource' as const,
+ resourceId: 'reference-hero',
+ kind: 'image',
+ mediaType: 'image/png',
+ label: 'hero.png',
+ category: 'scene' as const,
+ tags: ['主舞台'],
+ source: 'resource-card' as const,
+ },
+ ],
+ };
+
+ expect(directCodexPolicyRetryInput(firstTurn)).toEqual({
+ ...firstTurn,
+ directPolicyChecked: true,
+ });
+
+ // 引用是这一次输入的判别项,单独再断言一遍,避免上面整体相等被未来字段扩展掩盖。
+ expect(directCodexPolicyRetryInput(firstTurn).references).toEqual(
+ firstTurn.references,
+ );
+ });
+
it('loads the first run history page by file modified time before reading traces', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx
index 5d9119429..696fccadb 100644
--- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx
+++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx
@@ -191,20 +191,22 @@ describe('发送前提醒判据', () => {
},
'asset-picker',
);
- expect(
- chatPromptDraftKey({ text: '需求', references: [] }),
- ).not.toBe(chatPromptDraftKey({ text: '需求', references: [reference] }));
+ expect(chatPromptDraftKey({ text: '需求', references: [] })).not.toBe(
+ chatPromptDraftKey({ text: '需求', references: [reference] }),
+ );
});
test('persists the 不再提醒 preference on this machine only', () => {
expect(readChatPromptPolishReminderDisabled()).toBe(false);
writeChatPromptPolishReminderDisabled(true);
- expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY))
- .toBe('true');
+ expect(
+ window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY),
+ ).toBe('true');
expect(readChatPromptPolishReminderDisabled()).toBe(true);
writeChatPromptPolishReminderDisabled(false);
- expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY))
- .toBeNull();
+ expect(
+ window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY),
+ ).toBeNull();
expect(readChatPromptPolishReminderDisabled()).toBe(false);
});
});
@@ -368,6 +370,44 @@ describe('聊天输入区 AI 润色与发送前提醒', () => {
});
});
+ test('keeps the reminder open on Escape while the in-panel polish is in flight', async () => {
+ let resolvePolish: (value: string) => void = () => {};
+ installTauriInvoke(async (command) => {
+ if (command !== 'polish_local_project_prompt') return undefined;
+ return new Promise((resolve) => {
+ resolvePolish = resolve;
+ });
+ });
+ const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
+
+ fireEvent.click(sendButton());
+ fireEvent.click(
+ within(reminderPanel()).getByRole('button', { name: 'AI 润色' }),
+ );
+ await waitFor(() => {
+ expect(
+ within(reminderPanel())
+ .getByRole('button', { name: 'AI 润色' })
+ .getAttribute('aria-busy'),
+ ).toBe('true');
+ });
+
+ // Escape 与「关闭」按钮、遮罩点击同一口径:在飞期间不许关面板,
+ // 否则润色回来还会在用户已经取消之后继续把这一轮提交出去。
+ fireEvent.keyDown(reminderPanel(), { key: 'Escape' });
+ expect(screen.queryByRole('dialog', { name: '发送前提醒' })).not.toBeNull();
+ expect(onSubmitDraft).not.toHaveBeenCalled();
+
+ // 在飞请求照旧走完:润色回填后把这一轮发出去。
+ resolvePolish('润色后的长需求');
+ await waitFor(() => {
+ expect(onSubmitDraft).toHaveBeenCalledWith({
+ text: '润色后的长需求',
+ references: [],
+ });
+ });
+ });
+
test('persists 不再提醒 locally and stops holding later sends', async () => {
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
@@ -375,8 +415,9 @@ describe('聊天输入区 AI 润色与发送前提醒', () => {
fireEvent.click(
within(reminderPanel()).getByRole('checkbox', { name: '不再提醒' }),
);
- expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY))
- .toBe('true');
+ expect(
+ window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY),
+ ).toBe('true');
fireEvent.click(
within(reminderPanel()).getByRole('button', { name: '关闭' }),
diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx
index c44501396..5bd371894 100644
--- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import {
cleanup,
+ fireEvent,
render,
screen,
waitFor,
@@ -8,7 +9,7 @@ import {
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical';
-import { StrictMode } from 'react';
+import { StrictMode, useState } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type {
@@ -23,7 +24,9 @@ import { ResourceReferenceInput } from '../src/features/project-workspace/Resour
import {
type ChatComposerDraft,
type ChatReference,
+ chatReferenceListKey,
currentIterationVersionAssets,
+ dedupeChatReferences,
dispatchResourceReferenceInsert,
resolveActiveIterationVersion,
RESOURCE_REFERENCE_FILTERS,
@@ -35,6 +38,7 @@ import {
resourceReferenceMatchesQuery,
resourceReferenceMatchesTagSelection,
resourceReferenceTagLibrary,
+ type RuntimeRegionReference,
} from '../src/features/project-workspace/resourceReferences';
function asset(
@@ -148,6 +152,138 @@ async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) {
afterEach(cleanup);
describe('ResourceReferenceInput', () => {
+ test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => {
+ const base: RuntimeRegionReference = {
+ type: 'runtime-region',
+ label: 'Play',
+ runId: 'run-1',
+ versionId: 'v1',
+ elementTag: 'button',
+ elementRole: 'button',
+ text: '开始',
+ width: 120,
+ height: 40,
+ resourceIds: ['hero', 'enemy'],
+ source: 'runtime-picker',
+ };
+
+ // 同一个 run 里两个同文案的元素、或重新选点后绑定变了的同一块区域,都是不同引用:
+ // 指纹漏字段会让 `dedupeChatReferences` 静默丢掉其中一条。
+ const distinctVariants: RuntimeRegionReference[] = [
+ { ...base, resourceIds: ['hero'] },
+ { ...base, versionId: 'v2' },
+ { ...base, elementRole: 'link' },
+ { ...base, width: 200 },
+ { ...base, height: 80 },
+ ];
+ for (const variant of distinctVariants) {
+ expect(chatReferenceListKey([variant])).not.toBe(
+ chatReferenceListKey([base]),
+ );
+ expect(dedupeChatReferences([base, variant])).toHaveLength(2);
+ }
+
+ // 绑定素材顺序不影响身份:同一组素材换个顺序还是同一条引用。
+ expect(
+ dedupeChatReferences([base, { ...base, resourceIds: ['enemy', 'hero'] }]),
+ ).toHaveLength(1);
+ });
+
+ test('AI 润色只换文本时不会把引用一轮轮追加成重复 @显示名', async () => {
+ const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
+ const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
+ function Controlled() {
+ const [draft, setDraft] = useState({
+ text: '原始需求',
+ references: [reference],
+ });
+ return (
+ <>
+
+ {
+ onChange(next);
+ setDraft(next);
+ }}
+ assets={assets}
+ projectPath="C:/project"
+ ariaLabel="聊天"
+ />
+ >
+ );
+ }
+ render();
+ await settleComposer();
+
+ fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
+ await settleComposer();
+ await settleComposer();
+
+ // 重建必须收敛,而且不许把「重建后的编辑器内容」当成一次用户编辑回抛给宿主:
+ // 旧实现用 props 覆写 lastEmittedDraftRef,读回来的文本里多出的 `@显示名`
+ // 会触发下一轮重建,文本一轮轮变长(渲染循环)。
+ expect(onChange).not.toHaveBeenCalled();
+ const editorText =
+ document.querySelector('.resource-reference-input-editor')?.textContent ??
+ '';
+ expect(editorText).toContain('润色后的需求');
+ expect(editorText.match(/@hero/gu) ?? []).toHaveLength(1);
+ expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull();
+ });
+
+ test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
+ const onSubmit = vi.fn();
+ const user = userEvent.setup();
+ function Controlled() {
+ const [draft, setDraft] = useState({
+ text: '要一个',
+ references: [],
+ });
+ return (
+
+ );
+ }
+ render();
+
+ // 候选浮层开着时 Enter 属于浮层(候选菜单用它选引用),输入区的 HIGH 优先级提交
+ // 必须先让位,否则用户按 Enter 选候选会变成把这一轮直接发出去。
+ await user.click(screen.getByRole('button', { name: '插入素材引用' }));
+ await screen.findByRole('dialog', { name: '选择素材' });
+ const composer = screen.getByLabelText('聊天');
+ expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ // 对照:关掉浮层后 Enter 立刻恢复提交语义,说明上面不是编辑器整体坏掉。
+ await user.click(screen.getByRole('button', { name: '关闭素材选择' }));
+ expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+ });
+
test('opens the asset picker, supports multi-select, and inserts stable references', async () => {
const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
@@ -306,7 +442,17 @@ describe('ResourceReferenceInput', () => {
text: '开始游戏',
width: 120.4,
height: 40.2,
- resourceIds: ['hero', 'bad id', '../secret'],
+ // 资源 id 允许 `:` 与非 ASCII(画布规范形态是 `local-asset:`,
+ // 落盘文件名派生的 id 还可能是 `persisted-角色草图.png`);
+ // 空白与路径分隔符不是资源 id,照旧丢掉。
+ resourceIds: [
+ 'hero',
+ 'local-asset:hero',
+ 'persisted-角色草图.png',
+ 'bad id',
+ '../secret',
+ 'a/b',
+ ],
sourcePath: '/assets/hero.png?token=secret',
html: '',
},
@@ -320,7 +466,7 @@ describe('ResourceReferenceInput', () => {
text: '开始游戏',
width: 120.4,
height: 40.2,
- resourceIds: ['hero'],
+ resourceIds: ['hero', 'local-asset:hero', 'persisted-角色草图.png'],
sourcePath: '/assets/hero.png',
},
});
diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts
index a07793513..0f7fc58a0 100644
--- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts
+++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts
@@ -92,13 +92,17 @@ describe('进入项目时的预览活体核验', () => {
expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]);
});
- it('registry 不可读时按没有在跑处理,不把落盘记录当活体', async () => {
- const invoke = vi.fn(async (command: string) => {
- if (command === 'get_local_game_preview_status') {
- throw new Error('需要在陶泥儿客户端内运行');
- }
- return { status: 'stopped', url: null, port: null, root: null };
- });
+ it('registry 不可读时不顺手停预览:读失败不等于确认没有在跑', async () => {
+ const calls: Array<[string, Record | undefined]> = [];
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ calls.push([command, args]);
+ if (command === 'get_local_game_preview_status') {
+ throw new Error('需要在陶泥儿客户端内运行');
+ }
+ return { status: 'stopped', url: null, port: null, root: null };
+ },
+ );
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke,
@@ -110,8 +114,14 @@ describe('进入项目时的预览活体核验', () => {
},
});
- expect(resolution.sessionPreview).toBeNull();
- expect(resolution.manifestPreviewPatch).toEqual({ status: 'stopped' });
+ // 本次会话不带预览:落盘记录有可能确实是陈旧的,但读失败证明不了这一点。
+ expect(resolution).toEqual({
+ sessionPreview: null,
+ manifestPreviewPatch: null,
+ });
+ // 关键:不许发 stop_local_game_preview —— 它按项目停的是活体预览,
+ // 读失败时发出去会把真正在跑的预览一起停掉。
+ expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]);
});
it('记录本来就不是 running 时只读活体,不发多余的停止命令', async () => {
diff --git a/apps/ai-game-creator-shell/tests/usePromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/usePromptPolish.test.tsx
new file mode 100644
index 000000000..b95439f51
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/usePromptPolish.test.tsx
@@ -0,0 +1,129 @@
+// @vitest-environment jsdom
+import { act, cleanup, renderHook } from '@testing-library/react';
+import { afterEach, describe, expect, test, vi } from 'vitest';
+
+import { usePromptPolish } from '../src/features/project-workspace/usePromptPolish';
+
+afterEach(cleanup);
+
+function renderPolish({
+ requestPolish,
+ applyPrompt = vi.fn(),
+ normalizeResult,
+}: {
+ requestPolish: (
+ prompt: string,
+ context: string | null,
+ ) => Promise;
+ applyPrompt?: (text: string) => void;
+ normalizeResult?: (polished: string) => {
+ text: string;
+ notice?: string | null;
+ };
+}) {
+ return renderHook(() =>
+ usePromptPolish({
+ readPrompt: () => '原始需求',
+ applyPrompt,
+ requestPolish,
+ normalizeResult,
+ }),
+ );
+}
+
+describe('usePromptPolish', () => {
+ test('注入的 requestPolish 拒绝时收敛成失败提示,不产生未处理拒绝', async () => {
+ const applyPrompt = vi.fn();
+ const { result } = renderPolish({
+ requestPolish: vi.fn(async () => {
+ throw new Error('platform llm unavailable');
+ }),
+ applyPrompt,
+ });
+
+ let returned: string | null = 'sentinel';
+ await act(async () => {
+ returned = await result.current.polish();
+ });
+
+ // 默认 `requestChatPromptPolish` 自己吞掉失败;注入实现可以拒绝,此时必须
+ // 变成用户可见的失败提示,而不是把 promise 变成未处理拒绝、界面上什么都没有。
+ expect(returned).toBeNull();
+ expect(result.current.error).toBe('AI 润色失败,可重试');
+ expect(result.current.polishing).toBe(false);
+ expect(applyPrompt).not.toHaveBeenCalled();
+ });
+
+ test('notice 文案覆盖本次失败,并且回填前的抛错同样被接住', async () => {
+ const applyPrompt = vi.fn(() => {
+ throw new Error('composer write failed');
+ });
+ const { result } = renderPolish({
+ requestPolish: async () => '润色结果',
+ applyPrompt,
+ });
+
+ await act(async () => {
+ await result.current.polish({ failureMessage: '润色失败,可直接发送' });
+ });
+
+ expect(result.current.error).toBe('润色失败,可直接发送');
+ expect(result.current.polishing).toBe(false);
+ });
+
+ test('reset 之后的在飞润色结果不再回填宿主草稿', async () => {
+ let resolvePolish: (value: string) => void = () => {};
+ const applyPrompt = vi.fn();
+ const { result } = renderPolish({
+ requestPolish: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolvePolish = resolve;
+ }),
+ ),
+ applyPrompt,
+ });
+
+ let pending: Promise | null = null;
+ await act(async () => {
+ pending = result.current.polish();
+ });
+ // 宿主清空草稿 / 换资源:在飞请求必须被作废,迟到的结果不许写进新草稿。
+ await act(async () => {
+ result.current.reset();
+ });
+ await act(async () => {
+ resolvePolish('迟到的润色结果');
+ await pending;
+ });
+
+ expect(applyPrompt).not.toHaveBeenCalled();
+ expect(result.current.originalText).toBeNull();
+ expect(result.current.notice).toBeNull();
+ expect(result.current.error).toBeNull();
+ });
+
+ test('没有 reset 时在飞结果照旧回填(对照)', async () => {
+ let resolvePolish: (value: string) => void = () => {};
+ const applyPrompt = vi.fn();
+ const { result } = renderPolish({
+ requestPolish: () =>
+ new Promise((resolve) => {
+ resolvePolish = resolve;
+ }),
+ applyPrompt,
+ });
+
+ let pending: Promise | null = null;
+ await act(async () => {
+ pending = result.current.polish();
+ });
+ await act(async () => {
+ resolvePolish('润色后的需求');
+ await pending;
+ });
+
+ expect(applyPrompt).toHaveBeenCalledWith('润色后的需求');
+ expect(result.current.originalText).toBe('原始需求');
+ });
+});