"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 type { AiMessageView } from "@/lib/api/types"; import { useRefine } from "@/lib/workbench/useRefine"; import { useClarify } from "@/lib/workbench/useClarify"; import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify"; import { acceptActionFor, type AcceptAction } from "@/lib/workbench/aiConversation"; import { mergeInlineThreads, readSegment, 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(本轮 refine 往复 + 其 clarify 反问;后者持久化时并入 refine 批)。 const REFINE_KINDS = ["refine", "clarify"] as const; interface RefinePanelProps { projectId: string; chapterNo: number; // 选中原文;打开即对其回炉一次。 original: string; // 接受回填:把「当前选段」替换为该产出(调用方按选区坐标重锚落回草稿)。 onAccept: (refined: string) => void; // 从气泡「回填选段」:按该版存档的原选段(meta.segment)内容重锚回填,drift-safe;返回是否成功。 onReaccept: (segment: string, refined: string) => boolean; // Workbench 级持久化对话(本章留痕数据源 + fail-soft append)。 conversation: UseAiConversation; onClose: () => void; } // 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版。 // 面板正文是一条内联对话气泡流(本章已落库往复 + 本轮进行中气泡实时汇入),输入框在底部,读起来像聊天。 // 原文只读、生成不写库(不变量 #3),accept 才动草稿。 export function RefinePanel({ projectId, chapterNo, original, onAccept, onReaccept, conversation, onClose, }: RefinePanelProps) { const { messages, status, ensureLoaded, appendMessages } = conversation; const { status: refineStatus, versions, latest, refine, recommunicate } = useRefine(); const clarify = useClarify(); const [instruction, setInstruction] = useState(""); const [pendingInstruction, setPendingInstruction] = useState(null); // 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。 const [liveTurn, setLiveTurn] = useState(null); const ranRef = useRef(false); // 本面板一次会话 = 一个 thread;所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。 const threadId = useRef(crypto.randomUUID()).current; const versionNoRef = useRef(0); const streamRef = useRef(null); // 一版 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; ensureLoaded(); setLiveTurn([{ role: "author", content: original }]); void (async () => { const v = await refine(projectId, chapterNo, original); if (v) { appendMessages({ threadId, kind: "refine", chapterNo, messages: [{ role: "author", content: original }, aiRow(v.refined)], }); } setLiveTurn(null); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, chapterNo, original, refine, ensureLoaded, appendMessages]); const busy = refineStatus === "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(""); // clarify 分支:作者答复 IS 本轮 author 输入,不再另加 author(意见) 气泡;否则记 author(意见)。 const authorTurn: AiTurnMessage = { role: "author", content: finalInstruction }; setLiveTurn(clarifyTurns ?? [authorTurn]); void (async () => { const v = await recommunicate(projectId, chapterNo, finalInstruction); if (v) { const rows = clarifyTurns ? [...clarifyTurns, aiRow(v.refined)] : [authorTurn, aiRow(v.refined)]; appendMessages({ threadId, kind: "refine", chapterNo, messages: rows }); } setLiveTurn(null); })(); }; // 「再改一版」:意见含糊/极短(或 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, 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); }; // 本轮进行中气泡:liveTurn(已定的作者/反问)+ 反问待答时的作者意见 + 生成中的 ai 气泡(空正文=思考)。 const pending: AiMessageView[] = []; { let seq = 0; const add = ( seed: Parameters[0], ): void => { pending.push(toPendingView(seed, { threadId, chapterNo, kind: "refine", 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: pendingInstruction ?? instruction.trim(), }); } if (busy && liveTurn) { add({ localId: `pt-${threadId}-answer`, role: "ai", content: "", streaming: true }); } } const threads = mergeInlineThreads(messages, chapterNo, REFINE_KINDS, pending); const flatCount = threads.reduce((n, t) => n + t.messages.length, 0); // 新气泡出现即滚到底(聊天视图);reduced-motion 下瞬时滚动即可,无动画。 useEffect(() => { const el = streamRef.current; if (el) el.scrollTop = el.scrollHeight; }, [flatCount]); const onBubbleAccept = (action: AcceptAction, m: AiMessageView): void => { if (action === "refill_segment") onReaccept(readSegment(m.meta), m.content); }; return (
{/* 内联对话气泡流:本章 refine 往复(已落库)+ 本轮进行中气泡,作者右 / AI 左,滚动区。 */}
{status === "error" ? ( ) : null} {flatCount === 0 && status !== "error" && refineStatus !== "error" ? (

正在润色所选段落…

) : ( threads.map((thread) => ( )) )} {refineStatus === "error" ? ( ) : null}
{asking && firstQuestion ? ( // role="status":AI 反问动态出现时向读屏播报(WCAG 4.1.3 Status Messages)。

AI 想先跟你确认一下:

) : null}