- 动效基元:ai-dot 波动(三点竖跳)、ai-stream 不确定进度条、ai-breathe 呼吸微光(均带 reduced-motion 回退) - ThinkingIndicator 升级为明显波动;新增 Spinner / StreamingBar / GenerationSkeleton 原子件;Button 加 loading 态 - 写作台:首 token 前正文区『AI 正在构思本章…』占位(补最大缺口,不再空白像卡死)+ 流式顶部进度条 - 整章重写流式进度条;续写/润色忙碌骨架 + 触发键转圈;生成器/角色/世界观改用 Button loading
319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
"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<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 级 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<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.isStreaming ? (
|
||
<StreamingBar label="重写生成中" className="mt-3" />
|
||
) : null}
|
||
|
||
{/* 内联对话气泡流:本章 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>
|
||
);
|
||
}
|