Files
writer-work-flow/apps/web/components/workbench/ChapterRewritePanel.tsx

313 lines
13 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, 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 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<string | null>(null);
// 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。
const [liveTurn, setLiveTurn] = useState<AiTurnMessage[] | null>(null);
// 本面板一次会话 = 一个 thread多版迭代 + 澄清往复归此组,版本号本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0);
const streamRef = useRef<HTMLDivElement>(null);
// 打开即懒加载本章留痕Workbench 级 hookappend 也会触发——已加载则空操作)。
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<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);
};
// 本轮进行中气泡liveTurn已定的作者/反问)或反问待答时的作者意见 + 流式产出的 ai 气泡(逐字更新)。
const pending: AiMessageView[] = [];
{
let seq = 0;
const add = (seed: Parameters<typeof toPendingView>[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 (
<div className="max-h-[75vh] 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>
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
{flatCount > 0 || status === "error" ? (
<div
ref={streamRef}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label="整章重写对话"
className="mt-3 max-h-[45vh] space-y-3 overflow-auto overscroll-contain rounded border border-line bg-bg/40 p-3"
>
{status === "error" ? (
<StatusNote variant="danger" className="text-xs" title="历史对话加载失败">
<Button
onClick={conversation.reload}
variant="secondary"
size="sm"
className="mt-1"
>
</Button>
</StatusNote>
) : null}
{threads.map((thread) => (
<ConversationThread
key={thread.threadId}
messages={thread.messages}
action={acceptActionFor(thread)}
onAccept={onBubbleAccept}
/>
))}
</div>
) : null}
<div className="mt-3 space-y-2">
{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}
<label className="block">
<span className="sr-only"></span>
<TextArea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
placeholder={
hasVersions
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
: "这一章哪里不满意?想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
}
/>
</label>
<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>
{hasVersions && !rewrite.isStreaming ? (
<Button
onClick={() => rewrite.latest && onAccept(rewrite.latest.text)}
variant="primary"
size="sm"
>
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{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>
</div>
);
}