M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.5 KiB
TypeScript
159 lines
5.5 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");
|
||
}
|
||
|
||
// —— 世界观生成 ——
|
||
|
||
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: "" };
|
||
}
|
||
const c = raw as Record<string, unknown>;
|
||
return {
|
||
type: asString(c["type"], "未分类"),
|
||
where: asString(c["where"]),
|
||
refs: asStringArray(c["refs"]),
|
||
suggestion: asString(c["suggestion"]),
|
||
};
|
||
}
|
||
|
||
// 从错误信封提取 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} |