feat(frontend): 整章重写接入 AI 反问澄清——useRewriteClarify + ChapterRewritePanel 门控(WFW-9 M2)

This commit is contained in:
Yaojia Wang
2026-07-08 12:00:17 +02:00
parent 4b1088a179
commit 8e29b33a11
3 changed files with 258 additions and 10 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Check, RotateCcw, Square, X } from "lucide-react";
import { Check, MessagesSquare, RotateCcw, Square, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
@@ -9,7 +9,13 @@ import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { friendlyError } from "@/lib/errors/messages";
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
import { useRewriteClarify } from "@/lib/workbench/useRewriteClarify";
import { ChoiceChips } from "./ChoiceChips";
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10;
interface ChapterRewritePanelProps {
projectId: string;
@@ -21,8 +27,8 @@ interface ChapterRewritePanelProps {
onClose: () => void;
}
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → AI 就着当前整章 + 完整记忆注入
// 流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文。生成不写库HITL不变量 #3
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项(预检、只读、
// HITL→ 就着当前整章 + 完整记忆注入流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文不变量 #3
export function ChapterRewritePanel({
projectId,
chapterNo,
@@ -31,27 +37,55 @@ export function ChapterRewritePanel({
onClose,
}: ChapterRewritePanelProps) {
const rewrite = useChapterRewrite();
const clarify = useRewriteClarify();
const [feedback, setFeedback] = useState("");
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
const hasVersions = rewrite.versions.length > 0;
const canSend = feedback.trim().length > 0 && !rewrite.isStreaming;
const clarifying = clarify.status === "checking";
const canSend =
feedback.trim().length > 0 && !rewrite.isStreaming && !clarifying;
const firstQuestion = clarify.decision?.questions[0] ?? null;
const asking = clarify.status === "asking" && firstQuestion !== null;
// 下一轮以最新版正文为基础(无版本则用当前整章正文)迭代。
const priorDraft = rewrite.latest?.text ?? currentDraft;
const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
const onSend = (): void => {
if (!canSend) return;
const fb = feedback.trim();
// 折入澄清答案(或原意见)后流式重写,并清理澄清态。
const runSend = (fb: string): void => {
clarify.reset();
setPendingFeedback(null);
setFeedback("");
void rewrite.send(projectId, chapterNo, fb, priorDraft);
};
// 「重写整章 / 再改一版」:意见含糊/极短(或 force先预检反问否则直接流式重写。
const onSend = async (force: boolean): Promise<void> => {
const fb = feedback.trim();
if (!fb || rewrite.isStreaming || clarifying) return;
if (force || needsClarifyGate(fb, CLARIFY_MIN_CHARS)) {
const vm = await clarify.check(projectId, chapterNo, fb, priorDraft);
if (vm.needClarification && vm.questions.length > 0) {
setPendingFeedback(fb); // 记住原意见,等作者答完折进去
return;
}
}
runSend(fb);
};
// 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 流式重写。
const onClarifyAnswer = (answer: string): void => {
const question = clarify.decision?.questions[0]?.question ?? "";
const base = pendingFeedback ?? feedback.trim();
runSend(foldClarifications(base, [{ question, answer }]));
};
return (
<div className="max-h-[70vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<SectionHeader
title="对整章再沟通 / 重写"
description="给意见 → AI 就着当前整章 + 完整设定重写一版;不满意可再给意见迭代,满意再替换正文。"
description="给意见 → AI 就着当前整章 + 完整设定重写一版;意见太笼统时会先反问给方向选项,满意再替换正文。"
/>
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭整章重写">
<X className="h-4 w-4" aria-hidden="true" />
@@ -68,10 +102,22 @@ export function ChapterRewritePanel({
placeholder={
hasVersions
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
: "这一章哪里不满意?想怎么改?"
: "这一章哪里不满意?想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
}
/>
</label>
{asking && firstQuestion ? (
<div className="rounded border border-cinnabar/40 bg-bg p-3">
<p className="mb-2 text-2xs text-ink-soft">AI </p>
<ChoiceChips
question={firstQuestion.question}
options={firstQuestion.options}
allowFreeText={firstQuestion.allowFreeText}
onPick={onClarifyAnswer}
onFreeText={onClarifyAnswer}
/>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
{rewrite.isStreaming ? (
<Button onClick={rewrite.stop} variant="danger" size="sm">
@@ -79,13 +125,30 @@ export function ChapterRewritePanel({
</Button>
) : (
<Button onClick={onSend} disabled={!canSend} variant="primary" size="sm">
<Button
onClick={() => void onSend(false)}
disabled={!canSend}
variant="primary"
size="sm"
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
{hasVersions ? "再改一版" : "重写整章"}
</Button>
)}
<Button
onClick={() => void onSend(true)}
disabled={rewrite.isStreaming || clarifying || feedback.trim().length === 0}
variant="ghost"
size="sm"
title="让 AI 先反问、给方向选项再改"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
{rewrite.isStreaming ? (
<ThinkingIndicator label="重写中" className="text-xs text-cinnabar" />
) : clarifying ? (
<ThinkingIndicator label="琢磨要不要反问" className="text-xs text-cinnabar" />
) : null}
</div>
{err ? (