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

121 lines
4.1 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 { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
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 ? (
<ThinkingIndicator label="生成中" className="text-sm text-cinnabar" />
) : (
<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);
}