Files
writer-work-flow/apps/web/lib/generation/cards.ts
Yaojia Wang 29265fa0fd feat(web): 审稿改稿闭环——一键采纳/AI改写/正文高亮定位编辑/伏笔确认/草稿自动保存/章节目录
- 冲突「采纳改法」:有补丁瞬时改入终稿并选中;无补丁但可定位则采纳时调 AI(refiner)重写该段套入;定位不到提示手改
- 正文改为行内高亮叠层编辑器(与页面同底色、随内容增高、无滚动条),点冲突卡/锚点整页滚到并选中+闪烁
- 仅对可在正文定位的冲突显示「跳转」/锚点;locate 支持从 where 引号抽原文,杜绝定位失败刷屏
- 伏笔建议卡:新埋一键登记(预填表单)/ 疑似回收一键标记 CLOSED(复用现有端点)
- 审稿页草稿自动保存(防抖 PUT /draft),与「验收本章」解耦,刷新不丢
- 写作路由带章号 ?chapter=N + 验收成功面板/大纲页「写下一章」入口;写作目录从大纲列出全部章节

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:28:29 +02:00

172 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// 生成(世界观/角色)+ 入库的纯逻辑请求体组装、卡片归一、409 冲突收窄。
// 对齐 C3 扩world/characters generate + characters ingest。纯逻辑便于 node 环境单测。
import type {
CharacterCardView,
CharacterGenerateRequest,
CharacterIngestRequest,
WorldEntityCardView,
WorldGenerateRequest,
} from "@/lib/api/types";
import type { ReviewConflict } from "@/lib/review/sse";
// 角色数量边界(对齐后端 CharacterGenerateRequest ge=1,le=12
export const MIN_CHARACTER_COUNT = 1;
export const MAX_CHARACTER_COUNT = 12;
function asString(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asOptionalString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}
// —— 世界观生成 ——
export function buildWorldGenerateRequest(brief: string): WorldGenerateRequest {
return { brief: brief.trim() };
}
// —— 角色生成 ——
export function clampCharacterCount(count: number): number {
if (!Number.isFinite(count)) return MIN_CHARACTER_COUNT;
const n = Math.round(count);
if (n < MIN_CHARACTER_COUNT) return MIN_CHARACTER_COUNT;
if (n > MAX_CHARACTER_COUNT) return MAX_CHARACTER_COUNT;
return n;
}
export function buildCharacterGenerateRequest(
brief: string,
count: number,
role?: string | null,
): CharacterGenerateRequest {
const body: CharacterGenerateRequest = {
brief: brief.trim(),
count: clampCharacterCount(count),
};
const trimmedRole = role?.trim();
if (trimmedRole) body.role = trimmedRole;
return body;
}
// 把作者勾选的角色卡组装成入库请求acknowledge 用于越过 continuity gate409 后重发)。
export function buildCharacterIngestRequest(
cards: readonly CharacterCardView[],
acknowledgeConflicts = false,
): CharacterIngestRequest {
return {
cards: cards.map((c) => ({ ...c })),
acknowledge_conflicts: acknowledgeConflicts,
};
}
// 入库前作者可编辑角色卡:返回新卡(不可变更新)。
export function updateCard(
card: Readonly<CharacterCardView>,
patch: Partial<CharacterCardView>,
): CharacterCardView {
return { ...card, ...patch };
}
// —— 409 CONFLICT_UNRESOLVED 收窄 ——
// 后端入库 gateC3 扩 T5.2details{conflicts:[{type,where,refs,suggestion}], conflict_count}。
// 复用 ReviewConflict 形(同 accept gate。安全收窄松散 unknown。
export interface IngestConflicts {
conflicts: ReviewConflict[];
conflictCount: number;
}
function narrowConflict(raw: unknown): ReviewConflict {
if (typeof raw !== "object" || raw === null) {
return {
type: "未分类",
where: "",
refs: [],
suggestion: "",
original: null,
replacement: null,
};
}
const c = raw as Record<string, unknown>;
return {
type: asString(c["type"], "未分类"),
where: asString(c["where"]),
refs: asStringArray(c["refs"]),
suggestion: asString(c["suggestion"]),
original: asOptionalString(c["original"]),
replacement: asOptionalString(c["replacement"]),
};
}
// 从错误信封提取 409 冲突详情;非冲突错误 → null。
export function extractIngestConflicts(error: unknown): IngestConflicts | null {
if (typeof error !== "object" || error === null) return null;
const env = error as {
error?: {
code?: unknown;
details?: { conflicts?: unknown; conflict_count?: unknown } | null;
};
};
if (env.error?.code !== "CONFLICT_UNRESOLVED") return null;
const details = env.error?.details;
if (typeof details !== "object" || details === null) return null;
const rawConflicts = Array.isArray(details.conflicts) ? details.conflicts : [];
const conflicts = rawConflicts.map(narrowConflict);
const count =
typeof details.conflict_count === "number"
? details.conflict_count
: conflicts.length;
return { conflicts, conflictCount: count };
}
// 通用错误码提取503 LLM_UNAVAILABLE 等)。
export function errorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const env = error as { error?: { code?: unknown } };
return typeof env.error?.code === "string" ? env.error.code : undefined;
}
// 生成预览失败的统一文案503 无凭据引导去设置)。
export function generationErrorMessage(error: unknown): string {
return errorCode(error) === "LLM_UNAVAILABLE"
? "未配置提供商,请先去设置页连一家。"
: "生成失败,请稍后重试。";
}
// 世界观实体卡展示用rules 安全取数组。
export function worldEntityRules(entity: WorldEntityCardView): string[] {
return entity.rules ?? [];
}
// —— 设定库 Codex 列表合并 ——
// 初始持久化列表GET .../characters+ 本会话新入库卡:按 name 去重(持久化优先,
// 同名以初始列表为准——避免刚入库的回显与刷新后真源重复显示)。保序:持久化在前、新卡补后。
export function mergeCharacterCards(
persisted: readonly CharacterCardView[],
sessionCards: readonly CharacterCardView[],
): CharacterCardView[] {
const seen = new Set(persisted.map((c) => c.name));
const appended = sessionCards.filter((c) => !seen.has(c.name));
return [...persisted, ...appended];
}
// 世界观实体列表合并:同上,按 (type,name) 去重。
export function mergeWorldEntities(
persisted: readonly WorldEntityCardView[],
sessionEntities: readonly WorldEntityCardView[],
): WorldEntityCardView[] {
const key = (e: WorldEntityCardView) => `${e.type}${e.name}`;
const seen = new Set(persisted.map(key));
const appended = sessionEntities.filter((e) => !seen.has(key(e)));
return [...persisted, ...appended];
}