From a2dcd8988130f37458818bab167d9463808ff369 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 19:21:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Scope=20B=20B1=20=E2=80=94=20?= =?UTF-8?q?=E5=A4=9A=E7=AB=A0=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=93=BE=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20UI=EF=BC=88=E5=8F=91=E8=B5=B7/=E8=BD=AE=E8=AF=A2/?= =?UTF-8?q?=E8=A3=81=E5=86=B3=E7=BB=AD=E8=B7=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 链页 app/projects/[id]/chains(RSC fetchProject)+ ChainPage/ChainStarter/ ChainProgress/ChainAdjudication;lib/chain 纯函数(result 收窄/相位映射/resume 组装)。 复用 useJobPoll 轮询、ConflictCard + decisions.ts 裁决、normalizeConflicts/latestReview、 friendlyError;nav 加 chains 入口。awaiting(interrupt)→ 读该章冲突渲 ConflictCard → POST .../chains/runs/{job_id}/resume 续跑。gen:api 纳入 chains run/resume。 守 #5(链暂停=等裁决,token/正文不入 job result)。 --- apps/web/app/projects/[id]/chains/page.tsx | 20 ++ .../components/chain/ChainAdjudication.tsx | 176 ++++++++++++++ apps/web/components/chain/ChainPage.tsx | 119 +++++++++ apps/web/components/chain/ChainProgress.tsx | 63 +++++ apps/web/components/chain/ChainStarter.tsx | 80 +++++++ apps/web/lib/api/schema.d.ts | 226 +++++++++++++++++- apps/web/lib/api/types.ts | 6 + apps/web/lib/chain/chain.test.ts | 112 +++++++++ apps/web/lib/chain/chain.ts | 67 ++++++ apps/web/lib/nav/items.test.ts | 5 +- apps/web/lib/nav/items.ts | 4 +- 11 files changed, 874 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/projects/[id]/chains/page.tsx create mode 100644 apps/web/components/chain/ChainAdjudication.tsx create mode 100644 apps/web/components/chain/ChainPage.tsx create mode 100644 apps/web/components/chain/ChainProgress.tsx create mode 100644 apps/web/components/chain/ChainStarter.tsx create mode 100644 apps/web/lib/chain/chain.test.ts create mode 100644 apps/web/lib/chain/chain.ts diff --git a/apps/web/app/projects/[id]/chains/page.tsx b/apps/web/app/projects/[id]/chains/page.tsx new file mode 100644 index 0000000..67baf19 --- /dev/null +++ b/apps/web/app/projects/[id]/chains/page.tsx @@ -0,0 +1,20 @@ +import { notFound } from "next/navigation"; + +import { ChainPage } from "@/components/chain/ChainPage"; +import { fetchProject } from "@/lib/api/server"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +// 多章工作流链页(Scope B B1)。Server Component 取项目;ChainPage(Client)承载 +// 发起 → job 轮询进度 → interrupt 裁决 → resume 续跑交互。 +export default async function ChainsRoutePage({ params }: PageProps) { + const { id } = await params; + try { + const project = await fetchProject(id); + return ; + } catch { + notFound(); + } +} diff --git a/apps/web/components/chain/ChainAdjudication.tsx b/apps/web/components/chain/ChainAdjudication.tsx new file mode 100644 index 0000000..7ac13d2 --- /dev/null +++ b/apps/web/components/chain/ChainAdjudication.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { ConflictCard } from "@/components/review/ConflictCard"; +import { useToast } from "@/components/Toast"; +import { api } from "@/lib/api/client"; +import { buildResumeRequest } from "@/lib/chain/chain"; +import { friendlyError } from "@/lib/errors/messages"; +import type { ReviewConflict } from "@/lib/review/sse"; +import { + allResolved, + emptyDecisions, + setNote, + setVerdict, + unresolvedIndices, + type DecisionDraft, + type Verdict, +} from "@/lib/review/decisions"; +import { latestReview, normalizeConflicts } from "@/lib/review/history"; + +interface ChainAdjudicationProps { + projectId: string; + jobId: string; + // interrupt 命中的章号(读其最近审稿冲突供裁决)。 + chapterNo: number; + // 续跑发起后回调(父层重启轮询)。 + onResumed: () => void; +} + +type LoadState = "loading" | "ready" | "error"; + +// 链裁决面板(净新但复用满):读 awaiting 章最近审稿 → 渲 ConflictCard(每冲突一张,复用审稿页 +// 组件 + decisions.ts 纯逻辑)→ 全决后 POST resume(buildResumeRequest 复用 accept 裁决组装)。 +// 链无在手草稿正文 → 无段内定位(snippet=null/locatable=false),裁决纯靠 suggestion + 补丁对。 +export function ChainAdjudication({ + projectId, + jobId, + chapterNo, + onResumed, +}: ChainAdjudicationProps) { + const toast = useToast(); + const [loadState, setLoadState] = useState("loading"); + const [conflicts, setConflicts] = useState([]); + const [drafts, setDrafts] = useState([]); + const [resuming, setResuming] = useState(false); + + useEffect(() => { + let active = true; + setLoadState("loading"); + void (async () => { + try { + const { data, error } = await api.GET( + "/projects/{project_id}/chapters/{chapter_no}/reviews", + { + params: { + path: { project_id: projectId, chapter_no: chapterNo }, + }, + }, + ); + if (!active) return; + if (error || !data) { + setLoadState("error"); + return; + } + const next = normalizeConflicts(latestReview(data.reviews)); + setConflicts(next); + setDrafts(emptyDecisions(next.length)); + setLoadState("ready"); + } catch { + if (active) setLoadState("error"); + } + })(); + return () => { + active = false; + }; + }, [projectId, chapterNo]); + + const onVerdict = useCallback((index: number, verdict: Verdict): void => { + setDrafts((prev) => setVerdict(prev, index, verdict)); + }, []); + + const onNote = useCallback((index: number, note: string): void => { + setDrafts((prev) => setNote(prev, index, note)); + }, []); + + const onResume = useCallback(async (): Promise => { + if (!allResolved(drafts)) { + toast("仍有未裁决的冲突,请先逐条裁决。", "error"); + return; + } + setResuming(true); + try { + const { error } = await api.POST( + "/projects/{project_id}/chains/runs/{job_id}/resume", + { + params: { path: { project_id: projectId, job_id: jobId } }, + body: buildResumeRequest(drafts), + }, + ); + if (error) { + toast(friendlyError(undefined).text, "error"); + setResuming(false); + return; + } + onResumed(); + } catch { + toast("续跑请求异常,请检查网络。", "error"); + setResuming(false); + } + }, [drafts, projectId, jobId, onResumed, toast]); + + if (loadState === "loading") { + return

加载第 {chapterNo} 章冲突…

; + } + if (loadState === "error") { + return ( +

+ 加载第 {chapterNo} 章审稿冲突失败,请刷新重试。 +

+ ); + } + + const pending = unresolvedIndices(drafts); + + return ( +
+

+ 第 {chapterNo} 章冲突裁决({conflicts.length}) +

+ {conflicts.length === 0 ? ( +

+ 该章无未决冲突,可直接续跑。 +

+ ) : ( +
    + {conflicts.map((conflict, i) => { + const draft = drafts[i] ?? { verdict: null, note: "" }; + return ( + {}} + /> + ); + })} +
+ )} + + + {pending.length > 0 ? ( +

+ 还有 {pending.length} 条冲突未裁决。 +

+ ) : null} +
+ ); +} diff --git a/apps/web/components/chain/ChainPage.tsx b/apps/web/components/chain/ChainPage.tsx new file mode 100644 index 0000000..9b00f63 --- /dev/null +++ b/apps/web/components/chain/ChainPage.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { AppShell } from "@/components/AppShell"; +import { useToast } from "@/components/Toast"; +import { api } from "@/lib/api/client"; +import { chainPhase, chainResultView } from "@/lib/chain/chain"; +import { friendlyError } from "@/lib/errors/messages"; +import { useJobPoll } from "@/lib/jobs/useJobPoll"; +import type { ProjectResponse } from "@/lib/api/types"; +import { ChainAdjudication } from "./ChainAdjudication"; +import { ChainProgress } from "./ChainProgress"; +import { ChainStarter } from "./ChainStarter"; + +interface ChainPageProps { + project: ProjectResponse; +} + +const CHAIN_KEY = "draft_volume"; + +// 多章工作流链页(Scope B B1):发起链(POST run)→job 轮询进度→interrupt 命中则读该章冲突裁决→ +// POST resume 续跑。复用 useJobPoll(轮询)+ ConflictCard/decisions(裁决)+ friendlyError(文案)。 +// 链是后台任务,走 job 轮询而非 SSE。awaiting 时停轮询(后端把 job 置 awaiting_input, +// narrowJob 抹平成 queued 会无限轮询)→ 裁决面板 resume 后再重启轮询同一 job。 +export function ChainPage({ project }: ChainPageProps) { + const poll = useJobPoll(); + const toast = useToast(); + const [jobId, setJobId] = useState(null); + const [starting, setStarting] = useState(false); + + const job = poll.job; + const result = useMemo( + () => (job ? chainResultView(job) : null), + [job], + ); + const awaitingChapter = result?.awaitingChapter ?? null; + const phase = chainPhase(poll.status, awaitingChapter); + + // awaiting 命中:停轮询(后端 job=awaiting_input 被 narrowJob 抹成 queued 会空转)。 + useEffect(() => { + if (phase === "awaiting") poll.reset(); + }, [phase, poll]); + + const onStart = useCallback( + async (startChapterNo: number, count: number): Promise => { + setStarting(true); + try { + const { data, error } = await api.POST( + "/projects/{project_id}/chains/{chain_key}/run", + { + params: { + path: { project_id: project.id, chain_key: CHAIN_KEY }, + }, + body: { start_chapter_no: startChapterNo, count }, + }, + ); + if (error || !data) { + toast(friendlyError(undefined).text, "error"); + return; + } + setJobId(data.job_id); + poll.poll(data.job_id); + } catch { + toast("发起链请求异常,请检查网络。", "error"); + } finally { + setStarting(false); + } + }, + [project.id, poll, toast], + ); + + // 续跑成功:重启轮询同一 job(resume 已 202,图从检查点续)。 + const onResumed = useCallback((): void => { + if (jobId) poll.poll(jobId); + }, [jobId, poll]); + + const showProgress = jobId !== null && result !== null; + // 链运行中/等待裁决时禁止重复发起。 + const busy = starting || (jobId !== null && phase === "running"); + + return ( + +
+ void onStart(s, c)} disabled={busy} /> + + {showProgress && result ? ( + + ) : null} + + {phase === "awaiting" && + awaitingChapter !== null && + jobId !== null ? ( + + ) : null} + + {phase === "failed" ? ( +

+ {poll.error ?? "链运行失败,请重试。"} +

+ ) : null} + + {phase === "completed" ? ( +

本链已全部跑完。

+ ) : null} +
+
+ ); +} diff --git a/apps/web/components/chain/ChainProgress.tsx b/apps/web/components/chain/ChainProgress.tsx new file mode 100644 index 0000000..b8b68e5 --- /dev/null +++ b/apps/web/components/chain/ChainProgress.tsx @@ -0,0 +1,63 @@ +"use client"; + +import type { ChainPhase, ChainResultView } from "@/lib/chain/chain"; + +interface ChainProgressProps { + phase: ChainPhase; + progress: number; + result: ChainResultView; +} + +const PHASE_LABEL: Record = { + running: "运行中", + awaiting: "等待裁决", + completed: "已完成", + failed: "已失败", +}; + +// 链进度视图:相位徽标 + 进度条 + 已写章号清单(token/正文绝不展示,result 只含元数据)。 +export function ChainProgress({ phase, progress, result }: ChainProgressProps) { + return ( +
+
+

链进度

+ + {PHASE_LABEL[phase]} + +
+ +
+
+
+ +

+ 已完成 {result.written.length} 章 + {result.written.length > 0 ? ( + + ({result.written.join("、")}) + + ) : null} +

+ {phase === "awaiting" && result.awaitingChapter !== null ? ( +

+ 第 {result.awaitingChapter} 章存在未决冲突,请在下方逐条裁决后续跑。 +

+ ) : null} +
+ ); +} diff --git a/apps/web/components/chain/ChainStarter.tsx b/apps/web/components/chain/ChainStarter.tsx new file mode 100644 index 0000000..5ae691d --- /dev/null +++ b/apps/web/components/chain/ChainStarter.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; + +interface ChainStarterProps { + // 发起一条链(起始章 + 章数);父层负责 POST run + 轮询。 + onStart: (startChapterNo: number, count: number) => void; + // 链正在运行/等待裁决时禁用(避免重复发起)。 + disabled: boolean; +} + +const DEFAULT_START = 1; +const DEFAULT_COUNT = 3; +const MAX_COUNT = 50; + +// 链发起表单(净新):选起始章号 + 连续写几章 → 调 onStart。 +// count 1..50(对齐后端 ChainRunRequest Field 约束;前端先拦一道,越界后端 422 兜底)。 +export function ChainStarter({ onStart, disabled }: ChainStarterProps) { + const [start, setStart] = useState(String(DEFAULT_START)); + const [count, setCount] = useState(String(DEFAULT_COUNT)); + + const startNo = Number.parseInt(start, 10); + const countNo = Number.parseInt(count, 10); + const valid = + Number.isInteger(startNo) && + startNo >= 1 && + Number.isInteger(countNo) && + countNo >= 1 && + countNo <= MAX_COUNT; + + return ( +
{ + e.preventDefault(); + if (valid && !disabled) onStart(startNo, countNo); + }} + > +
+

连续写多章

+

+ 从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。 +

+
+
+ + +
+ +
+ ); +} diff --git a/apps/web/lib/api/schema.d.ts b/apps/web/lib/api/schema.d.ts index 20229b1..c8b9ac0 100644 --- a/apps/web/lib/api/schema.d.ts +++ b/apps/web/lib/api/schema.d.ts @@ -549,6 +549,55 @@ export interface paths { patch?: never; trace?: never; }; + "/projects/{project_id}/chains/{chain_key}/run": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Run Chain + * @description 发起多章链:写一行 job 返 202,链经 BackgroundTask 异步跑。 + * + * 项目不存在 → 404;未知 chain_key → 404;无凭据 → 503(dep 解析阶段拦下); + * `count` 越界由 schema Field 在解析阶段 → 422。`gateway` 仅做请求阶段凭据探测—— + * 链本体在 BackgroundTask 里用独立 session 重建网关跑(请求 session 即将关闭)。 + */ + post: operations["run_chain_projects__project_id__chains__chain_key__run_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/projects/{project_id}/chains/runs/{job_id}/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Resume Chain + * @description 裁决续跑:仅当 job=awaiting_input,带裁决经 BackgroundTask resume。 + * + * 项目不存在 → 404;job 不存在 / 不属于该项目 → 404(同案,不枚举);非 awaiting 态 → + * 409(CONFLICT)。`awaiting→running` 在 HTTP 处理器内**原子抢占**(条件 UPDATE),避免两个 + * 并发 resume 都过守卫后双 `Command(resume=...)` 损坏图(审评 #3);抢不到(已被并发抢走/非 + * awaiting)→ 409。resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id)。 + */ + post: operations["resume_chain_projects__project_id__chains_runs__job_id__resume_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/settings/providers": { parameters: { query?: never; @@ -721,6 +770,68 @@ export interface components { */ thinking: boolean; }; + /** + * ChainResumeAccepted + * @description resume 受理回执(202):job_id + chain_key(前端走 `GET /jobs/{id}` 轮询续跑进度)。 + * + * 续跑无独立区间(图从检查点续),故不回显 start/count(会是无意义哨兵值)——只回 job 标识。 + */ + ChainResumeAccepted: { + /** + * Job Id + * Format: uuid + */ + job_id: string; + /** Chain Key */ + chain_key: string; + }; + /** + * ChainResumeRequest + * @description 裁决续跑:对 awaiting 章最近审稿的每个冲突逐条裁决(复用 `ConflictDecision`)。 + */ + ChainResumeRequest: { + /** + * Decisions + * @description 对 awaiting 章冲突的裁决清单(采纳/忽略/手改) + */ + decisions?: components["schemas"]["ConflictDecision"][]; + }; + /** + * ChainRunAccepted + * @description 链受理回执(202):job_id + 回显请求参数(前端走 `GET /jobs/{id}` 轮询进度/awaiting)。 + */ + ChainRunAccepted: { + /** + * Job Id + * Format: uuid + */ + job_id: string; + /** Chain Key */ + chain_key: string; + /** Start Chapter No */ + start_chapter_no: number; + /** Count */ + count: number; + }; + /** + * ChainRunRequest + * @description 发起多章链:从 `start_chapter_no` 起循环写 `count` 章(区间含两端)。 + * + * `count` 1..50(>50 → 422 VALIDATION,由 Field 约束在边界拦下,不进 BackgroundTask); + * `start_chapter_no >= 1`。 + */ + ChainRunRequest: { + /** + * Start Chapter No + * @description 起始章号(含),>=1 + */ + start_chapter_no: number; + /** + * Count + * @description 本 run 连续写的章数(1..50,含起始章) + */ + count: number; + }; /** * CharacterCardView * @description 单张角色卡(贴 ww_agents.CharacterCard;预览 + 入库请求共用形)。 @@ -1723,9 +1834,14 @@ export interface components { * @default */ brief: string; + /** + * Text + * @description 原文输入(扩写/降AI 的原文、拆书的样本;text_input 策略工具用) + */ + text?: string | null; /** * Chapter No - * @description 按章展开类工具的章号(开篇/细纲) + * @description 按章展开/续写类工具的章号(开篇/细纲/续写) */ chapter_no?: number | null; /** @@ -3074,6 +3190,114 @@ export interface operations { }; }; }; + run_chain_projects__project_id__chains__chain_key__run_post: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + chain_key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChainRunRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChainRunAccepted"]; + }; + }; + /** @description 项目或链 key 不存在 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description 请求参数非法(count 越界等) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description 无可用 LLM 凭据 */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + resume_chain_projects__project_id__chains_runs__job_id__resume_post: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + job_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChainResumeRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChainResumeAccepted"]; + }; + }; + /** @description 项目或 job 不存在 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description job 非 awaiting_input 态,不可续跑 */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_providers_settings_providers_get: { parameters: { query?: never; diff --git a/apps/web/lib/api/types.ts b/apps/web/lib/api/types.ts index eacda3c..1d6e905 100644 --- a/apps/web/lib/api/types.ts +++ b/apps/web/lib/api/types.ts @@ -30,6 +30,12 @@ export type ConflictDecision = components["schemas"]["ConflictDecision"]; export type AcceptRequest = components["schemas"]["AcceptRequest"]; export type AcceptResponse = components["schemas"]["AcceptResponse"]; +// Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。 +export type ChainRunRequest = components["schemas"]["ChainRunRequest"]; +export type ChainRunAccepted = components["schemas"]["ChainRunAccepted"]; +export type ChainResumeRequest = components["schemas"]["ChainResumeRequest"]; +export type ChainResumeAccepted = components["schemas"]["ChainResumeAccepted"]; + // M3 伏笔 + 大纲(C3 扩 T3.2/T3.5)。 export type ForeshadowView = components["schemas"]["ForeshadowView"]; export type ForeshadowBoardResponse = diff --git a/apps/web/lib/chain/chain.test.ts b/apps/web/lib/chain/chain.test.ts new file mode 100644 index 0000000..8b7225a --- /dev/null +++ b/apps/web/lib/chain/chain.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { emptyDecisions, setNote, setVerdict } from "@/lib/review/decisions"; +import type { JobView } from "@/lib/jobs/job"; +import { + buildResumeRequest, + chainPhase, + chainResultView, +} from "./chain"; + +function job(result: Record | null): JobView { + return { + id: "j1", + kind: "chain", + status: "running", + progress: 0, + result, + error: null, + }; +} + +describe("chainResultView", () => { + it("narrows a completed chain result", () => { + const view = chainResultView( + job({ + chain_key: "draft_volume", + written: [1, 2, 3], + completed: true, + awaiting_chapter: null, + }), + ); + expect(view).toEqual({ + chainKey: "draft_volume", + written: [1, 2, 3], + awaitingChapter: null, + completed: true, + }); + }); + + it("narrows an awaiting result (interrupt on a chapter)", () => { + const view = chainResultView( + job({ + chain_key: "draft_volume", + written: [1], + completed: false, + awaiting_chapter: 2, + }), + ); + expect(view.awaitingChapter).toBe(2); + expect(view.completed).toBe(false); + expect(view.written).toEqual([1]); + }); + + it("returns safe defaults for null/garbage result", () => { + expect(chainResultView(job(null))).toEqual({ + chainKey: null, + written: [], + awaitingChapter: null, + completed: false, + }); + expect( + chainResultView(job({ written: "nope", awaiting_chapter: "x" })).written, + ).toEqual([]); + }); + + it("keeps only finite integer chapter numbers in written", () => { + const view = chainResultView( + job({ written: [1, "2", 3.5, null, 4] }), + ); + expect(view.written).toEqual([1, 4]); + }); +}); + +describe("chainPhase", () => { + it("maps polling with no awaiting chapter to running", () => { + expect(chainPhase("polling", null)).toBe("running"); + }); + + it("maps any awaiting chapter to awaiting (interrupt signal is authoritative)", () => { + // 后端 interrupt 时置 status=awaiting_input(被 narrowJob 抹平成 queued→仍 polling), + // 但 result.awaiting_chapter 已置数;运行中该值恒 null,故它是暂停的权威信号。 + expect(chainPhase("polling", 2)).toBe("awaiting"); + expect(chainPhase("done", 2)).toBe("awaiting"); + }); + + it("maps done + no awaiting to completed", () => { + expect(chainPhase("done", null)).toBe("completed"); + }); + + it("maps error to failed (even if an awaiting chapter lingers)", () => { + expect(chainPhase("error", null)).toBe("failed"); + expect(chainPhase("error", 2)).toBe("failed"); + }); +}); + +describe("buildResumeRequest", () => { + it("includes only resolved decisions and trims notes (reuses accept decision logic)", () => { + let drafts = emptyDecisions(3); + drafts = setVerdict(drafts, 0, "accept"); + drafts = setVerdict(drafts, 2, "manual"); + drafts = setNote(drafts, 2, " 改成后天淬炼 "); + const req = buildResumeRequest(drafts); + expect(req.decisions).toEqual([ + { conflict_index: 0, verdict: "accept" }, + { conflict_index: 2, verdict: "manual", note: "改成后天淬炼" }, + ]); + }); + + it("empty drafts → empty decisions list", () => { + expect(buildResumeRequest([])).toEqual({ decisions: [] }); + }); +}); diff --git a/apps/web/lib/chain/chain.ts b/apps/web/lib/chain/chain.ts new file mode 100644 index 0000000..fe54e41 --- /dev/null +++ b/apps/web/lib/chain/chain.ts @@ -0,0 +1,67 @@ +// 多章工作流链前端纯逻辑(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"; + +// 链 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 }; +} diff --git a/apps/web/lib/nav/items.test.ts b/apps/web/lib/nav/items.test.ts index 757e297..085745f 100644 --- a/apps/web/lib/nav/items.test.ts +++ b/apps/web/lib/nav/items.test.ts @@ -10,15 +10,16 @@ import { const PID = "p1"; describe("projectNavItems", () => { - it("builds the nine project entries scoped to the project id", () => { + it("builds the ten project entries scoped to the project id", () => { const items = projectNavItems(PID); - expect(items).toHaveLength(9); + expect(items).toHaveLength(10); expect(items.map((i) => i.key)).toEqual([ "write", "outline", "foreshadow", "review", + "chains", "style", "codex", "toolbox", diff --git a/apps/web/lib/nav/items.ts b/apps/web/lib/nav/items.ts index f2979aa..687c65d 100644 --- a/apps/web/lib/nav/items.ts +++ b/apps/web/lib/nav/items.ts @@ -10,7 +10,8 @@ export type ActiveNav = | "codex" | "rules" | "skills" - | "toolbox"; + | "toolbox" + | "chains"; export interface NavItem { href: string; @@ -35,6 +36,7 @@ export function projectNavItems(projectId: string): NavItem[] { key: "foreshadow", }, { href: `/projects/${projectId}/review`, label: "审稿", key: "review" }, + { href: `/projects/${projectId}/chains`, label: "多章链", key: "chains" }, { href: `/projects/${projectId}/style`, label: "文风", key: "style" }, { href: `/projects/${projectId}/codex`, label: "设定库", key: "codex" }, { href: `/projects/${projectId}/toolbox`, label: "工具箱", key: "toolbox" },