- gen:api 纳入 /templates、chain continue_volume、teardown rules ingest 端点 - F1:GeneratorRunner 识别拆书为单对象入库(整 preview 作一条 teardown), ingestTable(BookTeardownResult)=rules,buildIngestRequest 组装 teardown 体, 按 isSingleObjectIngest 隐藏勾选框、改文案「入库为规则」 - F2:ChainStarter 加链类型单选(draft_volume 从头写 / continue_volume 续写), chain_key 作 path 参传给 run(CHAIN_KINDS 对齐后端 SUPPORTED_CHAINS) - F3:模板库页 app/templates + 全局 nav 入口 + TemplatesManager(列/建/删,乐观更新回滚); GeneratorRunner 加 TemplateFiller「从模板填入」(body 填进 brief/text,纯前端) - vitest 覆盖 templates/ingest teardown/templateFillTarget/CHAIN_KINDS 纯逻辑
91 lines
3.7 KiB
TypeScript
91 lines
3.7 KiB
TypeScript
// 多章工作流链前端纯逻辑(Scope B):把 `GET /jobs/{id}` 的链 job result 安全收窄成
|
||
// 视图模型;把轮询状态 + interrupt 标志映射成链相位;裁决草稿 → resume 请求体。
|
||
// 纯逻辑(无 React / 无 fetch),便于 node 环境单测。链走 job 轮询(非 SSE)。
|
||
|
||
import type { ChainResumeRequest } from "@/lib/api/types";
|
||
import type { JobView, PollStatus } from "@/lib/jobs/job";
|
||
import { buildAcceptRequest, type DecisionDraft } from "@/lib/review/decisions";
|
||
|
||
// 内置链类型(对齐后端 SUPPORTED_CHAINS):draft_volume=从 start 章按记忆量产;
|
||
// continue_volume=续写式(每章以上一章 accepted 正文末尾作前文引子,比纯 digest 更顺)。
|
||
export type ChainKind = "draft_volume" | "continue_volume";
|
||
|
||
export interface ChainKindOption {
|
||
key: ChainKind;
|
||
label: string;
|
||
description: string;
|
||
}
|
||
|
||
export const CHAIN_KINDS: readonly ChainKindOption[] = [
|
||
{
|
||
key: "draft_volume",
|
||
label: "从头写",
|
||
description: "按记忆与大纲量产,每章独立写。",
|
||
},
|
||
{
|
||
key: "continue_volume",
|
||
label: "续写",
|
||
description: "每章以上一章已验收正文末尾作前文引子,承接更顺。",
|
||
},
|
||
];
|
||
|
||
// 链 job result 的视图模型(与后端 `_chain_result` 字段对齐:chain_key/written/completed/
|
||
// awaiting_chapter)。token/正文绝不入 result(不变量 #5 / chain §5),故此处只有进度元数据。
|
||
export interface ChainResultView {
|
||
chainKey: string | null;
|
||
// 已写完(已验收/已晋升)的章号清单。
|
||
written: number[];
|
||
// interrupt 命中、等作者裁决的章号;null = 未暂停(跑完或运行中)。
|
||
awaitingChapter: number | null;
|
||
// 全链跑完(无 interrupt 残留)。
|
||
completed: boolean;
|
||
}
|
||
|
||
function asIntList(v: unknown): number[] {
|
||
if (!Array.isArray(v)) return [];
|
||
return v.filter(
|
||
(n): n is number => typeof n === "number" && Number.isInteger(n),
|
||
);
|
||
}
|
||
|
||
function asIntOrNull(v: unknown): number | null {
|
||
return typeof v === "number" && Number.isInteger(v) ? v : null;
|
||
}
|
||
|
||
// 把链 job 的 result 收窄成 ChainResultView(缺字段/脏数据给安全默认,守边界 #not-trust)。
|
||
export function chainResultView(job: JobView): ChainResultView {
|
||
const r = job.result ?? {};
|
||
return {
|
||
chainKey: typeof r["chain_key"] === "string" ? r["chain_key"] : null,
|
||
written: asIntList(r["written"]),
|
||
awaitingChapter: asIntOrNull(r["awaiting_chapter"]),
|
||
completed: r["completed"] === true,
|
||
};
|
||
}
|
||
|
||
// 链相位(驱动页面 UI 分支):运行中 / 待裁决 / 跑完 / 失败。
|
||
export type ChainPhase = "running" | "awaiting" | "completed" | "failed";
|
||
|
||
// 后端 interrupt 时把 job 置 awaiting_input(非 done/failed)+ result.awaiting_chapter=N——
|
||
// `narrowJob` 把未知态 awaiting_input 归到 "queued",故轮询 reducer 仍 polling;运行中
|
||
// awaiting_chapter 恒为 null,仅 interrupt 命中才置数,故以 `awaitingChapter !== null` 为
|
||
// 暂停的权威信号(不依赖被抹平的原始状态),让页面在暂停时进入裁决相而非空轮询(不变量 #5)。
|
||
export function chainPhase(
|
||
pollStatus: PollStatus,
|
||
awaitingChapter: number | null,
|
||
): ChainPhase {
|
||
if (pollStatus === "error") return "failed";
|
||
if (awaitingChapter !== null) return "awaiting";
|
||
if (pollStatus === "done") return "completed";
|
||
return "running";
|
||
}
|
||
|
||
// 裁决草稿 → resume 请求体。复用 accept 的裁决组装逻辑(DRY;resume.decisions 与
|
||
// accept.decisions 同为 ConflictDecision[]),仅丢弃 final_text(链续跑不带终稿)。
|
||
export function buildResumeRequest(
|
||
drafts: readonly DecisionDraft[],
|
||
): ChainResumeRequest {
|
||
const { decisions } = buildAcceptRequest("", drafts);
|
||
return { decisions };
|
||
}
|