Files
writer-work-flow/apps/web/components/workbench/ChapterRewritePanel.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

245 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 { useRef, useState } from "react";
import { Check, MessagesSquare, RotateCcw, Square, 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 { 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 type {
AiTurnInput,
AiTurnMessage,
} from "@/lib/workbench/useAiConversation";
import { ChoiceChips } from "./ChoiceChips";
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10;
interface ChapterRewritePanelProps {
projectId: string;
chapterNo: number;
// 当前整章正文(首轮重写的 prior_draft
currentDraft: string;
// 接受某一版 → 替换整章正文HITL作者显式点选才落草稿
onAccept: (text: string) => void;
// 留痕:每版重写记 [author(折后意见), ai(版本正文)],含缓冲的 clarify Q&A同 thread 迭代。
appendMessages: (turn: AiTurnInput) => void;
onClose: () => void;
}
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项(预检、只读、
// HITL→ 就着当前整章 + 完整记忆注入流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文(不变量 #3
export function ChapterRewritePanel({
projectId,
chapterNo,
currentDraft,
onAccept,
appendMessages,
onClose,
}: ChapterRewritePanelProps) {
const rewrite = useChapterRewrite();
const clarify = useRewriteClarify();
const [feedback, setFeedback] = useState("");
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
// 本面板一次会话 = 一个 thread多版迭代 + 澄清往复归此组,版本号本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0);
const hasVersions = rewrite.versions.length > 0;
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;
// 折入澄清答案或原意见后流式重写结束后留痕clarifyTurns 非空时把缓冲 Q&A 一并落库。
const runSend = (fb: string, clarifyTurns: AiTurnMessage[] | null): void => {
clarify.reset();
setPendingFeedback(null);
setFeedback("");
void (async () => {
const v = await rewrite.send(projectId, chapterNo, fb, priorDraft);
if (!v) return; // 停止/失败/空版:不留痕。
versionNoRef.current += 1;
const versionNo = versionNoRef.current;
const aiRow: AiTurnMessage = {
role: "ai",
content: v.text,
meta: { version_no: versionNo },
};
// clarify 分支:作者答复 IS 本轮 author 输入;否则记 author(折后意见, meta{version_no})。
const messages = clarifyTurns
? [...clarifyTurns, aiRow]
: [
{
role: "author" as const,
content: fb,
meta: { version_no: versionNo },
},
aiRow,
];
appendMessages({ threadId, kind: "rewrite", chapterNo, messages });
})();
};
// 「重写整章 / 再改一版」:意见含糊/极短(或 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, null);
};
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A原意见→反问→答复+ 折进意见 → 流式重写。
const onClarifyAnswer = (answer: string): void => {
const q = clarify.decision?.questions[0];
const question = q?.question ?? "";
const base = pendingFeedback ?? feedback.trim();
const clarifyTurns: AiTurnMessage[] = [
{ role: "author", content: base },
{
role: "ai",
content: question,
meta: {
parent_kind: "rewrite",
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 },
];
runSend(foldClarifications(base, [{ question, answer }]), clarifyTurns);
};
return (
<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">
<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 space-y-2">
<label className="block">
<span className="sr-only"></span>
<TextArea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
placeholder={
hasVersions
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
: "这一章哪里不满意?想怎么改?(意见太笼统时 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">
{rewrite.isStreaming ? (
<Button onClick={rewrite.stop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button
onClick={() => void onSend(false)}
disabled={!canSend}
variant="primary"
size="sm"
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
{hasVersions ? "再改一版" : "重写整章"}
</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 ? (
<ThinkingIndicator label="重写中" className="text-xs text-cinnabar" />
) : clarifying ? (
<ThinkingIndicator label="琢磨要不要反问" className="text-xs text-cinnabar" />
) : null}
</div>
{err ? (
<StatusNote variant="danger" className="text-xs" title="整章重写失败">
<p>{friendlyError(err.code, err.message).text}</p>
</StatusNote>
) : null}
</div>
{rewrite.isStreaming && rewrite.state.text ? (
<figure className="mt-3 rounded border border-cinnabar/40 bg-bg p-3">
<figcaption className="mb-1 text-2xs text-ink-soft"></figcaption>
<p className="whitespace-pre-wrap text-sm text-ink">{rewrite.state.text}</p>
</figure>
) : null}
{hasVersions && !rewrite.isStreaming ? (
<ul className="mt-3 space-y-2">
{[...rewrite.versions].reverse().map((version, i) => {
const versionNo = rewrite.versions.length - i;
return (
<li key={versionNo} className="rounded border border-line bg-bg p-3">
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<span className="text-2xs text-ink-soft">
{versionNo} · {version.feedback}
</span>
<Button onClick={() => onAccept(version.text)} variant="primary" size="sm">
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<p className="max-h-48 overflow-auto whitespace-pre-wrap text-sm text-ink">
{version.text}
</p>
</li>
);
})}
</ul>
) : null}
</div>
);
}