refactor(frontend): 抽出可复用 ConversationThread + inlineConversation 纯助手

把 AiConversationDrawer 内联的气泡渲染(Bubble/readOptions/ACCEPT_LABEL)提取为
共享的 ConversationThread 组件,抽屉改为复用之(外观不变,DRY)。新增纯助手
lib/workbench/inlineConversation.ts:mergeInlineThreads(已落库留痕 ∪ 进行中气泡,
按 id 去重 + 复用 groupByThread 定序)、toPendingView(合成未落库气泡,meta._pending/
_streaming UI 标记)、isPendingView/isStreamingView/readSegment,配 9 单测(RED→GREEN)。
为内联对话视图铺路;抽屉行为完全不变。
This commit is contained in:
Yaojia Wang
2026-07-10 16:31:53 +02:00
parent f6e88d7438
commit 5af45f5034
4 changed files with 336 additions and 84 deletions

View File

@@ -11,11 +11,10 @@ import { StatusNote } from "@/components/ui/StatusNote";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { cn, overlayScrim } from "@/lib/ui/variants";
import { overlayScrim } from "@/lib/ui/variants";
import type { AiMessageView } from "@/lib/api/types";
import {
acceptActionFor,
bubbleSide,
groupByThread,
kindBadge,
kindLabel,
@@ -23,7 +22,9 @@ import {
type AcceptAction,
type AiThread,
} from "@/lib/workbench/aiConversation";
import { readSegment } from "@/lib/workbench/inlineConversation";
import type { UseAiConversation } from "@/lib/workbench/useAiConversation";
import { ConversationThread } from "./ConversationThread";
// AI 对话总开关:一键关闭即回滚(触发按钮据此隐藏),仿 CONTEXT_DRAWER_ENABLED。
export const AI_CONVERSATION_ENABLED = true;
@@ -266,92 +267,15 @@ function ThreadBlock({ thread, onAccept }: ThreadBlockProps) {
{formatBubbleTime(headAt)}
</span>
</div>
<div className="space-y-2">
{thread.messages.map((m) => (
<Bubble key={m.id} message={m} action={action} onAccept={onAccept} />
))}
</div>
<ConversationThread
messages={thread.messages}
action={action}
onAccept={onAccept}
/>
</div>
);
}
interface BubbleProps {
message: AiMessageView;
action: AcceptAction | null;
onAccept: (action: AcceptAction, m: AiMessageView) => void;
}
// 单条气泡作者右cinnabar-wash/ AI 左border+bgclarify ai 气泡额外渲染只读方向选项。
function Bubble({ message, action, onAccept }: BubbleProps) {
const side = bubbleSide(message.role);
const options = message.kind === "clarify" ? readOptions(message.meta) : [];
// 只有 ai 产出气泡(左侧)挂再接受按钮;作者气泡与 clarify 不挂。
const showAccept = action !== null && side === "left" && message.kind !== "clarify";
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",
)}
>
<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>
);
}
const ACCEPT_LABEL: Record<AcceptAction, string> = {
replace_chapter: "接受这版(替换整章)",
append_draft: "插入正文(章末)",
refill_segment: "回填选段",
};
// meta.segmentrefine ai 行每版都存原选段)——供「回填选段」按内容重锚。
function readSegment(meta: AiMessageView["meta"]): string {
const v = (meta ?? {})["segment"];
return typeof v === "string" ? v : "";
}
// 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);
}
// 气泡时间戳:本地 HH:MM显示用与自动保存「保存于 HH:MM」风格一致
function formatBubbleTime(iso: string): string {
const d = new Date(iso);