Files
writer-work-flow/apps/web/lib/generation/cards.ts
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
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>
2026-06-20 10:39:58 +02:00

159 lines
5.5 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");
}
// —— 世界观生成 ——
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: "" };
}
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}${e.name}`;
const seen = new Set(persisted.map(key));
const appended = sessionEntities.filter((e) => !seen.has(key(e)));
return [...persisted, ...appended];
}