把提示词润色的状态机抽成 usePromptPolish,聊天输入区先迁过去

- 新增 features/project-workspace/usePromptPolish.ts:失败保留原文、首次成功落原文快照(反复润色只覆盖结果)、可重入保护、可注入的上下文与规范化回调,Tauri 调用固定在 AGC 侧(默认 requestChatPromptPolish)
- 聊天输入区改用它,行为逐字不变:润色按钮 aria-busy/disabled、Loader2/Sparkles、「润色中…」/失败文案、失败保留原文、恢复原文回到最初原文、草稿清空后重置
- 聊天特有的「发送前提醒」留在输入区:提醒偏好、本轮已确认草稿指纹、表单捕获拦截、面板内润色用另一句失败文案
- chatPromptPolish.ts 的 requestChatPromptPolish 及其单测未改动,继续作为这条 LLM 通道的契约测试
- 护栏:chatPromptPolish.test.tsx(16 条)与 resourceReferenceInput.test.tsx(20 条)全绿
This commit is contained in:
2026-09-11 16:53:33 +08:00
parent 3de2ec734b
commit 25c55fcb7f
2 changed files with 228 additions and 102 deletions
@@ -66,7 +66,6 @@ import {
import {
chatPromptDraftKey,
readChatPromptPolishReminderDisabled,
requestChatPromptPolish,
shouldRemindChatPromptPolish,
writeChatPromptPolishReminderDisabled,
} from './chatPromptPolish';
@@ -93,6 +92,7 @@ import {
resourceReferenceMatchesQuery,
type ResourceReferenceScope,
} from './resourceReferences';
import { usePromptPolish } from './usePromptPolish';
type ResourceReferenceInputProps = {
value: string;
@@ -255,21 +255,25 @@ function mentionableAssetReferences(
function $staleResourceReferenceNodes(
assetsById: ReadonlyMap<string, GameCreationAppAssetManifestEntry>,
) {
const staleNodes: { node: ResourceReferenceNode; reference: ChatReference }[] =
[];
$getRoot().getChildren().forEach((block) => {
if (!$isElementNode(block)) return;
block.getChildren().forEach((child) => {
if (!$isResourceReferenceNode(child)) return;
if (child.__reference.type !== 'resource') return;
const nextReference = refreshResourceReference(
child.__reference,
assetsById,
);
if (nextReference === child.__reference) return;
staleNodes.push({ node: child, reference: nextReference });
const staleNodes: {
node: ResourceReferenceNode;
reference: ChatReference;
}[] = [];
$getRoot()
.getChildren()
.forEach((block) => {
if (!$isElementNode(block)) return;
block.getChildren().forEach((child) => {
if (!$isResourceReferenceNode(child)) return;
if (child.__reference.type !== 'resource') return;
const nextReference = refreshResourceReference(
child.__reference,
assetsById,
);
if (nextReference === child.__reference) return;
staleNodes.push({ node: child, reference: nextReference });
});
});
});
return staleNodes;
}
@@ -460,9 +464,11 @@ function ResourceReferenceEditor({
.read(() => $staleResourceReferenceNodes(assetsById).length > 0);
if (!hasStaleReferences) return;
editor.update(() => {
$staleResourceReferenceNodes(assetsById).forEach(({ node, reference }) => {
node.replace($createResourceReferenceNode(reference));
});
$staleResourceReferenceNodes(assetsById).forEach(
({ node, reference }) => {
node.replace($createResourceReferenceNode(reference));
},
);
});
}, [assetsById, editor]);
@@ -533,14 +539,8 @@ function ResourceReferenceEditor({
);
// —— C8 AI 润色与发送前提醒 ——
// 原文快照只在第一次成功润色时落下,之后反复润色只覆盖结果,
// 因此「恢复原文」永远回到最初原文
const [promptPolish, setPromptPolish] = useState<{
originalText: string;
resultText: string;
} | null>(null);
const [polishing, setPolishing] = useState(false);
const [polishError, setPolishError] = useState<string | null>(null);
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截
const [reminderOpen, setReminderOpen] = useState(false);
const [reminderPolishing, setReminderPolishing] = useState(false);
const [reminderDisabled, setReminderDisabled] = useState(() =>
@@ -562,45 +562,35 @@ function ResourceReferenceEditor({
[onChange],
);
const runPromptPolish = useCallback(async () => {
const draft = liveDraftRef.current;
if (!draft.text.trim()) return null;
return requestChatPromptPolish(draft.text, projectPath || null);
}, [projectPath]);
const readPromptText = useCallback(() => liveDraftRef.current.text, []);
const resolvePolishContext = useCallback(
() => projectPath || null,
[projectPath],
);
const promptPolishState = usePromptPolish({
readPrompt: readPromptText,
applyPrompt: applyPromptText,
resolveContext: resolvePolishContext,
});
const {
polishing,
error: polishError,
originalText: polishedOriginalText,
polish: runPolish,
restoreOriginal: restoreOriginalPrompt,
clearError: clearPolishError,
reset: resetPromptPolish,
} = promptPolishState;
const polishPrompt = useCallback(async () => {
if (polishing) return;
setPolishing(true);
setPolishError(null);
try {
const polished = await runPromptPolish();
if (!polished) {
// 失败 / 超时 / 未配置模型:保留原文,只给出可重试提示。
setPolishError('AI 润色失败,可重试');
return;
}
setPromptPolish((current) => ({
originalText: current?.originalText ?? liveDraftRef.current.text,
resultText: polished,
}));
applyPromptText(polished);
} finally {
setPolishing(false);
}
}, [applyPromptText, polishing, runPromptPolish]);
const restoreOriginalPrompt = useCallback(() => {
const originalText = promptPolish?.originalText;
if (originalText === undefined) return;
setPromptPolish(null);
setPolishError(null);
applyPromptText(originalText);
}, [applyPromptText, promptPolish]);
await runPolish();
}, [runPolish]);
const closeReminder = useCallback(() => {
setReminderOpen(false);
setPolishError(null);
}, []);
clearPolishError();
}, [clearPolishError]);
// 用户已在提醒面板里做出选择:记下本轮草稿指纹,再真正提交表单。
// 输入区不在表单里时(例如单独渲染的单元测试)没有可提交的表单事件,仅取消提醒状态。
@@ -611,46 +601,29 @@ function ResourceReferenceEditor({
}, []);
const useOriginalAndSubmit = useCallback(() => {
setPolishError(null);
clearPolishError();
submitCurrentDraft();
}, [submitCurrentDraft]);
}, [clearPolishError, submitCurrentDraft]);
const polishAndSubmitFromReminder = useCallback(async () => {
if (reminderPolishing) return;
setReminderPolishing(true);
setPolishError(null);
try {
const polished = await runPromptPolish();
if (!polished) {
// 保留原文并留在提醒面板里,用户可以选择重试或直接使用原文提交。
setPolishError('AI 润色失败,可重试或使用原文提交');
return;
}
const nextPromptPolish = {
originalText: promptPolish?.originalText ?? liveDraftRef.current.text,
resultText: polished,
};
setPromptPolish(nextPromptPolish);
applyPromptText(polished);
const polished = await runPolish({
failureMessage: 'AI 润色失败,可重试或使用原文提交',
});
// 失败时留在提醒面板里,用户可以选择重试或直接使用原文提交
if (!polished) return;
submitCurrentDraft();
} finally {
setReminderPolishing(false);
}
}, [
applyPromptText,
promptPolish,
reminderPolishing,
runPromptPolish,
submitCurrentDraft,
]);
}, [reminderPolishing, runPolish, submitCurrentDraft]);
const setPromptPolishReminderDisabled = useCallback(
(disabled: boolean) => {
writeChatPromptPolishReminderDisabled(disabled);
setReminderDisabled(disabled);
},
[],
);
const setPromptPolishReminderDisabled = useCallback((disabled: boolean) => {
writeChatPromptPolishReminderDisabled(disabled);
setReminderDisabled(disabled);
}, []);
// 发送前提醒拦截:在捕获阶段拦下 form 的 submit,阻止 React 的表单提交处理器执行,
// 等用户在独立面板里做出选择后再用 requestSubmit() 真正提交。
@@ -658,11 +631,13 @@ function ResourceReferenceEditor({
const form = rootRef.current?.closest('form');
if (!form) return;
const handleFormSubmit = (event: Event) => {
if (!shouldRemindChatPromptPolish({
draft: liveDraftRef.current,
acknowledgedDraftKey: acknowledgedDraftKeyRef.current,
reminderDisabled: reminderDisabledRef.current,
})) {
if (
!shouldRemindChatPromptPolish({
draft: liveDraftRef.current,
acknowledgedDraftKey: acknowledgedDraftKeyRef.current,
reminderDisabled: reminderDisabledRef.current,
})
) {
return;
}
event.preventDefault();
@@ -676,10 +651,9 @@ function ResourceReferenceEditor({
// 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。
useEffect(() => {
if (value.trim() !== '' || references.length > 0) return;
setPromptPolish(null);
setPolishError(null);
resetPromptPolish();
acknowledgedDraftKeyRef.current = null;
}, [references, value]);
}, [references, resetPromptPolish, value]);
const renderMentionMenu: MenuRenderFn<ResourceMentionOption> = useCallback(
(_anchorElementRef, itemProps) => {
@@ -847,7 +821,7 @@ function ResourceReferenceEditor({
<Sparkles size={15} aria-hidden="true" />
)}
</button>
{promptPolish ? (
{polishedOriginalText !== null ? (
<button
type="button"
className="resource-reference-input-restore"
@@ -978,10 +952,7 @@ function ResourceReferenceEditor({
(resourceId) =>
resourceId !== reference.resourceId,
)
: [
...selectedResourceIds,
reference.resourceId,
],
: [...selectedResourceIds, reference.resourceId],
})
}
>
@@ -0,0 +1,155 @@
import { useCallback, useRef, useState } from 'react';
import { requestChatPromptPolish } from './chatPromptPolish';
/**
* 润色回填前的规范化结果。
*
* `notice` 是给用户看的替代文案(例如「已按长度上限截断」):只要规范化动过内容就
* 必须给提示,不许静默丢内容。
*/
export type PromptPolishNormalized = {
text: string;
notice?: string | null;
};
export type PromptPolishRunOptions = {
/** 覆盖本次失败的文案(提醒面板里的润色用另一句)。 */
failureMessage?: string;
};
export type UsePromptPolishOptions = {
/** 读当前文本;宿主自己持有文本状态,hook 不复制一份。 */
readPrompt: () => string;
/** 把润色结果写回宿主状态。 */
applyPrompt: (text: string) => void;
/** 宿主自己的可润色门禁(面板锁定、生成中等)。 */
canPolish?: () => boolean;
/** `polish_local_project_prompt` 的场景上下文(≤1000 字符)。 */
resolveContext?: () => string | null;
/** 回填前的规范化(例如按资源类型上限截断)。默认原样返回。 */
normalizeResult?: (polished: string) => PromptPolishNormalized;
/** 失败文案,默认「AI 润色失败,可重试」。 */
failureMessage?: string;
/** 单次润色实现,默认走 `requestChatPromptPolish`Tauri 调用只留在 AGC 侧)。 */
requestPolish?: (
prompt: string,
context: string | null,
) => Promise<string | null>;
};
export type UsePromptPolishResult = {
polishing: boolean;
error: string | null;
/** 最近一次成功回填的截断提示;没有截断时为 null。 */
notice: string | null;
/** 首次成功润色时落下的原文快照;非空时宿主渲染「恢复原文」。 */
originalText: string | null;
/** 润色并在成功时回填,返回回填后的文本;失败返回 null 并保留原文。 */
polish: (options?: PromptPolishRunOptions) => Promise<string | null>;
restoreOriginal: () => void;
clearError: () => void;
/** 清掉往返状态(草稿清空、面板换资源时用)。 */
reset: () => void;
};
/**
* 提示词润色的状态机:失败保留原文、首次成功落原文快照、反复润色只覆盖结果。
*
* 聊天输入区与资源侧(生成素材 / 快速编辑)共用这一份;它与界面无关,也不碰共享组件,
* 宿主自己决定文本存在哪里、怎么回填。Tauri 调用固定在 AGC 侧
* `requestChatPromptPolish`),共享 composer 只接一个可选的注入位。
*/
export function usePromptPolish({
readPrompt,
applyPrompt,
canPolish,
resolveContext,
normalizeResult,
failureMessage = 'AI 润色失败,可重试',
requestPolish = requestChatPromptPolish,
}: UsePromptPolishOptions): UsePromptPolishResult {
const [polishing, setPolishing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [originalText, setOriginalText] = useState<string | null>(null);
// 同一次润色不允许重入:按钮 disabled 之外再挡一道,避免连点发出两次计费请求。
const runningRef = useRef(false);
const polish = useCallback(
async (options?: PromptPolishRunOptions) => {
if (runningRef.current || (canPolish && !canPolish())) {
return null;
}
const prompt = readPrompt();
if (!prompt.trim()) {
return null;
}
runningRef.current = true;
setPolishing(true);
setError(null);
try {
const polished = await requestPolish(
prompt,
resolveContext?.() ?? null,
);
if (!polished) {
// 失败 / 超时 / 未配置模型:保留原文,只给出可重试提示。
setError(options?.failureMessage ?? failureMessage);
return null;
}
const normalized = normalizeResult
? normalizeResult(polished)
: { text: polished };
// 原文快照只在第一次成功润色时落下,因此「恢复原文」永远回到最初原文。
setOriginalText((current) => current ?? prompt);
setNotice(normalized.notice ?? null);
applyPrompt(normalized.text);
return normalized.text;
} finally {
runningRef.current = false;
setPolishing(false);
}
},
[
applyPrompt,
canPolish,
failureMessage,
normalizeResult,
readPrompt,
requestPolish,
resolveContext,
],
);
const restoreOriginal = useCallback(() => {
if (originalText === null) {
return;
}
setOriginalText(null);
setError(null);
setNotice(null);
applyPrompt(originalText);
}, [applyPrompt, originalText]);
const clearError = useCallback(() => {
setError(null);
}, []);
const reset = useCallback(() => {
setOriginalText(null);
setError(null);
setNotice(null);
}, []);
return {
polishing,
error,
notice,
originalText,
polish,
restoreOriginal,
clearError,
reset,
};
}