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:
@@ -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+bg);clarify 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.segment(refine 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);
|
||||
|
||||
120
apps/web/components/workbench/ConversationThread.tsx
Normal file
120
apps/web/components/workbench/ConversationThread.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"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+bg);clarify 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);
|
||||
}
|
||||
Reference in New Issue
Block a user