"use client"; import { useEffect, 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 { StreamingBar } from "@/components/ui/StreamingBar"; import { TextArea } from "@/components/ui/TextArea"; import type { AiMessageView } from "@/lib/api/types"; 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 { acceptActionFor, type AcceptAction } from "@/lib/workbench/aiConversation"; import { mergeInlineThreads, toPendingView } from "@/lib/workbench/inlineConversation"; import type { AiTurnMessage, UseAiConversation } from "@/lib/workbench/useAiConversation"; import { ChoiceChips } from "./ChoiceChips"; import { ConversationThread } from "./ConversationThread"; // 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。 const CLARIFY_MIN_CHARS = 10; // 本面板内联气泡流展示的 kinds(本轮 rewrite 往复 + 其 clarify 反问;后者持久化时并入 rewrite 批)。 const REWRITE_KINDS = ["rewrite", "clarify"] as const; interface ChapterRewritePanelProps { projectId: string; chapterNo: number; // 当前整章正文(首轮重写的 prior_draft)。 currentDraft: string; // 接受某一版 → 替换整章正文(HITL,作者显式点选才落草稿);气泡「再接受」亦复用此回调。 onAccept: (text: string) => void; // Workbench 级持久化对话(本章留痕数据源 + fail-soft append)。 conversation: UseAiConversation; onClose: () => void; } // 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项 → // 就着当前整章 + 完整记忆注入流式重写一版。面板正文是一条内联对话气泡流(本章已落库 rewrite 往复 + // 本轮进行中气泡实时汇入,流式 token 逐字进气泡),输入框在底部。接受某版才替换整章正文(不变量 #3)。 export function ChapterRewritePanel({ projectId, chapterNo, currentDraft, onAccept, conversation, onClose, }: ChapterRewritePanelProps) { const { messages, status, ensureLoaded, appendMessages } = conversation; const rewrite = useChapterRewrite(); const clarify = useRewriteClarify(); const [feedback, setFeedback] = useState(""); const [pendingFeedback, setPendingFeedback] = useState(null); // 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。 const [liveTurn, setLiveTurn] = useState(null); // 本面板一次会话 = 一个 thread;多版迭代 + 澄清往复归此组,版本号本地累加。 const threadId = useRef(crypto.randomUUID()).current; const versionNoRef = useRef(0); const streamRef = useRef(null); // 打开即懒加载本章留痕(Workbench 级 hook;append 也会触发——已加载则空操作)。 useEffect(() => { ensureLoaded(); }, [ensureLoaded]); 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(""); // clarify 分支:作者答复 IS 本轮 author 输入;否则记 author(意见)。 const authorTurn: AiTurnMessage = { role: "author", content: fb }; setLiveTurn(clarifyTurns ?? [authorTurn]); void (async () => { const v = await rewrite.send(projectId, chapterNo, fb, priorDraft); if (v) { versionNoRef.current += 1; const versionNo = versionNoRef.current; const aiRow: AiTurnMessage = { role: "ai", content: v.text, meta: { version_no: versionNo }, }; const rows = clarifyTurns ? [...clarifyTurns, aiRow] : [ { role: "author" as const, content: fb, meta: { version_no: versionNo } }, aiRow, ]; appendMessages({ threadId, kind: "rewrite", chapterNo, messages: rows }); } setLiveTurn(null); })(); }; // 「重写整章 / 再改一版」:意见含糊/极短(或 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, 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); }; // 本轮进行中气泡:liveTurn(已定的作者/反问)或反问待答时的作者意见 + 流式产出的 ai 气泡(逐字更新)。 const pending: AiMessageView[] = []; { let seq = 0; const add = (seed: Parameters[0]): void => { pending.push(toPendingView(seed, { threadId, chapterNo, kind: "rewrite", seq: seq++ })); }; if (liveTurn) { liveTurn.forEach((m, i) => add({ localId: `pt-${threadId}-${i}`, role: m.role, content: m.content }), ); } else if (asking) { add({ localId: `pt-${threadId}-ask`, role: "author", content: pendingFeedback ?? feedback.trim(), }); } // 生成中(含 token 已到但流未结束)显示流式 ai 气泡;正文空 → 「生成中」指示。 const answerActive = liveTurn !== null && (rewrite.isStreaming || rewrite.state.text.length > 0); if (answerActive) { add({ localId: `pt-${threadId}-answer`, role: "ai", content: rewrite.state.text, streaming: rewrite.isStreaming, }); } } const threads = mergeInlineThreads(messages, chapterNo, REWRITE_KINDS, pending); const flatCount = threads.reduce((n, t) => n + t.messages.length, 0); // 新气泡/流式 token 出现即滚到底(聊天视图)。 useEffect(() => { const el = streamRef.current; if (el) el.scrollTop = el.scrollHeight; }, [flatCount, rewrite.state.text]); const onBubbleAccept = (action: AcceptAction, m: AiMessageView): void => { if (action === "replace_chapter") onAccept(m.content); }; return (
{/* 重写流式期间:气泡区顶部挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */} {rewrite.isStreaming ? ( ) : null} {/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */} {flatCount > 0 || status === "error" ? (
{status === "error" ? ( ) : null} {threads.map((thread) => ( ))}
) : null}
{asking && firstQuestion ? ( // role="status":AI 反问动态出现时向读屏播报(WCAG 4.1.3 Status Messages)。

AI 想先跟你确认一下:

) : null}