Files
writer-work-flow/apps/web/components/workbench/RefinePanel.tsx
Yaojia Wang 33a9b7656c feat(frontend): 五类交互接入 AI 对话留痕 + Workbench 抽屉入口(AC-3)
- RefinePanel/ChapterRewritePanel:结束后 append [author(意见/答复), ai(产出)];
  clarify buffer-until-concluded——澄清 Q&A(原意见→反问→答复)缓冲,随最终产出批
  一起落库同 thread;纯放弃不记。refine ai 行 meta 存 version_no+原选段(每版重锚)。
- ContinuePanel:ai-only(续写无作者输入),每候选一条 ai 气泡 meta{candidate_index}。
- GeneratorRunner:成功后 append [author(字段摘要, meta{tool_key,input_fields}),
  ai(渲染预览, meta{tool_key,output_kind})];空产物不记。lib/toolbox/aiSummary.ts 纯助手+单测。
- InlineToolbox→GeneratorRunner 透传 append+本章号;ToolboxPage 用 silent 直投项目级(chapter_no=null)。
- Workbench:挂 useAiConversation 单实例 + AiConversationDrawer + 底栏「AI 对话」入口(flag 灰度),
  三个 DRAFT-only 再接受回调(替换整章/插入正文/回填选段),成功关抽屉+toast、漂移不盲替换。
2026-07-09 17:27:14 +02:00

255 lines
10 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 type {
AiTurnInput,
AiTurnMessage,
} from "@/lib/workbench/useAiConversation";
import { ChoiceChips } from "./ChoiceChips";
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10;
interface RefinePanelProps {
projectId: string;
chapterNo: number;
// 选中原文;打开即对其回炉一次。
original: string;
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。
onAccept: (refined: string) => void;
// 留痕:把本轮往复(含缓冲的 clarify Q&A作为一批 appendfail-soft不阻塞回炉
appendMessages: (turn: AiTurnInput) => void;
onClose: () => void;
}
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版,
// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3accept 才动草稿。
export function RefinePanel({
projectId,
chapterNo,
original,
onAccept,
appendMessages,
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);
// 本面板一次会话 = 一个 thread所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0);
// 一版 ai 产出气泡content=产出meta 存版本号 + 原选段(每版都存,供任意版 re-accept 重锚)。
const aiRow = (refined: string): AiTurnMessage => {
versionNoRef.current += 1;
return {
role: "ai",
content: refined,
meta: { version_no: versionNoRef.current, segment: original },
};
};
// 打开即对选段回炉一次(仅一次,避免重复请求);成功后留痕 [author(原选段), ai(润色)]。
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
void (async () => {
const v = await refine(projectId, chapterNo, original);
if (v) {
appendMessages({
threadId,
kind: "refine",
chapterNo,
messages: [{ role: "author", content: original }, aiRow(v.refined)],
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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;
// 折入澄清答案或原意见后回炉结束后留痕clarifyTurns 非空时把缓冲的 Q&A 一并落库。
const runRecommunicate = (
finalInstruction: string,
clarifyTurns: AiTurnMessage[] | null,
): void => {
clarify.reset();
setPendingInstruction(null);
setInstruction("");
void (async () => {
const v = await recommunicate(projectId, chapterNo, finalInstruction);
if (!v) return;
// clarify 分支:作者答复 IS 本轮 author 输入,不再另加 author(意见) 气泡;否则记 author(意见)。
const messages = clarifyTurns
? [...clarifyTurns, aiRow(v.refined)]
: [{ role: "author" as const, content: finalInstruction }, aiRow(v.refined)];
appendMessages({ threadId, kind: "refine", chapterNo, messages });
})();
};
// 「再改一版」:意见含糊/极短(或 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, null);
};
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A原意见→反问→答复+ 折进意见 → 回炉。
const onClarifyAnswer = (answer: string): void => {
const q = clarify.decision?.questions[0];
const question = q?.question ?? "";
const base = pendingInstruction ?? instruction.trim();
// 缓冲三条:原含糊意见(author) → AI 反问(ai, 选项进 meta) → 作者答复(author),随本轮产出一起落库。
const clarifyTurns: AiTurnMessage[] = [
{ role: "author", content: base },
{
role: "ai",
content: question,
meta: {
parent_kind: "refine",
options: (q?.options ?? []).map((o) => ({
label: o.label,
value: o.value,
})),
allow_free_text: q?.allowFreeText ?? false,
verification: clarify.decision?.verification ?? "",
},
},
{ role: "author", content: answer },
];
runRecommunicate(foldClarifications(base, [{ question, answer }]), clarifyTurns);
};
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 ? (
// role="status"AI 反问动态出现时向读屏播报WCAG 4.1.3 Status Messages
<div role="status" 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>
);
}