Files
writer-work-flow/apps/web/components/workbench/RefinePanel.tsx
Yaojia Wang e30c933c76 feat(frontend): 润色再沟通接入 AI 反问澄清——选项芯片 + 折答案回炉(WFW-9 M1)
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%。
2026-07-08 08:47:28 +02:00

193 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useRef, useState } from "react";
import { Check, MessageSquarePlus, MessagesSquare, RotateCcw, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
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;
chapterNo: number;
// 选中原文;打开即对其回炉一次。
original: string;
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。
onAccept: (refined: string) => void;
onClose: () => void;
}
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版,
// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3accept 才动草稿。
export function RefinePanel({
projectId,
chapterNo,
original,
onAccept,
onClose,
}: RefinePanelProps) {
const { status, versions, latest, refine, recommunicate } = useRefine();
const clarify = useClarify();
const [instruction, setInstruction] = useState("");
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
const ranRef = useRef(false);
// 打开即对选段回炉一次(仅一次,避免重复请求)。
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
void refine(projectId, chapterNo, original);
}, [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 runRecommunicate = (finalInstruction: string): void => {
clarify.reset();
setPendingInstruction(null);
setInstruction("");
void recommunicate(projectId, chapterNo, finalInstruction);
};
// 「再改一版」:意见含糊/极短(或 force先预检反问否则直接回炉。
const onRecommunicate = async (force: boolean): Promise<void> => {
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 (
<div className="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 再改一版,满意后接受回填。"
/>
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭润色">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<figure className="rounded border border-line bg-bg p-3">
<figcaption className="mb-1 text-2xs text-ink-soft"></figcaption>
<p className="whitespace-pre-wrap text-sm text-ink-soft">{original}</p>
</figure>
<figure className="rounded border border-cinnabar/40 bg-bg p-3">
<figcaption className="mb-1 flex items-center gap-2 text-2xs text-ink-soft">
{versions.length > 0 ? (
<span className="rounded bg-panel px-1.5 py-0.5 tabular-nums">
{versions.length}
</span>
) : null}
</figcaption>
{busy && !latest ? (
<ThinkingIndicator label="润色中" className="text-xs text-cinnabar" />
) : latest ? (
<p className="whitespace-pre-wrap text-sm text-ink">{latest.refined}</p>
) : status === "error" ? (
<StatusNote variant="danger" className="text-xs" title="回炉失败">
<button
type="button"
onClick={() => void refine(projectId, chapterNo, original)}
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : null}
</figure>
</div>
<div className="mt-3 space-y-2">
<label className="block">
<span className="sr-only"></span>
<TextArea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
rows={2}
placeholder="再沟通:想怎么改?(意见太笼统时 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">
<Button
onClick={() => void onRecommunicate(false)}
disabled={busy || clarifying || instruction.trim().length === 0}
variant="secondary"
size="sm"
>
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => void onRecommunicate(true)}
disabled={busy || clarifying || instruction.trim().length === 0}
variant="ghost"
size="sm"
title="让 AI 先反问、给方向选项再改"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => latest && onAccept(latest.refined)}
disabled={busy || !latest}
variant="primary"
size="sm"
>
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
{busy || clarifying ? (
<ThinkingIndicator
label={clarifying ? "琢磨要不要反问" : "生成中"}
className="text-xs text-cinnabar"
/>
) : null}
</div>
</div>
</div>
);
}