把提示词润色的状态机抽成 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:
+73
-102
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user