修复 PR #316 review(前端 TS/TSX):@ 引用与提示词润色主链路

- App.tsx:`conversation.write` 策略确认后的重跑改为整体复用首轮入参(`directCodexPolicyRetryInput`),不再手写字段,修掉漏传 `references` 导致 @ 引用被静默丢弃的缺陷
- resourceReferences.ts:运行画面引用的判别指纹补上 `resourceIds`(排序集合口径)/`versionId`/`elementRole`/`width`/`height`,避免不同选点撞 key 被 `dedupeChatReferences` 去重丢掉
- ResourceReferenceInput.tsx:程序化重建编辑器内容改为把 chip 内联嵌在文本里的同名 token 位置(不再另起一段堆在末尾),并把 `lastEmittedDraftRef` 记为「编辑器实际读回来的草稿」而不是 props,消除每轮多一段 `@显示名` 的渲染循环
- ResourceReferenceInput.tsx:候选浮层(候选菜单 / 素材选择器)打开时 Enter 不再提交表单,把按键让给 `LexicalTypeaheadMenuPlugin`
- ResourceReferenceInput.tsx:`assets` / `currentVersionAssetReferences` 的 memo 依赖改用内容签名;预览请求在换素材 / 卸载时主动作废上一个 scope;空态文案的嵌套三元抽成 `pickerEmptyMessage`
- usePromptPolish.ts:`polish()` 补 `catch`,注入式 `requestPolish` 拒绝时给出可见失败提示而不是未处理拒绝;`reset()` 递增请求代次作废在飞请求
- ChatPromptPolishReminder.tsx:章节级 Esc 分支补 `busy` 判据,与关闭按钮 / 遮罩同一口径(在飞不许关面板)
- LocalGamePreviewFrame.tsx:资源 id 改用专用净化(允许 `:` 与可打印非 ASCII),修掉 `local-asset:<id>`、`persisted-角色草图.png` 被整条过滤导致引用丢素材关联
- sessionPreview.ts:`get_local_game_preview_status` 读失败不再当作「确认没有在跑」去发停止命令,避免停掉真在跑的活体预览
- useHomeProjectCreation.ts:进项目流程加代次令牌,慢请求后到不再覆盖新项目
- WorkspaceLauncher.tsx:拒收恢复的阶段回写按 `projectId`/`heldRevision`/`snapshotRevision` 关联,不再把恢复结果盖到后续新拒收的提示上
- 用例:`directCodexPolicyRetryInput` 透传、引用指纹区分度、重建不循环、浮层打开时 Enter 不提交、润色拒绝 / reset 作废、预览读失败不停预览
This commit is contained in:
2026-09-12 20:48:13 +08:00
parent e4c2f2466f
commit d04fe83f10
14 changed files with 683 additions and 95 deletions
+22 -7
View File
@@ -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) {
@@ -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) {
@@ -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) {
@@ -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
? {
@@ -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);
}}
>
<h2 id="chat-prompt-polish-reminder-title"></h2>
{error ? (
@@ -92,6 +92,20 @@ function sanitizeInspectIdentifier(value: unknown, maxChars: number) {
return /^[A-Za-z0-9._-]+$/u.test(sanitized) ? sanitized : undefined;
}
/**
* 资源 id 的净化:比元素标识宽松。
*
* 资源 id 里允许 `:`(画布规范形态 `local-asset:<id>`)与可打印非 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)
: [];
@@ -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<string | null>(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<ResourceReferenceScope, ResourcePickerScopeState>
>(createResourcePickerScopeStates);
@@ -333,9 +471,13 @@ function ResourceReferenceEditor({
} | null>(null);
const rootRef = useRef<HTMLDivElement>(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({
<div className="resource-reference-picker-list" role="listbox">
{visiblePickerReferences.length === 0 ? (
<p className="resource-reference-picker-empty">
{scopeReferences.length === 0
? pickerScope === 'current-version'
? '当前版本还没有绑定素材'
: '当前项目还没有已登记素材'
: '没有匹配的素材'}
{pickerEmptyMessage(scopeReferences.length, pickerScope)}
</p>
) : (
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]);
@@ -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');
}
@@ -75,6 +75,8 @@ export function usePromptPolish({
const [originalText, setOriginalText] = useState<string | null>(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);
@@ -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',
@@ -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<string>((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: '关闭' }),
@@ -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<ChatComposerDraft>({
text: '原始需求',
references: [reference],
});
return (
<>
<button
type="button"
onClick={() =>
setDraft((current) => ({ ...current, text: '润色后的需求' }))
}
>
</button>
<ResourceReferenceInput
value={draft.text}
references={draft.references}
onChange={(next) => {
onChange(next);
setDraft(next);
}}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</>
);
}
render(<Controlled />);
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<ChatComposerDraft>({
text: '要一个',
references: [],
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<ResourceReferenceInput
value={draft.text}
references={draft.references}
onChange={setDraft}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</form>
);
}
render(<Controlled />);
// 候选浮层开着时 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>`
// 落盘文件名派生的 id 还可能是 `persisted-角色草图.png`);
// 空白与路径分隔符不是资源 id,照旧丢掉。
resourceIds: [
'hero',
'local-asset:hero',
'persisted-角色草图.png',
'bad id',
'../secret',
'a/b',
],
sourcePath: '/assets/hero.png?token=secret',
html: '<button>secret</button>',
},
@@ -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',
},
});
@@ -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<string, unknown> | undefined]> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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 () => {
@@ -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<string | null>;
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<string>((resolve) => {
resolvePolish = resolve;
}),
),
applyPrompt,
});
let pending: Promise<string | null> | 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<string>((resolve) => {
resolvePolish = resolve;
}),
applyPrompt,
});
let pending: Promise<string | null> | null = null;
await act(async () => {
pending = result.current.polish();
});
await act(async () => {
resolvePolish('润色后的需求');
await pending;
});
expect(applyPrompt).toHaveBeenCalledWith('润色后的需求');
expect(result.current.originalText).toBe('原始需求');
});
});