Files
writer-work-flow/apps/web/lib/workbench/aiConversation.ts
Yaojia Wang ddf7fa061a feat(frontend): AI 对话核心——视图模型 + 数据/追加 hook + 抽屉(AC-3)
- lib/workbench/aiConversation.ts:纯视图模型(kind 标签/徽标、bubbleSide、
  按 thread 归组 (created_at,seq) 定序、按作用域分区、接受动作映射)+ 单测。
- lib/workbench/useAiConversation.ts:Workbench 级懒加载 + 批量 append(乐观插入 +
  正位对账 / 失败回滚),fail-soft 绝不 throw 进生成/HITL;silent 直投供工具箱页。
  renderHook 测试覆盖懒加载去重、对账、回滚、silent、项目切换复位。
- components/workbench/AiConversationDrawer.tsx:本章对话 + 项目级生成两段的气泡抽屉,
  克隆 ContextDrawer 的 a11y(role=dialog/log、Esc、focus trap、还原焦点);
  ai 气泡挂 DRAFT-only 再接受按钮(替换整章/插入正文/回填选段),不绕开验收事务。
- lib/api/types.ts:re-export AiMessage* 码生成名。
2026-07-09 17:11:09 +02:00

140 lines
4.9 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.

// AI 对话(聊天记录)纯视图模型:把 append-only 的 AiMessageView 扁平流归组成可渲染的
// 线程/分区结构 + kind 标签/徽标/气泡方位映射。无 React/IO确定性纯函数供抽屉与单测共用。
// 排序纪律(守 AC 契约):组内与线程都按 (created_at ASC, seq ASC)——绝不单靠 created_at
// (同一 append 批次共享 created_at靠 repo 赋的 seq 消歧,这正是后端加 seq 的原因)。
import type { AiMessageView } from "@/lib/api/types";
import type { BadgeVariant } from "@/lib/ui/variants";
// v1 记录的五类交互(与后端 AiKind Literal 对齐)。
export type AiKind = "refine" | "rewrite" | "clarify" | "continue" | "generator";
// 抽屉里对某条 ai 产出可执行的接受动作(复用编辑器既有 DRAFT-only HITL 回调,不绕开验收事务)。
export type AcceptAction = "replace_chapter" | "append_draft" | "refill_segment";
export const KIND_LABEL: Record<AiKind, string> = {
refine: "润色",
rewrite: "整章重写",
clarify: "反问澄清",
continue: "续写",
generator: "工具箱",
};
export const KIND_BADGE: Record<AiKind, BadgeVariant> = {
refine: "accent",
rewrite: "warning",
clarify: "info",
continue: "success",
generator: "neutral",
};
// kind → 中文标签;未知 kind后端可零迁移新增回退原始串永不崩。
export function kindLabel(kind: string): string {
return KIND_LABEL[kind as AiKind] ?? kind;
}
// kind → 徽标变体;未知 kind 回退中性徽标。
export function kindBadge(kind: string): BadgeVariant {
return KIND_BADGE[kind as AiKind] ?? "neutral";
}
// 气泡方位作者靠右、AI 靠左(对话视图约定)。
export function bubbleSide(role: string): "left" | "right" {
return role === "author" ? "right" : "left";
}
// 一次往复交换(同 thread_id 归组),消息按 (created_at, seq) 升序。
export interface AiThread {
threadId: string;
kind: string;
toolKey: string | null;
chapterNo: number | null;
messages: AiMessageView[];
}
// created_at → 毫秒时间戳(兼容 Z / +00:00 两种序列化;不可解析回退 0
function timeOf(iso: string): number {
const t = Date.parse(iso);
return Number.isNaN(t) ? 0 : t;
}
// 稳定定序比较器:先 created_at再 seq跨批 created_at 定序、批内 seq 消歧)。
function byTimeThenSeq(a: AiMessageView, b: AiMessageView): number {
const dt = timeOf(a.created_at) - timeOf(b.created_at);
return dt !== 0 ? dt : a.seq - b.seq;
}
// 扁平消息流 → 线程数组。组内按 (created_at, seq) 升序;线程按各自首条 (created_at, seq) 升序
// (老线程在前,新往复追加在后——契合聊天视图 + aria additions 在末尾播报新增)。
export function groupByThread(messages: readonly AiMessageView[]): AiThread[] {
const groups = new Map<string, AiMessageView[]>();
for (const msg of messages) {
const bucket = groups.get(msg.thread_id);
if (bucket) {
bucket.push(msg);
} else {
groups.set(msg.thread_id, [msg]);
}
}
const threads: AiThread[] = [];
for (const [threadId, rows] of groups) {
const sorted = [...rows].sort(byTimeThenSeq);
const head = sorted[0];
if (!head) continue; // 分组桶恒非空;仅为类型收窄。
threads.push({
threadId,
kind: head.kind,
toolKey: head.tool_key,
chapterNo: head.chapter_no,
messages: sorted,
});
}
// 线程按首条消息定序(复用同一比较器,取每组已排序的 messages[0])。
return threads.sort((a, b) => {
const ha = a.messages[0];
const hb = b.messages[0];
if (!ha || !hb) return 0;
return byTimeThenSeq(ha, hb);
});
}
// 线程按作用域分区本章线程chapterNo === 当前章与项目级线程chapterNo == null工具箱
export interface ScopePartition {
chapterThreads: AiThread[];
projectThreads: AiThread[];
}
export function partitionByScope(
threads: readonly AiThread[],
currentChapterNo: number,
): ScopePartition {
const chapterThreads: AiThread[] = [];
const projectThreads: AiThread[] = [];
for (const thread of threads) {
if (thread.chapterNo === currentChapterNo) {
chapterThreads.push(thread);
} else if (thread.chapterNo === null) {
projectThreads.push(thread);
}
// 其它章号(联合查询理论上不返回):既不归本章也不归项目级,静默丢弃。
}
return { chapterThreads, projectThreads };
}
// 线程 kind → 抽屉里对 ai 气泡可执行的接受动作clarify / 未知 kind 无接受动作。
export function acceptActionFor(thread: AiThread): AcceptAction | null {
switch (thread.kind) {
case "rewrite":
return "replace_chapter";
case "continue":
case "generator":
return "append_draft";
case "refine":
return "refill_segment";
default:
return null;
}
}