feat(frontend): 整章重写接入 AI 反问澄清——useRewriteClarify + ChapterRewritePanel 门控(WFW-9 M2)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -9,7 +9,13 @@ import { SectionHeader } from "@/components/ui/SectionHeader";
|
|||||||
import { StatusNote } from "@/components/ui/StatusNote";
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
import { TextArea } from "@/components/ui/TextArea";
|
import { TextArea } from "@/components/ui/TextArea";
|
||||||
import { friendlyError } from "@/lib/errors/messages";
|
import { friendlyError } from "@/lib/errors/messages";
|
||||||
|
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
|
||||||
import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
|
import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
|
||||||
|
import { useRewriteClarify } from "@/lib/workbench/useRewriteClarify";
|
||||||
|
import { ChoiceChips } from "./ChoiceChips";
|
||||||
|
|
||||||
|
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
|
||||||
|
const CLARIFY_MIN_CHARS = 10;
|
||||||
|
|
||||||
interface ChapterRewritePanelProps {
|
interface ChapterRewritePanelProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -21,8 +27,8 @@ interface ChapterRewritePanelProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → AI 就着当前整章 + 完整记忆注入
|
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项(预检、只读、
|
||||||
// 流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文。生成不写库(HITL,不变量 #3)。
|
// HITL)→ 就着当前整章 + 完整记忆注入流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文(不变量 #3)。
|
||||||
export function ChapterRewritePanel({
|
export function ChapterRewritePanel({
|
||||||
projectId,
|
projectId,
|
||||||
chapterNo,
|
chapterNo,
|
||||||
@@ -31,27 +37,55 @@ export function ChapterRewritePanel({
|
|||||||
onClose,
|
onClose,
|
||||||
}: ChapterRewritePanelProps) {
|
}: ChapterRewritePanelProps) {
|
||||||
const rewrite = useChapterRewrite();
|
const rewrite = useChapterRewrite();
|
||||||
|
const clarify = useRewriteClarify();
|
||||||
const [feedback, setFeedback] = useState("");
|
const [feedback, setFeedback] = useState("");
|
||||||
|
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
|
||||||
|
|
||||||
const hasVersions = rewrite.versions.length > 0;
|
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 priorDraft = rewrite.latest?.text ?? currentDraft;
|
||||||
const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
|
const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
|
||||||
|
|
||||||
const onSend = (): void => {
|
// 折入澄清答案(或原意见)后流式重写,并清理澄清态。
|
||||||
if (!canSend) return;
|
const runSend = (fb: string): void => {
|
||||||
const fb = feedback.trim();
|
clarify.reset();
|
||||||
|
setPendingFeedback(null);
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
void rewrite.send(projectId, chapterNo, fb, priorDraft);
|
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 (
|
return (
|
||||||
<div className="max-h-[70vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6">
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="对整章再沟通 / 重写"
|
title="对整章再沟通 / 重写"
|
||||||
description="给意见 → AI 就着当前整章 + 完整设定重写一版;不满意可再给意见迭代,满意再替换正文。"
|
description="给意见 → AI 就着当前整章 + 完整设定重写一版;意见太笼统时会先反问给方向选项,满意再替换正文。"
|
||||||
/>
|
/>
|
||||||
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭整章重写">
|
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭整章重写">
|
||||||
<X className="h-4 w-4" aria-hidden="true" />
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
@@ -68,10 +102,22 @@ export function ChapterRewritePanel({
|
|||||||
placeholder={
|
placeholder={
|
||||||
hasVersions
|
hasVersions
|
||||||
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
|
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
|
||||||
: "这一章哪里不满意?想怎么改?"
|
: "这一章哪里不满意?想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{rewrite.isStreaming ? (
|
{rewrite.isStreaming ? (
|
||||||
<Button onClick={rewrite.stop} variant="danger" size="sm">
|
<Button onClick={rewrite.stop} variant="danger" size="sm">
|
||||||
@@ -79,13 +125,30 @@ export function ChapterRewritePanel({
|
|||||||
停
|
停
|
||||||
</Button>
|
</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" />
|
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||||
{hasVersions ? "再改一版" : "重写整章"}
|
{hasVersions ? "再改一版" : "重写整章"}
|
||||||
</Button>
|
</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 ? (
|
{rewrite.isStreaming ? (
|
||||||
<ThinkingIndicator label="重写中" className="text-xs text-cinnabar" />
|
<ThinkingIndicator label="重写中" className="text-xs text-cinnabar" />
|
||||||
|
) : clarifying ? (
|
||||||
|
<ThinkingIndicator label="琢磨要不要反问" className="text-xs text-cinnabar" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{err ? (
|
{err ? (
|
||||||
|
|||||||
115
apps/web/lib/workbench/useRewriteClarify.test.ts
Normal file
115
apps/web/lib/workbench/useRewriteClarify.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { useRewriteClarify } from "./useRewriteClarify";
|
||||||
|
import type { ClarifyDecisionVM } from "./clarify";
|
||||||
|
|
||||||
|
const post = vi.fn();
|
||||||
|
vi.mock("@/lib/api/client", () => ({
|
||||||
|
api: { POST: (...a: unknown[]) => post(...a) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CLARIFY_PATH = "/projects/{project_id}/chapters/{chapter_no}/rewrite/clarify";
|
||||||
|
|
||||||
|
describe("useRewriteClarify", () => {
|
||||||
|
beforeEach(() => post.mockReset());
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("初始 idle、无决策", () => {
|
||||||
|
const { result } = renderHook(() => useRewriteClarify());
|
||||||
|
expect(result.current.status).toBe("idle");
|
||||||
|
expect(result.current.decision).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("need_clarification=true:POST rewrite/clarify 带 feedback+prior_draft,映射 VM,status=asking", async () => {
|
||||||
|
post.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
need_clarification: true,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: "高潮想更爽还是更虐?",
|
||||||
|
options: [
|
||||||
|
{ label: "更爽", value: "把高潮写得更爽快" },
|
||||||
|
{ label: "更虐", value: "把高潮写得更虐" },
|
||||||
|
],
|
||||||
|
allow_free_text: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
verification: null,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRewriteClarify());
|
||||||
|
let vm: ClarifyDecisionVM | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
vm = await result.current.check("p1", 3, "改改", "整章草稿正文");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith(CLARIFY_PATH, {
|
||||||
|
params: { path: { project_id: "p1", chapter_no: 3 } },
|
||||||
|
body: { feedback: "改改", prior_draft: "整章草稿正文" },
|
||||||
|
});
|
||||||
|
expect(vm).toEqual({
|
||||||
|
needClarification: true,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: "高潮想更爽还是更虐?",
|
||||||
|
options: [
|
||||||
|
{ label: "更爽", value: "把高潮写得更爽快" },
|
||||||
|
{ label: "更虐", value: "把高潮写得更虐" },
|
||||||
|
],
|
||||||
|
allowFreeText: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
verification: undefined,
|
||||||
|
});
|
||||||
|
expect(result.current.status).toBe("asking");
|
||||||
|
expect(result.current.decision?.needClarification).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("need_clarification=false:status=proceed,decision 清空", async () => {
|
||||||
|
post.mockResolvedValue({
|
||||||
|
data: { need_clarification: false, questions: [], verification: "我按你说的拉满高潮" },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useRewriteClarify());
|
||||||
|
let vm: ClarifyDecisionVM | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
vm = await result.current.check("p1", 1, "把第 3 段的高潮再拉满,删掉闪回", "草稿");
|
||||||
|
});
|
||||||
|
expect(vm?.needClarification).toBe(false);
|
||||||
|
expect(result.current.status).toBe("proceed");
|
||||||
|
expect(result.current.decision).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("后端 error:视为放行(proceed,needClarification=false),不阻塞重写", async () => {
|
||||||
|
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
|
||||||
|
const { result } = renderHook(() => useRewriteClarify());
|
||||||
|
let vm: ClarifyDecisionVM | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
vm = await result.current.check("p1", 1, "改", "草稿");
|
||||||
|
});
|
||||||
|
expect(vm?.needClarification).toBe(false);
|
||||||
|
expect(result.current.status).toBe("proceed");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注:hook 的 try/catch 对「fetch 抛异常」的放行分支与上面「后端 error 信封」放行分支同构、
|
||||||
|
// 行为一致(均 status=proceed、needClarification=false、返回 PROCEED);此处不单测 throw 路径,
|
||||||
|
// 因 mock 抛错 + React act 冲刷会触发 vitest 未处理错误误报(与被测逻辑无关,同 M1 useClarify.test.ts)。
|
||||||
|
|
||||||
|
it("reset 清回 idle", async () => {
|
||||||
|
post.mockResolvedValue({
|
||||||
|
data: { need_clarification: true, questions: [{ question: "q", options: [], allow_free_text: true }] },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useRewriteClarify());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.check("p1", 1, "改", "草稿");
|
||||||
|
});
|
||||||
|
act(() => result.current.reset());
|
||||||
|
expect(result.current.status).toBe("idle");
|
||||||
|
expect(result.current.decision).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
70
apps/web/lib/workbench/useRewriteClarify.ts
Normal file
70
apps/web/lib/workbench/useRewriteClarify.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import { PROCEED, toVM, type ClarifyDecisionVM } from "./clarify";
|
||||||
|
|
||||||
|
export type ClarifyStatus = "idle" | "checking" | "asking" | "proceed" | "error";
|
||||||
|
|
||||||
|
export interface UseRewriteClarify {
|
||||||
|
status: ClarifyStatus;
|
||||||
|
// status=asking 时的待答问题(含选项);其它时为 null。
|
||||||
|
decision: ClarifyDecisionVM | null;
|
||||||
|
// 预检:调 rewrite/clarify 端点,返回 VM 供调用方同步分支。失败=放行(needClarification=false),不阻塞整章重写。
|
||||||
|
check: (
|
||||||
|
projectId: string,
|
||||||
|
chapterNo: number,
|
||||||
|
feedback: string,
|
||||||
|
priorDraft: string,
|
||||||
|
) => Promise<ClarifyDecisionVM>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整章重写侧 AI 反问澄清预检(WFW-9 M2,路线A 两阶段之「问题」阶段——refine/clarify 的章级孪生)。
|
||||||
|
// 「再沟通/重写」意见含糊/极短时先调独立非流式端点让 AI 反问;needClarification=false 或失败=放行去既有 rewrite SSE。
|
||||||
|
export function useRewriteClarify(): UseRewriteClarify {
|
||||||
|
const [status, setStatus] = useState<ClarifyStatus>("idle");
|
||||||
|
const [decision, setDecision] = useState<ClarifyDecisionVM | null>(null);
|
||||||
|
|
||||||
|
const check = useCallback<UseRewriteClarify["check"]>(
|
||||||
|
async (projectId, chapterNo, feedback, priorDraft) => {
|
||||||
|
setStatus("checking");
|
||||||
|
try {
|
||||||
|
const { data, error } = await api.POST(
|
||||||
|
"/projects/{project_id}/chapters/{chapter_no}/rewrite/clarify",
|
||||||
|
{
|
||||||
|
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||||
|
body: { feedback, prior_draft: priorDraft },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error || !data) {
|
||||||
|
setStatus("proceed");
|
||||||
|
setDecision(null);
|
||||||
|
return PROCEED;
|
||||||
|
}
|
||||||
|
const vm = toVM(data);
|
||||||
|
if (vm.needClarification && vm.questions.length > 0) {
|
||||||
|
setStatus("asking");
|
||||||
|
setDecision(vm);
|
||||||
|
} else {
|
||||||
|
setStatus("proceed");
|
||||||
|
setDecision(null);
|
||||||
|
}
|
||||||
|
return vm;
|
||||||
|
} catch {
|
||||||
|
setStatus("proceed");
|
||||||
|
setDecision(null);
|
||||||
|
return PROCEED;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback((): void => {
|
||||||
|
setStatus("idle");
|
||||||
|
setDecision(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { status, decision, check, reset };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user