- 冲突「采纳改法」:有补丁瞬时改入终稿并选中;无补丁但可定位则采纳时调 AI(refiner)重写该段套入;定位不到提示手改 - 正文改为行内高亮叠层编辑器(与页面同底色、随内容增高、无滚动条),点冲突卡/锚点整页滚到并选中+闪烁 - 仅对可在正文定位的冲突显示「跳转」/锚点;locate 支持从 where 引号抽原文,杜绝定位失败刷屏 - 伏笔建议卡:新埋一键登记(预填表单)/ 疑似回收一键标记 CLOSED(复用现有端点) - 审稿页草稿自动保存(防抖 PUT /draft),与「验收本章」解耦,刷新不丢 - 写作路由带章号 ?chapter=N + 验收成功面板/大纲页「写下一章」入口;写作目录从大纲列出全部章节 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
172 lines
5.8 KiB
TypeScript
172 lines
5.8 KiB
TypeScript
// 生成(世界观/角色)+ 入库的纯逻辑:请求体组装、卡片归一、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 gate(409 后重发)。
|
||
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 收窄 ——
|
||
// 后端入库 gate(C3 扩 T5.2):details{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} |