Files
writer-work-flow/apps/web/components/workbench/ConversationThread.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

122 lines
4.2 KiB
TypeScript
Raw Permalink 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 { Button } from "@/components/ui/Button";
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
import { cn } from "@/lib/ui/variants";
import type { AiMessageView } from "@/lib/api/types";
import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation";
import {
isPendingView,
isStreamingView,
} from "@/lib/workbench/inlineConversation";
// 一组对话气泡(作者右 / AI 左。抽屉与内联面板共用同一渲染外观完全一致DRY
// 对已落库的 ai 产出气泡挂「再接受」(复用编辑器既有 DRAFT-only HITL 回调,不绕验收事务);
// clarify 气泡与进行中(未落库/流式)气泡不挂。
export const ACCEPT_LABEL: Record<AcceptAction, string> = {
replace_chapter: "采用(替换整章)",
append_draft: "采用(加到章末)",
refill_segment: "采用(替换这段)",
};
interface ConversationThreadProps {
messages: readonly AiMessageView[];
// 本线程可执行的接受动作(据 thread.kind 推导null 时全部气泡不挂接受。
action: AcceptAction | null;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
export function ConversationThread({
messages,
action,
onAccept,
}: ConversationThreadProps) {
return (
<div className="space-y-2">
{messages.map((m) => (
<Bubble key={m.id} message={m} action={action} onAccept={onAccept} />
))}
</div>
);
}
interface BubbleProps {
message: AiMessageView;
action: AcceptAction | null;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
// 单条气泡作者右cinnabar-wash/ AI 左border+bgclarify ai 气泡额外渲染只读方向选项。
// 进行中气泡meta._pending不挂再接受流式且空正文时渲染「生成中」指示meta._streaming
function Bubble({ message, action, onAccept }: BubbleProps) {
const side = bubbleSide(message.role);
const options = message.kind === "clarify" ? readOptions(message.meta) : [];
const pending = isPendingView(message);
const showThinking = isStreamingView(message) && message.content.length === 0;
// 只有已落库 ai 产出气泡(左侧、非 clarify挂再接受进行中气泡仍在生成不挂。
const showAccept =
action !== null &&
side === "left" &&
message.kind !== "clarify" &&
!pending;
return (
<div
className={cn("flex", side === "right" ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[85%] rounded border px-3 py-2",
side === "right"
? "border-cinnabar/30 bg-[var(--color-cinnabar-wash)]"
: "border-line bg-bg",
pending && "opacity-90",
)}
>
{showThinking ? (
// 空正文的进行中 ai 气泡(如同步 refine 的整段等待):呼吸微光骨架,比单三点更明显。
<GenerationSkeleton label="生成中" lines={2} className="min-w-[8rem]" />
) : (
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
{message.content}
</p>
)}
{options.length > 0 ? (
<ul className="mt-2 flex flex-wrap gap-1.5">
{options.map((opt, i) => (
<li
key={i}
className="rounded border border-line bg-panel px-2 py-0.5 text-2xs text-ink-soft"
>
{opt}
</li>
))}
</ul>
) : null}
{showAccept && action ? (
<Button
onClick={() => onAccept(action, message)}
variant="secondary"
size="sm"
className="mt-2"
>
{ACCEPT_LABEL[action]}
</Button>
) : null}
</div>
</div>
);
}
// clarify ai 行 meta.options → 只读选项 label 列表(外部数据,逐项显式取值,不信任形状)。
function readOptions(meta: AiMessageView["meta"]): string[] {
const raw = (meta ?? {})["options"];
if (!Array.isArray(raw)) return [];
return raw
.map((o) =>
o && typeof o === "object" && "label" in o
? String((o as { label: unknown }).label ?? "")
: "",
)
.filter((label) => label.length > 0);
}