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* 码生成名。
This commit is contained in:
139
apps/web/lib/workbench/aiConversation.ts
Normal file
139
apps/web/lib/workbench/aiConversation.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user