Files
writer-work-flow/apps/web/components/workbench/RefinePanel.tsx
Yaojia Wang 1afeb2cdb5 feat(ui): 强化 AI 工作中动效——构思占位/流式进度条/波动三点/转圈
- 动效基元:ai-dot 波动(三点竖跳)、ai-stream 不确定进度条、ai-breathe 呼吸微光(均带 reduced-motion 回退)
- ThinkingIndicator 升级为明显波动;新增 Spinner / StreamingBar / GenerationSkeleton 原子件;Button 加 loading 态
- 写作台:首 token 前正文区『AI 正在构思本章…』占位(补最大缺口,不再空白像卡死)+ 流式顶部进度条
- 整章重写流式进度条;续写/润色忙碌骨架 + 触发键转圈;生成器/角色/世界观改用 Button loading
2026-07-12 19:32:18 +02:00

329 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, 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;
}
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版。
// 面板正文是一条内联对话气泡流(本章已落库往复 + 本轮进行中气泡实时汇入),输入框在底部,读起来像聊天。
// 原文只读、生成不写库(不变量 #3accept 才动草稿。
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<string | null>(null);
// 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。
const [liveTurn, setLiveTurn] = useState<AiTurnMessage[] | null>(null);
const ranRef = useRef(false);
// 本面板一次会话 = 一个 thread所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0);
const streamRef = useRef<HTMLDivElement>(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<void> => {
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<typeof toPendingView>[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 (
<div className="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>
{/* 内联对话气泡流:本章 refine 往复(已落库)+ 本轮进行中气泡,作者右 / AI 左,滚动区。 */}
<div
ref={streamRef}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label="润色对话"
className="mt-3 max-h-[40vh] 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}
{flatCount === 0 && status !== "error" && refineStatus !== "error" ? (
<p className="py-2 text-center text-xs text-ink-soft"></p>
) : (
threads.map((thread) => (
<ConversationThread
key={thread.threadId}
messages={thread.messages}
action={acceptActionFor(thread)}
onAccept={onBubbleAccept}
/>
))
)}
{refineStatus === "error" ? (
<StatusNote variant="danger" className="text-xs" title="回炉失败">
<button
type="button"
onClick={() => void refine(projectId, chapterNo, original)}
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : null}
</div>
<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={instruction}
onChange={(e) => setInstruction(e.target.value)}
rows={2}
placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
/>
</label>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => void onRecommunicate(false)}
loading={busy}
disabled={clarifying || instruction.trim().length === 0}
variant="secondary"
size="sm"
>
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => void onRecommunicate(true)}
disabled={busy || clarifying || instruction.trim().length === 0}
variant="ghost"
size="sm"
title="让 AI 先反问、给方向选项再改"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => latest && onAccept(latest.refined)}
disabled={busy || !latest}
variant="primary"
size="sm"
>
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
{busy || clarifying ? (
<ThinkingIndicator
label={clarifying ? "琢磨要不要反问" : "生成中"}
className="text-xs text-cinnabar"
/>
) : null}
{versions.length > 0 ? (
<span className="ml-auto rounded bg-bg px-1.5 py-0.5 text-2xs tabular-nums text-ink-soft">
{versions.length}
</span>
) : null}
</div>
</div>
</div>
);
}