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 ( +
{ + event.preventDefault(); + onSubmit(); + }} + > + + + ); + } + 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('原始需求'); + }); +});