Files
writer-work-flow/apps/web/lib/chain/chain.ts
Yaojia Wang 1a402f5ccc feat(web): F1/F2/F3 前端——拆书入库为规则 / 续写式链选择 / 模板库
- 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 纯逻辑
2026-06-23 20:29:48 +02:00

91 lines
3.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

// 多章工作流链前端纯逻辑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_CHAINSdraft_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 的裁决组装逻辑DRYresume.decisions 与
// accept.decisions 同为 ConflictDecision[]),仅丢弃 final_text链续跑不带终稿
export function buildResumeRequest(
drafts: readonly DecisionDraft[],
): ChainResumeRequest {
const { decisions } = buildAcceptRequest("", drafts);
return { decisions };
}