修复 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:
@@ -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
|
||||
? {
|
||||
|
||||
+6
-1
@@ -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 ? (
|
||||
|
||||
+15
-1
@@ -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)
|
||||
: [];
|
||||
|
||||
+185
-54
@@ -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('原始需求');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user