From e30c933c7621b1487f7a3c44b92daaae6fda3e08 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 8 Jul 2026 08:47:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=B6=A6=E8=89=B2=E5=86=8D?= =?UTF-8?q?=E6=B2=9F=E9=80=9A=E6=8E=A5=E5=85=A5=20AI=20=E5=8F=8D=E9=97=AE?= =?UTF-8?q?=E6=BE=84=E6=B8=85=E2=80=94=E2=80=94=E9=80=89=E9=A1=B9=E8=8A=AF?= =?UTF-8?q?=E7=89=87=20+=20=E6=8A=98=E7=AD=94=E6=A1=88=E5=9B=9E=E7=82=89?= =?UTF-8?q?=EF=BC=88WFW-9=20M1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RefinePanel「再沟通」升级为对话式:意见含糊/极短(门控 <10 字,或点「帮我理清方向」)时先调 clarify 预检端点让 AI 反问,渲染 ChoiceChips 选项芯片(+自由输入兜底);作者选/答后 foldClarifications 把答案折进 instruction,再走既有 refine 回炉(零迁移)。清晰意见直接回炉、 不交往返税;预检失败=放行(useClarify 视 error/异常为 needClarification=false,不阻塞润色)。 新增:lib/workbench/clarify.ts(VM 类型+foldClarifications+needsClarifyGate 纯逻辑) + useClarify(SSE 外调映射 snake→VM) + ChoiceChips.tsx;gen:api 重生成客户端。 门禁绿:tsc/lint/vitest 654(+clarify/useClarify 18 例)/build/coverage 95.36%。 --- apps/web/components/workbench/ChoiceChips.tsx | 106 +++++++++++ apps/web/components/workbench/RefinePanel.tsx | 79 +++++++- apps/web/lib/api/schema.d.ts | 173 ++++++++++++++++++ apps/web/lib/workbench/clarify.test.ts | 90 +++++++++ apps/web/lib/workbench/clarify.ts | 64 +++++++ apps/web/lib/workbench/useClarify.test.ts | 115 ++++++++++++ apps/web/lib/workbench/useClarify.ts | 97 ++++++++++ 7 files changed, 714 insertions(+), 10 deletions(-) create mode 100644 apps/web/components/workbench/ChoiceChips.tsx create mode 100644 apps/web/lib/workbench/clarify.test.ts create mode 100644 apps/web/lib/workbench/clarify.ts create mode 100644 apps/web/lib/workbench/useClarify.test.ts create mode 100644 apps/web/lib/workbench/useClarify.ts diff --git a/apps/web/components/workbench/ChoiceChips.tsx b/apps/web/components/workbench/ChoiceChips.tsx new file mode 100644 index 0000000..e3f19ee --- /dev/null +++ b/apps/web/components/workbench/ChoiceChips.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useId, useState, type KeyboardEvent } from "react"; + +import { Button } from "@/components/ui/Button"; +import { TextInput } from "@/components/ui/TextInput"; +import type { ClarifyOptionVM } from "@/lib/workbench/clarify"; + +interface ChoiceChipsProps { + // 反问的澄清问题。 + question: string; + // 2–4 个具体选项;为空时只渲染自由输入。 + options: ClarifyOptionVM[]; + // 是否提供常驻自由输入兜底。 + allowFreeText: boolean; + // 点选某选项:回传其 value(供上层折进 instruction)。 + onPick: (value: string) => void; + // 提交自由输入文本(allowFreeText 且提供此回调时可用)。 + onFreeText?: (text: string) => void; +} + +// AI 反问澄清的选项芯片组(WFW-9 M1):渲染一个问题 + 一组选项芯片(复用 Button), +// 作者点选一项即高亮并回传 value;可选自由输入兜底。纯展示、无副作用、无 API 调用。 +export function ChoiceChips({ + question, + options, + allowFreeText, + onPick, + onFreeText, +}: ChoiceChipsProps) { + const [picked, setPicked] = useState(null); + const [freeText, setFreeText] = useState(""); + const inputId = useId(); + const hasOptions = options.length > 0; + const canSubmit = Boolean(onFreeText) && freeText.trim().length > 0; + + const handlePick = (value: string): void => { + setPicked(value); + onPick(value); + }; + + const handleFreeSubmit = (): void => { + const trimmed = freeText.trim(); + if (!trimmed || !onFreeText) return; + onFreeText(trimmed); + setFreeText(""); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Enter") return; + event.preventDefault(); + handleFreeSubmit(); + }; + + return ( +
+

{question}

+ + {hasOptions ? ( +
+ {options.map((option, index) => { + const active = option.value === picked; + return ( + + ); + })} +
+ ) : null} + + {allowFreeText ? ( +
+ + setFreeText(event.target.value)} + onKeyDown={handleKeyDown} + controlSize="sm" + placeholder="或直接说你的想法…" + className="flex-1" + /> + +
+ ) : null} +
+ ); +} diff --git a/apps/web/components/workbench/RefinePanel.tsx b/apps/web/components/workbench/RefinePanel.tsx index 5a31a1c..0896914 100644 --- a/apps/web/components/workbench/RefinePanel.tsx +++ b/apps/web/components/workbench/RefinePanel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Check, MessageSquarePlus, RotateCcw, X } from "lucide-react"; +import { Check, MessageSquarePlus, MessagesSquare, RotateCcw, X } from "lucide-react"; import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { Button } from "@/components/ui/Button"; @@ -9,6 +9,12 @@ import { SectionHeader } from "@/components/ui/SectionHeader"; import { StatusNote } from "@/components/ui/StatusNote"; import { TextArea } from "@/components/ui/TextArea"; import { useRefine } from "@/lib/workbench/useRefine"; +import { useClarify } from "@/lib/workbench/useClarify"; +import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify"; +import { ChoiceChips } from "./ChoiceChips"; + +// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。 +const CLARIFY_MIN_CHARS = 10; interface RefinePanelProps { projectId: string; @@ -30,7 +36,9 @@ export function RefinePanel({ onClose, }: RefinePanelProps) { const { status, versions, latest, refine, recommunicate } = useRefine(); + const clarify = useClarify(); const [instruction, setInstruction] = useState(""); + const [pendingInstruction, setPendingInstruction] = useState(null); const ranRef = useRef(false); // 打开即对选段回炉一次(仅一次,避免重复请求)。 @@ -41,12 +49,38 @@ export function RefinePanel({ }, [projectId, chapterNo, original, refine]); const busy = status === "refining"; + const clarifying = clarify.status === "checking"; + const firstQuestion = clarify.decision?.questions[0] ?? null; + const asking = clarify.status === "asking" && firstQuestion !== null; - const onRecommunicate = (): void => { - const trimmed = instruction.trim(); - if (!trimmed || busy) return; + // 折入澄清答案(或原意见)后回炉,并清理澄清态。 + const runRecommunicate = (finalInstruction: string): void => { + clarify.reset(); + setPendingInstruction(null); setInstruction(""); - void recommunicate(projectId, chapterNo, trimmed); + void recommunicate(projectId, chapterNo, finalInstruction); + }; + + // 「再改一版」:意见含糊/极短(或 force)先预检反问;否则直接回炉。 + const onRecommunicate = async (force: boolean): Promise => { + const trimmed = instruction.trim(); + if (!trimmed || busy || clarifying) return; + if (force || needsClarifyGate(trimmed, CLARIFY_MIN_CHARS)) { + const segment = latest?.refined ?? original; + const vm = await clarify.check(projectId, chapterNo, segment, trimmed); + if (vm.needClarification && vm.questions.length > 0) { + setPendingInstruction(trimmed); // 记住原意见,等作者答完折进去 + return; + } + } + runRecommunicate(trimmed); + }; + + // 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 回炉。 + const onClarifyAnswer = (answer: string): void => { + const question = clarify.decision?.questions[0]?.question ?? ""; + const base = pendingInstruction ?? instruction.trim(); + runRecommunicate(foldClarifications(base, [{ question, answer }])); }; return ( @@ -101,19 +135,41 @@ export function RefinePanel({ value={instruction} onChange={(e) => setInstruction(e.target.value)} rows={2} - placeholder="再沟通:想怎么改?(如「再收紧节奏」「保留这句比喻」)" + placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)" /> + {asking && firstQuestion ? ( +
+

AI 想先跟你确认一下:

+ +
+ ) : null}
+ - {busy ? ( - + {busy || clarifying ? ( + ) : null}
diff --git a/apps/web/lib/api/schema.d.ts b/apps/web/lib/api/schema.d.ts index 0227856..7a46c0c 100644 --- a/apps/web/lib/api/schema.d.ts +++ b/apps/web/lib/api/schema.d.ts @@ -429,6 +429,33 @@ export interface paths { patch?: never; trace?: never; }; + "/projects/{project_id}/chapters/{chapter_no}/refine/clarify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Clarify Refine + * @description 润色预检澄清(WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON,只判不改。 + * + * 独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项, + * 清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点)。analyst 档(不变量 #2)。 + * + * 项目不存在 → 404;无凭据 → 503(dep 解析阶段拦下)。**只读不写库**(不变量 #3); + * 末尾 `commit()` 让网关 usage_ledger 落库(add-only,否则记账静默丢失,同 refine 纪律)。 + * 判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。 + */ + post: operations["clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/templates": { parameters: { query?: never; @@ -1109,6 +1136,81 @@ export interface components { /** Note */ note?: string | null; }; + /** + * ClarifyDecision + * @description 润色预检澄清决策(结构化 LLM 输出 + API 响应)。 + * + * 路线A 两阶段之「问题」阶段:仅判定「要不要反问」并给选项,**不改正文**(正文仍走 + * 既有 refine 端点)。纯只读(`clarify_refine_spec.writes=()`,不变量 #3)。 + * + * **全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式):LLM 漏产/畸形时 + * 降级为 `need_clarification=false`(不问、直接放行 refine),绝不阻塞润色主链路。 + * + * - `need_clarification=false` → `questions` 为空、可给一句 `verification` 确认语; + * - `need_clarification=true` → `questions` 恰 1 问(v1 硬上限)、`verification` 可空。 + */ + ClarifyDecision: { + /** + * Need Clarification + * @description 是否需要向作者反问澄清(含糊/多走法→true;明确→false) + * @default false + */ + need_clarification: boolean; + /** + * Questions + * @description 反问清单(need_clarification=false 时为空;v1 硬上限 1 问) + */ + questions?: components["schemas"]["ClarifyQuestion"][]; + /** + * Verification + * @description 明确时的一句『我这样理解对吗』确认语(need_clarification=false 时给,可空) + */ + verification?: string | null; + }; + /** + * ClarifyOption + * @description 单个澄清选项:展示文案 + 选中回填值(锚定本段/本章的一种具体走法)。 + * + * `value` = 作者选中后折进 refine instruction 字符串的内容(零迁移,不落库)。 + * 凑不出具体可区分选项时退化为空 options(`ClarifyQuestion.options=[]`),突出自由输入。 + */ + ClarifyOption: { + /** + * Label + * @description 展示文案(作者看到的选项标签) + */ + label: string; + /** + * Value + * @description 选中后回填/折进 instruction 的具体走法内容 + */ + value: string; + }; + /** + * ClarifyQuestion + * @description 单条澄清反问:问题 + 2–4 个锚定选项 + 自由输入兜底。 + * + * `options` 常规 2–4 个(锚定本段/本章的具体走法);凑不出具体可区分选项时留空列表, + * 退化为纯自由输入。`allow_free_text` 常驻 True——自由输入永远兜底(作者可不选任一项)。 + */ + ClarifyQuestion: { + /** + * Question + * @description 反问的澄清问题(一句话,指向本段的分歧点) + */ + question: string; + /** + * Options + * @description 2–4 个锚定本段/本章的具体走法选项;凑不出具体选项时为空列表 + */ + options?: components["schemas"]["ClarifyOption"][]; + /** + * Allow Free Text + * @description 是否允许自由输入(常驻兜底) + * @default true + */ + allow_free_text: boolean; + }; /** * ConflictDecision * @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。 @@ -1780,6 +1882,23 @@ export interface components { /** Tier Routing */ tier_routing?: components["schemas"]["TierRoutingInput"][]; }; + /** + * RefineClarifyRequest + * @description 润色预检澄清:选段 + 再沟通意见(WFW-9 M1 路线A 两阶段之「问题」阶段)。 + * + * 与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的 + * 再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision` + * (在 ww_agents,端点直接返回,供前端 gen:api 生成强类型客户端)。 + */ + RefineClarifyRequest: { + /** Segment */ + segment: string; + /** + * Instruction + * @default + */ + instruction: string; + }; /** * RefineRequest * @description 回炉:重写选中段(可选改写指令)。 @@ -3310,6 +3429,60 @@ export interface operations { }; }; }; + clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + chapter_no: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RefineClarifyRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClarifyDecision"]; + }; + }; + /** @description 项目不存在 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description LLM 不可用 */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; list_templates_templates_get: { parameters: { query?: never; diff --git a/apps/web/lib/workbench/clarify.test.ts b/apps/web/lib/workbench/clarify.test.ts new file mode 100644 index 0000000..4031491 --- /dev/null +++ b/apps/web/lib/workbench/clarify.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { + foldClarifications, + needsClarifyGate, + type ClarifyAnswer, +} from "./clarify"; + +describe("foldClarifications", () => { + it("原意见在前,每条澄清另起一行并保序", () => { + const answers: ClarifyAnswer[] = [ + { question: "这段谁在说话?", answer: "主角内心独白" }, + { question: "结尾要留钩子吗?", answer: "留一个悬念" }, + ]; + + const result = foldClarifications("收紧节奏", answers); + + expect(result).toBe( + "收紧节奏\n已澄清:这段谁在说话? → 主角内心独白\n已澄清:结尾要留钩子吗? → 留一个悬念", + ); + }); + + it("无回答时原样返回去空白后的 instruction", () => { + expect(foldClarifications(" 收紧节奏 ", [])).toBe("收紧节奏"); + }); + + it("instruction 为空时只输出澄清行", () => { + const answers: ClarifyAnswer[] = [ + { question: "谁在说话?", answer: "配角旁白" }, + ]; + expect(foldClarifications(" ", answers)).toBe("已澄清:谁在说话? → 配角旁白"); + }); + + it("跳过空/纯空白回答的条目", () => { + const answers: ClarifyAnswer[] = [ + { question: "Q1", answer: " " }, + { question: "Q2", answer: "有效回答" }, + ]; + expect(foldClarifications("原意见", answers)).toBe( + "原意见\n已澄清:Q2 → 有效回答", + ); + }); + + it("问题为空(自由输入)时省略问题只留答案", () => { + const answers: ClarifyAnswer[] = [{ question: " ", answer: "就按我说的改" }]; + expect(foldClarifications("原意见", answers)).toBe( + "原意见\n已澄清:就按我说的改", + ); + }); + + it("去除问题与答案两端空白", () => { + const answers: ClarifyAnswer[] = [ + { question: " 谁在说话? ", answer: " 主角 " }, + ]; + expect(foldClarifications("x", answers)).toBe("x\n已澄清:谁在说话? → 主角"); + }); + + it("不可变:不修改入参数组", () => { + const answers: ClarifyAnswer[] = [{ question: "Q", answer: "A" }]; + const snapshot = JSON.parse(JSON.stringify(answers)); + foldClarifications("原意见", answers); + expect(answers).toEqual(snapshot); + }); + + it("全部为空回答且 instruction 为空时返回空串", () => { + expect(foldClarifications("", [{ question: "Q", answer: "" }])).toBe(""); + }); +}); + +describe("needsClarifyGate", () => { + it("去空白后长度小于 minChars 时需要预检", () => { + expect(needsClarifyGate("改一下", 8)).toBe(true); + }); + + it("恰好等于 minChars 时不需预检(边界)", () => { + expect(needsClarifyGate("12345678", 8)).toBe(false); + }); + + it("超过 minChars 时不需预检", () => { + expect(needsClarifyGate("这是一段足够清晰具体的润色意见", 8)).toBe(false); + }); + + it("纯空白按空计(长度 0 < minChars)需预检", () => { + expect(needsClarifyGate(" ", 8)).toBe(true); + }); + + it("先去两端空白再计长度", () => { + expect(needsClarifyGate(" 改 ", 2)).toBe(true); + }); +}); diff --git a/apps/web/lib/workbench/clarify.ts b/apps/web/lib/workbench/clarify.ts new file mode 100644 index 0000000..e023507 --- /dev/null +++ b/apps/web/lib/workbench/clarify.ts @@ -0,0 +1,64 @@ +// AI 反问澄清(纯逻辑 + 前端 view-model 类型;WFW-9 M1,路线A 两阶段)。 +// 「润色/再沟通」意见不清晰时,先走独立非流式 JSON 预检端点让 AI 反问给选项, +// 作者点选/自由输入后,把答案「折进」既有 refine 的 instruction 字符串(零迁移、不动 refine 端点)。 +// 这里只放确定性纯函数 + 本地 VM 类型(camelCase);与后端 snake_case ClarifyDecision 的映射由主线接线时完成。 + +// 澄清选项(锚定本段/本章的一种具体走法)。 +export interface ClarifyOptionVM { + label: string; + value: string; +} + +// 一个澄清问题:反问 + 2–4 个具体选项(可为空退化为纯自由输入)+ 常驻自由输入兜底。 +export interface ClarifyQuestionVM { + question: string; + options: ClarifyOptionVM[]; + allowFreeText: boolean; +} + +// 一回合预检结论(对应后端 ClarifyDecision)。 +export interface ClarifyDecisionVM { + needClarification: boolean; + // v1 硬上限 1 问;needClarification=false 时为空。 + questions: ClarifyQuestionVM[]; + // needClarification=false 时给一句「我这样理解对吗」确认语,可空。 + verification?: string; +} + +// 作者对某个澄清问题的回答(点选回填 value 或自由输入文本)。 +export interface ClarifyAnswer { + question: string; + answer: string; +} + +const CLARIFY_PREFIX = "已澄清:"; +const CLARIFY_ARROW = " → "; +const LINE_SEPARATOR = "\n"; + +// 把作者对各澄清问题的回答逐条折进 instruction(原意见在前,每条澄清另起一行),供既有 refine 端点直接使用。 +// 纯函数、确定性、不可变:不改动入参;跳过空回答;保留传入顺序。 +export function foldClarifications( + instruction: string, + answers: readonly ClarifyAnswer[], +): string { + const lines = answers + .map((entry) => ({ + question: entry.question.trim(), + answer: entry.answer.trim(), + })) + .filter((entry) => entry.answer.length > 0) + .map((entry) => + entry.question + ? `${CLARIFY_PREFIX}${entry.question}${CLARIFY_ARROW}${entry.answer}` + : `${CLARIFY_PREFIX}${entry.answer}`, + ); + + const trimmedInstruction = instruction.trim(); + const parts = trimmedInstruction ? [trimmedInstruction, ...lines] : lines; + return parts.join(LINE_SEPARATOR); +} + +// 门控:instruction 去空白后长度不足 minChars(意见太空/太短)才需要预检反问;清晰意见直接走 refine 不发问。 +export function needsClarifyGate(instruction: string, minChars: number): boolean { + return instruction.trim().length < minChars; +} diff --git a/apps/web/lib/workbench/useClarify.test.ts b/apps/web/lib/workbench/useClarify.test.ts new file mode 100644 index 0000000..c08487e --- /dev/null +++ b/apps/web/lib/workbench/useClarify.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 { useClarify } from "./useClarify"; +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}/refine/clarify"; + +describe("useClarify", () => { + beforeEach(() => post.mockReset()); + afterEach(() => vi.clearAllMocks()); + + it("初始 idle、无决策", () => { + const { result } = renderHook(() => useClarify()); + expect(result.current.status).toBe("idle"); + expect(result.current.decision).toBeNull(); + }); + + it("need_clarification=true:映射 snake→VM,status=asking,返回 VM", 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(() => useClarify()); + 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: { segment: "这段", instruction: "改改" }, + }); + 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(() => useClarify()); + 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"); + expect(result.current.decision).toBeNull(); + }); + + it("后端 error:视为放行(proceed,needClarification=false)", async () => { + post.mockResolvedValue({ data: null, error: { detail: "boom" } }); + const { result } = renderHook(() => useClarify()); + 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);此处不单测 throw 路径, + // 因 mockRejected + React act 冲刷会触发 vitest 未处理拒绝误报(与被测逻辑无关)。 + + it("reset 清回 idle", async () => { + post.mockResolvedValue({ + data: { need_clarification: true, questions: [{ question: "q", options: [], allow_free_text: true }] }, + error: null, + }); + const { result } = renderHook(() => useClarify()); + 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/useClarify.ts b/apps/web/lib/workbench/useClarify.ts new file mode 100644 index 0000000..5e56f18 --- /dev/null +++ b/apps/web/lib/workbench/useClarify.ts @@ -0,0 +1,97 @@ +"use client"; + +import { useCallback, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { ClarifyDecisionVM } from "./clarify"; + +// 澄清预检结论为「放行」(直接去 refine,不反问)——后端 error/前端异常时的确定性回退。 +const PROCEED: ClarifyDecisionVM = { needClarification: false, questions: [] }; + +export type ClarifyStatus = "idle" | "checking" | "asking" | "proceed" | "error"; + +export interface UseClarify { + status: ClarifyStatus; + // status=asking 时的待答问题(含选项);其它时为 null。 + decision: ClarifyDecisionVM | null; + // 预检:调 refine/clarify 端点,返回 VM 供调用方同步分支。失败=放行(needClarification=false),不阻塞润色。 + check: ( + projectId: string, + chapterNo: number, + segment: string, + instruction: string, + ) => Promise; + reset: () => void; +} + +// 后端 snake_case ClarifyDecision → 前端 camelCase VM(不信任外部数据,全部显式取值 + 默认)。 +function toVM(data: { + need_clarification?: boolean; + questions?: { + question?: string; + options?: { label?: string; value?: string }[]; + allow_free_text?: boolean; + }[]; + verification?: string | null; +}): ClarifyDecisionVM { + return { + needClarification: Boolean(data.need_clarification), + questions: (data.questions ?? []).map((q) => ({ + question: q.question ?? "", + options: (q.options ?? []).map((o) => ({ + label: o.label ?? "", + value: o.value ?? "", + })), + allowFreeText: Boolean(q.allow_free_text), + })), + verification: data.verification ?? undefined, + }; +} + +// AI 反问澄清预检(WFW-9 M1,路线A 两阶段之「问题」阶段)。 +// 「再沟通」意见含糊/极短时先调独立非流式端点让 AI 反问;needClarification=false 或失败=放行去既有 refine。 +export function useClarify(): UseClarify { + const [status, setStatus] = useState("idle"); + const [decision, setDecision] = useState(null); + + const check = useCallback( + async (projectId, chapterNo, segment, instruction) => { + setStatus("checking"); + try { + const { data, error } = await api.POST( + "/projects/{project_id}/chapters/{chapter_no}/refine/clarify", + { + params: { path: { project_id: projectId, chapter_no: chapterNo } }, + body: { segment, instruction }, + }, + ); + 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 }; +}