diff --git a/apps/web/components/workbench/ChapterRewritePanel.tsx b/apps/web/components/workbench/ChapterRewritePanel.tsx index edc889b..762ef1a 100644 --- a/apps/web/components/workbench/ChapterRewritePanel.tsx +++ b/apps/web/components/workbench/ChapterRewritePanel.tsx @@ -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(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 => { + 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 (
) : ( - )} + {rewrite.isStreaming ? ( + ) : clarifying ? ( + ) : null}
{err ? ( diff --git a/apps/web/lib/workbench/useRewriteClarify.test.ts b/apps/web/lib/workbench/useRewriteClarify.test.ts new file mode 100644 index 0000000..80508bc --- /dev/null +++ b/apps/web/lib/workbench/useRewriteClarify.test.ts @@ -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(); + }); +}); diff --git a/apps/web/lib/workbench/useRewriteClarify.ts b/apps/web/lib/workbench/useRewriteClarify.ts new file mode 100644 index 0000000..5db60a2 --- /dev/null +++ b/apps/web/lib/workbench/useRewriteClarify.ts @@ -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; + reset: () => void; +} + +// 整章重写侧 AI 反问澄清预检(WFW-9 M2,路线A 两阶段之「问题」阶段——refine/clarify 的章级孪生)。 +// 「再沟通/重写」意见含糊/极短时先调独立非流式端点让 AI 反问;needClarification=false 或失败=放行去既有 rewrite SSE。 +export function useRewriteClarify(): UseRewriteClarify { + const [status, setStatus] = useState("idle"); + const [decision, setDecision] = useState(null); + + const check = useCallback( + 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 }; +}