feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)
- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点 - 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件 - 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板 - 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE - bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试 - M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug - 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
This commit is contained in:
370
apps/web/lib/api/schema.d.ts
vendored
370
apps/web/lib/api/schema.d.ts
vendored
@@ -169,7 +169,14 @@ export interface paths {
|
||||
/**
|
||||
* Accept Chapter
|
||||
* @description 验收事务 + 冲突 gate(§5.5):gate(事务前)→ 终稿提炼 digest(事务外,R2)→
|
||||
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
|
||||
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
|
||||
* BackgroundTask(M3-b/d,§6.2)。
|
||||
*
|
||||
* 到期扫描接 §5.5 步骤 3 的 `TODO(M3)` 占位:验收提交成功**后**,把
|
||||
* `current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 OVERDUE。
|
||||
* 伏笔登记/回收(PARTIAL/CLOSED)的「采纳建议」经作者经 foreshadow 端点显式确认,
|
||||
* 不在验收事务里 AI 静默改库(不变量 #3)。**§7.4 持久性局限**:BackgroundTask 进程内
|
||||
* 跑、重启丢失——原型可接受(见 `services/foreshadow_scan.py`,不引入 jobs 表)。
|
||||
*/
|
||||
post: operations["accept_chapter_projects__project_id__chapters__chapter_no__accept_post"];
|
||||
delete?: never;
|
||||
@@ -178,6 +185,75 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/foreshadow": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List Foreshadow
|
||||
* @description 伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
|
||||
*
|
||||
* `status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE)→ VALIDATION 信封。四泳道前端
|
||||
* 据 `status` 分组;OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
|
||||
*/
|
||||
get: operations["list_foreshadow_projects__project_id__foreshadow_get"];
|
||||
put?: never;
|
||||
/**
|
||||
* Register Foreshadow
|
||||
* @description 登记一条伏笔(status=OPEN)。重复 `code` → DB 唯一约束冲突 → VALIDATION 信封。
|
||||
*/
|
||||
post: operations["register_foreshadow_projects__project_id__foreshadow_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/foreshadow/{code}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Update Foreshadow
|
||||
* @description 状态机转移 和/或 追加进展。非法转移 → VALIDATION 信封;不存在 → NOT_FOUND。
|
||||
*
|
||||
* `to_status` 与 `progress_entry` 皆可选;两者都缺 → VALIDATION(无操作)。先转移、后追加。
|
||||
*/
|
||||
patch: operations["update_foreshadow_projects__project_id__foreshadow__code__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/outline": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Generate Outline
|
||||
* @description 生成大纲并逐章持久化。无凭据 → LLM_UNAVAILABLE;项目不存在 → NOT_FOUND。
|
||||
*/
|
||||
post: operations["generate_outline_projects__project_id__outline_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings/providers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -338,11 +414,164 @@ export interface components {
|
||||
/** Text */
|
||||
text: string;
|
||||
};
|
||||
/**
|
||||
* ForeshadowBoardResponse
|
||||
* @description GET /projects/:id/foreshadow?status=:伏笔看板(四泳道 + OVERDUE 字段齐)。
|
||||
*
|
||||
* `status` 缺省返回全部;按 `code` 升序(repo `list_by_status` 已排序)。前端按
|
||||
* `status` 分四泳道(OPEN/PARTIAL/CLOSED/OVERDUE),用 `expected_close_to` 标逾期。
|
||||
*/
|
||||
ForeshadowBoardResponse: {
|
||||
/** Foreshadow */
|
||||
foreshadow?: components["schemas"]["ForeshadowView"][];
|
||||
};
|
||||
/**
|
||||
* ForeshadowRegisterRequest
|
||||
* @description POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。
|
||||
*/
|
||||
ForeshadowRegisterRequest: {
|
||||
/**
|
||||
* Code
|
||||
* @description 伏笔代号,`(project_id, code)` 唯一
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* Title
|
||||
* @description 伏笔标题/一句话描述
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Planted At
|
||||
* @description 埋设章号
|
||||
*/
|
||||
planted_at?: number | null;
|
||||
/**
|
||||
* Content
|
||||
* @description 伏笔正文/线索
|
||||
*/
|
||||
content?: string | null;
|
||||
/**
|
||||
* Expected Close From
|
||||
* @description 预期回收窗口起始章
|
||||
*/
|
||||
expected_close_from?: number | null;
|
||||
/**
|
||||
* Expected Close To
|
||||
* @description 预期回收窗口结束章(到期判据用)
|
||||
*/
|
||||
expected_close_to?: number | null;
|
||||
/**
|
||||
* Importance
|
||||
* @description 重要度(自由文本,看板用)
|
||||
*/
|
||||
importance?: string | null;
|
||||
};
|
||||
/**
|
||||
* ForeshadowTransitionRequest
|
||||
* @description PATCH /projects/:id/foreshadow/:code:状态机转移 和/或 追加一条进展。
|
||||
*
|
||||
* 两字段皆可选:`to_status` 走状态机(非法 → VALIDATION 信封);`progress_entry`
|
||||
* append-only 追加到 progress JSONB。二者可同时给(先转移、后追加进展)。
|
||||
*/
|
||||
ForeshadowTransitionRequest: {
|
||||
/**
|
||||
* To Status
|
||||
* @description 目标状态(OPEN/PARTIAL/CLOSED/OVERDUE)
|
||||
*/
|
||||
to_status?: string | null;
|
||||
/**
|
||||
* Progress Entry
|
||||
* @description 追加一条进展记录(append-only,不覆盖历史)
|
||||
*/
|
||||
progress_entry?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
/**
|
||||
* ForeshadowView
|
||||
* @description 伏笔账本视图(登记/状态变更/看板共用;snake_case)。形对齐 `ForeshadowLedgerView`。
|
||||
*/
|
||||
ForeshadowView: {
|
||||
/** Code */
|
||||
code: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Status */
|
||||
status: string;
|
||||
/** Planted At */
|
||||
planted_at?: number | null;
|
||||
/** Content */
|
||||
content?: string | null;
|
||||
/** Expected Close From */
|
||||
expected_close_from?: number | null;
|
||||
/** Expected Close To */
|
||||
expected_close_to?: number | null;
|
||||
/** Importance */
|
||||
importance?: string | null;
|
||||
/** Links */
|
||||
links?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
/** Progress */
|
||||
progress?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
};
|
||||
/**
|
||||
* ForeshadowWindowView
|
||||
* @description 单章关联的伏笔回收窗口(snake_case;形对齐 `ww_agents.ForeshadowWindow`)。
|
||||
*/
|
||||
ForeshadowWindowView: {
|
||||
/** Code */
|
||||
code: string;
|
||||
/** Plant Chapter */
|
||||
plant_chapter?: number | null;
|
||||
/** Expected Close From */
|
||||
expected_close_from?: number | null;
|
||||
/** Expected Close To */
|
||||
expected_close_to?: number | null;
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/**
|
||||
* OutlineChapterView
|
||||
* @description 单章大纲视图:章号 + 卷 + 节拍 + 伏笔窗口。
|
||||
*/
|
||||
OutlineChapterView: {
|
||||
/** No */
|
||||
no: number;
|
||||
/** Volume */
|
||||
volume: number;
|
||||
/** Beats */
|
||||
beats?: string[];
|
||||
/** Foreshadow Windows */
|
||||
foreshadow_windows?: components["schemas"]["ForeshadowWindowView"][];
|
||||
};
|
||||
/**
|
||||
* OutlineGenerateRequest
|
||||
* @description POST /projects/:id/outline:触发大纲生成 + 持久化。
|
||||
*
|
||||
* M3 简单分卷:`volume` 缺省=1(schema 无逐章卷号;本端点把整批章节落到同一卷)。
|
||||
*/
|
||||
OutlineGenerateRequest: {
|
||||
/**
|
||||
* Volume
|
||||
* @description 本批大纲所属卷号(M3 默认全卷 1)
|
||||
* @default 1
|
||||
*/
|
||||
volume: number;
|
||||
};
|
||||
/**
|
||||
* OutlineResponse
|
||||
* @description 大纲生成响应:逐章已持久化的大纲(含窗口,供前端 T3.6)。
|
||||
*/
|
||||
OutlineResponse: {
|
||||
/** Chapters */
|
||||
chapters?: components["schemas"]["OutlineChapterView"][];
|
||||
};
|
||||
/**
|
||||
* ProjectCreateRequest
|
||||
* @description POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。
|
||||
@@ -898,6 +1127,145 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_foreshadow_projects__project_id__foreshadow_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowBoardResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
register_foreshadow_projects__project_id__foreshadow_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowRegisterRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowView"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_foreshadow_projects__project_id__foreshadow__code__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
code: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowTransitionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowView"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
generate_outline_projects__project_id__outline_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["OutlineGenerateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OutlineResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_providers_settings_providers_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serverApiBase } from "./config";
|
||||
import type {
|
||||
ForeshadowBoardResponse,
|
||||
ProjectListResponse,
|
||||
ProjectResponse,
|
||||
ProvidersResponse,
|
||||
@@ -35,3 +36,10 @@ export async function fetchReviews(
|
||||
`/projects/${projectId}/chapters/${chapterNo}/reviews`,
|
||||
);
|
||||
}
|
||||
|
||||
// 伏笔看板(全量,前端按 status 分四泳道)。
|
||||
export async function fetchForeshadow(
|
||||
projectId: string,
|
||||
): Promise<ForeshadowBoardResponse> {
|
||||
return getJson<ForeshadowBoardResponse>(`/projects/${projectId}/foreshadow`);
|
||||
}
|
||||
|
||||
@@ -25,3 +25,18 @@ export type ReviewHistoryResponse =
|
||||
export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||
export type AcceptRequest = components["schemas"]["AcceptRequest"];
|
||||
export type AcceptResponse = components["schemas"]["AcceptResponse"];
|
||||
|
||||
// M3 伏笔 + 大纲(C3 扩 T3.2/T3.5)。
|
||||
export type ForeshadowView = components["schemas"]["ForeshadowView"];
|
||||
export type ForeshadowBoardResponse =
|
||||
components["schemas"]["ForeshadowBoardResponse"];
|
||||
export type ForeshadowRegisterRequest =
|
||||
components["schemas"]["ForeshadowRegisterRequest"];
|
||||
export type ForeshadowTransitionRequest =
|
||||
components["schemas"]["ForeshadowTransitionRequest"];
|
||||
export type ForeshadowWindowView =
|
||||
components["schemas"]["ForeshadowWindowView"];
|
||||
export type OutlineChapterView = components["schemas"]["OutlineChapterView"];
|
||||
export type OutlineGenerateRequest =
|
||||
components["schemas"]["OutlineGenerateRequest"];
|
||||
export type OutlineResponse = components["schemas"]["OutlineResponse"];
|
||||
|
||||
134
apps/web/lib/foreshadow/board.test.ts
Normal file
134
apps/web/lib/foreshadow/board.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import {
|
||||
buildRegisterRequest,
|
||||
buildTransitionRequest,
|
||||
extractReason,
|
||||
groupByStatus,
|
||||
isWindowApproaching,
|
||||
validationReasonMessage,
|
||||
} from "./board";
|
||||
|
||||
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
|
||||
code: "F-001",
|
||||
title: "线索",
|
||||
status: "OPEN",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("groupByStatus", () => {
|
||||
it("splits items into four lanes preserving order", () => {
|
||||
const lanes = groupByStatus([
|
||||
view({ code: "F-001", status: "OPEN" }),
|
||||
view({ code: "F-002", status: "OVERDUE" }),
|
||||
view({ code: "F-003", status: "OPEN" }),
|
||||
view({ code: "F-004", status: "CLOSED" }),
|
||||
view({ code: "F-005", status: "PARTIAL" }),
|
||||
]);
|
||||
expect(lanes.OPEN.map((v) => v.code)).toEqual(["F-001", "F-003"]);
|
||||
expect(lanes.OVERDUE.map((v) => v.code)).toEqual(["F-002"]);
|
||||
expect(lanes.CLOSED.map((v) => v.code)).toEqual(["F-004"]);
|
||||
expect(lanes.PARTIAL.map((v) => v.code)).toEqual(["F-005"]);
|
||||
});
|
||||
|
||||
it("falls back unknown status to OPEN and handles undefined", () => {
|
||||
const lanes = groupByStatus([view({ status: "WEIRD" })]);
|
||||
expect(lanes.OPEN).toHaveLength(1);
|
||||
expect(groupByStatus(undefined).OPEN).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWindowApproaching", () => {
|
||||
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
|
||||
const f = view({
|
||||
status: "PARTIAL",
|
||||
expected_close_from: 40,
|
||||
expected_close_to: 60,
|
||||
});
|
||||
expect(isWindowApproaching(f, 50)).toBe(true);
|
||||
expect(isWindowApproaching(f, 39)).toBe(false);
|
||||
expect(isWindowApproaching(f, 61)).toBe(false);
|
||||
});
|
||||
|
||||
it("never flags CLOSED/OVERDUE or items without a window", () => {
|
||||
expect(
|
||||
isWindowApproaching(
|
||||
view({ status: "CLOSED", expected_close_to: 60 }),
|
||||
50,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isWindowApproaching(view({ status: "OPEN" }), 50)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRegisterRequest", () => {
|
||||
it("trims and omits empty optionals", () => {
|
||||
expect(
|
||||
buildRegisterRequest({
|
||||
projectId: "p1",
|
||||
code: " F-007 ",
|
||||
title: " 旧誓约 ",
|
||||
content: " ",
|
||||
expectedCloseTo: 110,
|
||||
}),
|
||||
).toEqual({ code: "F-007", title: "旧誓约", expected_close_to: 110 });
|
||||
});
|
||||
|
||||
it("keeps numeric and non-empty string optionals", () => {
|
||||
expect(
|
||||
buildRegisterRequest({
|
||||
projectId: "p1",
|
||||
code: "F-1",
|
||||
title: "t",
|
||||
plantedAt: 8,
|
||||
content: "线索",
|
||||
expectedCloseFrom: 40,
|
||||
importance: "主线",
|
||||
}),
|
||||
).toEqual({
|
||||
code: "F-1",
|
||||
title: "t",
|
||||
planted_at: 8,
|
||||
content: "线索",
|
||||
expected_close_from: 40,
|
||||
importance: "主线",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTransitionRequest", () => {
|
||||
it("includes only provided fields", () => {
|
||||
expect(buildTransitionRequest({ projectId: "p1", toStatus: "PARTIAL" })).toEqual({
|
||||
to_status: "PARTIAL",
|
||||
});
|
||||
expect(
|
||||
buildTransitionRequest({
|
||||
projectId: "p1",
|
||||
progressEntry: { chapter: 23, note: "发光" },
|
||||
}),
|
||||
).toEqual({ progress_entry: { chapter: 23, note: "发光" } });
|
||||
expect(buildTransitionRequest({ projectId: "p1" })).toEqual({});
|
||||
expect(
|
||||
buildTransitionRequest({ projectId: "p1", progressEntry: {} }),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validationReasonMessage / extractReason", () => {
|
||||
it("maps known reasons to friendly text", () => {
|
||||
expect(validationReasonMessage("duplicate")).toContain("已存在");
|
||||
expect(validationReasonMessage("invalid_transition")).toContain("终态");
|
||||
expect(validationReasonMessage("empty_update")).toContain("进展");
|
||||
expect(validationReasonMessage("invalid_status")).toContain("筛选");
|
||||
expect(validationReasonMessage(undefined)).toContain("校验");
|
||||
});
|
||||
|
||||
it("extracts details.reason from envelope", () => {
|
||||
expect(
|
||||
extractReason({ error: { details: { reason: "duplicate" } } }),
|
||||
).toBe("duplicate");
|
||||
expect(extractReason({ error: { details: null } })).toBeUndefined();
|
||||
expect(extractReason("oops")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
141
apps/web/lib/foreshadow/board.ts
Normal file
141
apps/web/lib/foreshadow/board.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// 伏笔看板纯逻辑:四泳道分组、登记/转移请求体组装、422 reason 映射。
|
||||
// 对齐 C3 扩(GET/POST/PATCH .../foreshadow)。纯逻辑,便于 node 环境单测。
|
||||
|
||||
import type {
|
||||
ForeshadowRegisterRequest,
|
||||
ForeshadowTransitionRequest,
|
||||
ForeshadowView,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
// 四状态泳道(UX §6.8);OVERDUE 用琥珀强调(渲染层处理)。
|
||||
export type ForeshadowStatus = "OPEN" | "PARTIAL" | "CLOSED" | "OVERDUE";
|
||||
|
||||
export const LANES: readonly ForeshadowStatus[] = [
|
||||
"OPEN",
|
||||
"PARTIAL",
|
||||
"CLOSED",
|
||||
"OVERDUE",
|
||||
] as const;
|
||||
|
||||
export const LANE_LABELS: Record<ForeshadowStatus, string> = {
|
||||
OPEN: "OPEN",
|
||||
PARTIAL: "PARTIAL",
|
||||
CLOSED: "CLOSED",
|
||||
OVERDUE: "OVERDUE",
|
||||
};
|
||||
|
||||
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
|
||||
|
||||
function emptyLanes(): ForeshadowLanes {
|
||||
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
|
||||
}
|
||||
|
||||
function isStatus(v: string): v is ForeshadowStatus {
|
||||
return v === "OPEN" || v === "PARTIAL" || v === "CLOSED" || v === "OVERDUE";
|
||||
}
|
||||
|
||||
// 按 status 分四泳道(未知 status 落 OPEN 兜底);保持 repo 已排序的 code 顺序。
|
||||
export function groupByStatus(
|
||||
items: readonly ForeshadowView[] | undefined,
|
||||
): ForeshadowLanes {
|
||||
const lanes = emptyLanes();
|
||||
for (const item of items ?? []) {
|
||||
const status = isStatus(item.status) ? item.status : "OPEN";
|
||||
lanes[status] = [...lanes[status], item];
|
||||
}
|
||||
return lanes;
|
||||
}
|
||||
|
||||
// 接近回收窗口判定:current 在 [from, to] 内,或已超 from 但尚未 CLOSED(提示安排回收)。
|
||||
// 仅对 OPEN/PARTIAL 提示;CLOSED 已回收、OVERDUE 已逾期另有强调。
|
||||
export function isWindowApproaching(
|
||||
item: ForeshadowView,
|
||||
currentChapter: number,
|
||||
): boolean {
|
||||
if (item.status === "CLOSED" || item.status === "OVERDUE") return false;
|
||||
const to = item.expected_close_to;
|
||||
if (typeof to !== "number") return false;
|
||||
const from = item.expected_close_from ?? to;
|
||||
return currentChapter >= from && currentChapter <= to;
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
projectId: string;
|
||||
code: string;
|
||||
title: string;
|
||||
plantedAt?: number | null;
|
||||
content?: string | null;
|
||||
expectedCloseFrom?: number | null;
|
||||
expectedCloseTo?: number | null;
|
||||
importance?: string | null;
|
||||
}
|
||||
|
||||
// 组装登记请求体(去空白;空可选字段省略)。
|
||||
export function buildRegisterRequest(
|
||||
input: RegisterInput,
|
||||
): ForeshadowRegisterRequest {
|
||||
const body: ForeshadowRegisterRequest = {
|
||||
code: input.code.trim(),
|
||||
title: input.title.trim(),
|
||||
};
|
||||
if (typeof input.plantedAt === "number") body.planted_at = input.plantedAt;
|
||||
const content = input.content?.trim();
|
||||
if (content) body.content = content;
|
||||
if (typeof input.expectedCloseFrom === "number")
|
||||
body.expected_close_from = input.expectedCloseFrom;
|
||||
if (typeof input.expectedCloseTo === "number")
|
||||
body.expected_close_to = input.expectedCloseTo;
|
||||
const importance = input.importance?.trim();
|
||||
if (importance) body.importance = importance;
|
||||
return body;
|
||||
}
|
||||
|
||||
export interface TransitionInput {
|
||||
projectId: string;
|
||||
toStatus?: ForeshadowStatus | null;
|
||||
// 一条进展记录(自由 dict;如 {chapter, note})。
|
||||
progressEntry?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// 组装转移/进展请求体(两字段皆可选;至少给一项由调用方校验)。
|
||||
export function buildTransitionRequest(
|
||||
input: TransitionInput,
|
||||
): ForeshadowTransitionRequest {
|
||||
const body: ForeshadowTransitionRequest = {};
|
||||
if (input.toStatus) body.to_status = input.toStatus;
|
||||
if (input.progressEntry && Object.keys(input.progressEntry).length > 0)
|
||||
body.progress_entry = input.progressEntry;
|
||||
return body;
|
||||
}
|
||||
|
||||
// 422 VALIDATION 信封 details.reason(C3 扩)→ 用户文案。
|
||||
export type ValidationReason =
|
||||
| "duplicate"
|
||||
| "invalid_transition"
|
||||
| "empty_update"
|
||||
| "invalid_status";
|
||||
|
||||
export function validationReasonMessage(reason: string | undefined): string {
|
||||
switch (reason) {
|
||||
case "duplicate":
|
||||
return "该伏笔代号已存在,请换一个。";
|
||||
case "invalid_transition":
|
||||
return "非法状态转移(CLOSED 为终态,不可再改)。";
|
||||
case "empty_update":
|
||||
return "请填写目标状态或一条进展。";
|
||||
case "invalid_status":
|
||||
return "无效的筛选状态。";
|
||||
default:
|
||||
return "操作未通过校验,请检查输入。";
|
||||
}
|
||||
}
|
||||
|
||||
// 从错误信封安全提取 details.reason。
|
||||
export function extractReason(error: unknown): string | undefined {
|
||||
if (typeof error !== "object" || error === null) return undefined;
|
||||
const env = error as {
|
||||
error?: { details?: { reason?: unknown } | null };
|
||||
};
|
||||
const reason = env.error?.details?.reason;
|
||||
return typeof reason === "string" ? reason : undefined;
|
||||
}
|
||||
96
apps/web/lib/foreshadow/useForeshadow.ts
Normal file
96
apps/web/lib/foreshadow/useForeshadow.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import {
|
||||
buildRegisterRequest,
|
||||
buildTransitionRequest,
|
||||
extractReason,
|
||||
validationReasonMessage,
|
||||
type ForeshadowStatus,
|
||||
type RegisterInput,
|
||||
type TransitionInput,
|
||||
} from "./board";
|
||||
|
||||
export interface UseForeshadow {
|
||||
items: ForeshadowView[];
|
||||
busy: boolean;
|
||||
register: (input: RegisterInput) => Promise<boolean>;
|
||||
transition: (code: string, input: TransitionInput) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// 伏笔登记/状态变更(乐观更新 + 回滚 + Toast)。
|
||||
// register:成功后把返回行追加进 items;422 duplicate → 友好提示,不改 items。
|
||||
// transition:先乐观替换该行 status,失败回滚到旧 items 并 toast(读 details.reason)。
|
||||
export function useForeshadow(initial: ForeshadowView[]): UseForeshadow {
|
||||
const [items, setItems] = useState<ForeshadowView[]>(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const register = useCallback<UseForeshadow["register"]>(
|
||||
async (input) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/foreshadow",
|
||||
{
|
||||
params: { path: { project_id: input.projectId } },
|
||||
body: buildRegisterRequest(input),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
toast(validationReasonMessage(extractReason(error)), "error");
|
||||
return false;
|
||||
}
|
||||
setItems((prev) => [...prev, data]);
|
||||
toast(`已登记伏笔 ${data.code}`, "success");
|
||||
return true;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const transition = useCallback<UseForeshadow["transition"]>(
|
||||
async (code, input) => {
|
||||
const snapshot = items;
|
||||
// 乐观:本地先改 status(若给了 toStatus)。
|
||||
if (input.toStatus) {
|
||||
const next = input.toStatus as ForeshadowStatus;
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.code === code ? { ...it, status: next } : it)),
|
||||
);
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data, error } = await api.PATCH(
|
||||
"/projects/{project_id}/foreshadow/{code}",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: input.projectId, code },
|
||||
},
|
||||
body: buildTransitionRequest(input),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
setItems(snapshot); // 回滚
|
||||
toast(validationReasonMessage(extractReason(error)), "error");
|
||||
return false;
|
||||
}
|
||||
// 用服务端权威行替换(status + progress 都对齐)。
|
||||
setItems((prev) => prev.map((it) => (it.code === code ? data : it)));
|
||||
toast(`已更新 ${data.code}`, "success");
|
||||
return true;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[items, toast],
|
||||
);
|
||||
|
||||
return { items, busy, register, transition };
|
||||
}
|
||||
63
apps/web/lib/outline/outline.test.ts
Normal file
63
apps/web/lib/outline/outline.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
ForeshadowWindowView,
|
||||
OutlineChapterView,
|
||||
} from "@/lib/api/types";
|
||||
import {
|
||||
groupByVolume,
|
||||
isCloseWindow,
|
||||
windowBadgeLabel,
|
||||
} from "./outline";
|
||||
|
||||
const ch = (over: Partial<OutlineChapterView>): OutlineChapterView => ({
|
||||
no: 1,
|
||||
volume: 1,
|
||||
beats: [],
|
||||
foreshadow_windows: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const win = (
|
||||
over: Partial<ForeshadowWindowView>,
|
||||
): ForeshadowWindowView => ({ code: "F-012", ...over });
|
||||
|
||||
describe("groupByVolume", () => {
|
||||
it("groups by volume and sorts volumes and chapters ascending", () => {
|
||||
const groups = groupByVolume([
|
||||
ch({ no: 127, volume: 3 }),
|
||||
ch({ no: 12, volume: 1 }),
|
||||
ch({ no: 126, volume: 3 }),
|
||||
]);
|
||||
expect(groups.map((g) => g.volume)).toEqual([1, 3]);
|
||||
expect(groups[1]?.chapters.map((c) => c.no)).toEqual([126, 127]);
|
||||
});
|
||||
|
||||
it("handles undefined", () => {
|
||||
expect(groupByVolume(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("windowBadgeLabel", () => {
|
||||
it("renders a range, half-open, or bare badge", () => {
|
||||
expect(
|
||||
windowBadgeLabel(win({ expected_close_from: 40, expected_close_to: 60 })),
|
||||
).toBe("⚑ F-012 (40-60)");
|
||||
expect(windowBadgeLabel(win({ expected_close_to: 60 }))).toBe(
|
||||
"⚑ F-012 (≤60)",
|
||||
);
|
||||
expect(windowBadgeLabel(win({ expected_close_from: 40 }))).toBe(
|
||||
"⚑ F-012 (≥40)",
|
||||
);
|
||||
expect(windowBadgeLabel(win({}))).toBe("⚑ F-012");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCloseWindow", () => {
|
||||
it("is true when chapter no is inside the recovery window", () => {
|
||||
const w = win({ expected_close_from: 40, expected_close_to: 60 });
|
||||
expect(isCloseWindow(ch({ no: 50 }), w)).toBe(true);
|
||||
expect(isCloseWindow(ch({ no: 61 }), w)).toBe(false);
|
||||
expect(isCloseWindow(ch({ no: 50 }), win({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
49
apps/web/lib/outline/outline.ts
Normal file
49
apps/web/lib/outline/outline.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// 大纲编辑器纯逻辑:按卷分组、伏笔窗口徽标文案、接近回收窗口提示(UX §6.7)。
|
||||
// OutlineChapterView/ForeshadowWindowView 已由 OpenAPI 强类型,无需手动收窄。
|
||||
|
||||
import type { ForeshadowWindowView, OutlineChapterView } from "@/lib/api/types";
|
||||
|
||||
export interface VolumeGroup {
|
||||
volume: number;
|
||||
chapters: OutlineChapterView[];
|
||||
}
|
||||
|
||||
// 按 volume 分组(保持章号升序);卷号升序。
|
||||
export function groupByVolume(
|
||||
chapters: readonly OutlineChapterView[] | undefined,
|
||||
): VolumeGroup[] {
|
||||
const byVolume = new Map<number, OutlineChapterView[]>();
|
||||
for (const ch of chapters ?? []) {
|
||||
const list = byVolume.get(ch.volume) ?? [];
|
||||
byVolume.set(ch.volume, [...list, ch]);
|
||||
}
|
||||
return [...byVolume.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([volume, list]) => ({
|
||||
volume,
|
||||
chapters: [...list].sort((a, b) => a.no - b.no),
|
||||
}));
|
||||
}
|
||||
|
||||
// 伏笔窗口徽标文案:`⚑ F-012 (40-60)` / `⚑ F-012 (≤60)` / `⚑ F-012`。
|
||||
export function windowBadgeLabel(window: ForeshadowWindowView): string {
|
||||
const from = window.expected_close_from;
|
||||
const to = window.expected_close_to;
|
||||
if (typeof from === "number" && typeof to === "number") {
|
||||
return `⚑ ${window.code} (${from}-${to})`;
|
||||
}
|
||||
if (typeof to === "number") return `⚑ ${window.code} (≤${to})`;
|
||||
if (typeof from === "number") return `⚑ ${window.code} (≥${from})`;
|
||||
return `⚑ ${window.code}`;
|
||||
}
|
||||
|
||||
// 接近回收:本章号落在窗口 [from,to] 内(提示「本章可回收」,UX §6.7 ⚑ 可回收)。
|
||||
export function isCloseWindow(
|
||||
chapter: OutlineChapterView,
|
||||
window: ForeshadowWindowView,
|
||||
): boolean {
|
||||
const to = window.expected_close_to;
|
||||
if (typeof to !== "number") return false;
|
||||
const from = window.expected_close_from ?? to;
|
||||
return chapter.no >= from && chapter.no <= to;
|
||||
}
|
||||
63
apps/web/lib/outline/useOutline.ts
Normal file
63
apps/web/lib/outline/useOutline.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { OutlineChapterView } from "@/lib/api/types";
|
||||
|
||||
export type OutlineStatus = "idle" | "generating" | "ready" | "error";
|
||||
|
||||
export interface OutlineError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UseOutline {
|
||||
chapters: OutlineChapterView[];
|
||||
status: OutlineStatus;
|
||||
error: OutlineError | null;
|
||||
generate: (projectId: string, volume: number) => Promise<void>;
|
||||
}
|
||||
|
||||
// 生成大纲:POST .../outline(body {volume})。无凭据/网关失败 → 503 LLM_UNAVAILABLE
|
||||
// (流前 JSON 信封),经 error 检出、引导去设置(同 draft/review 范式)。
|
||||
export function useOutline(initial: OutlineChapterView[]): UseOutline {
|
||||
const [chapters, setChapters] = useState<OutlineChapterView[]>(initial);
|
||||
const [status, setStatus] = useState<OutlineStatus>(
|
||||
initial.length > 0 ? "ready" : "idle",
|
||||
);
|
||||
const [error, setError] = useState<OutlineError | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const generate = useCallback<UseOutline["generate"]>(
|
||||
async (projectId, volume) => {
|
||||
setStatus("generating");
|
||||
setError(null);
|
||||
const { data, error: apiError } = await api.POST(
|
||||
"/projects/{project_id}/outline",
|
||||
{
|
||||
params: { path: { project_id: projectId } },
|
||||
body: { volume },
|
||||
},
|
||||
);
|
||||
if (apiError || !data) {
|
||||
const env = apiError as
|
||||
| { error?: { code?: string; message?: string } }
|
||||
| undefined;
|
||||
const code = env?.error?.code ?? "OUTLINE_FAILED";
|
||||
const message = env?.error?.message ?? "大纲生成失败,请重试。";
|
||||
setError({ code, message });
|
||||
setStatus("error");
|
||||
toast(message, "error");
|
||||
return;
|
||||
}
|
||||
setChapters(data.chapters ?? []);
|
||||
setStatus("ready");
|
||||
toast("大纲已生成", "success");
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
return { chapters, status, error, generate };
|
||||
}
|
||||
37
apps/web/lib/pace/beatmap.test.ts
Normal file
37
apps/web/lib/pace/beatmap.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SPARK_CHARS, toBeatCells, toSparkline } from "./beatmap";
|
||||
|
||||
describe("toBeatCells", () => {
|
||||
it("maps min→▁ and max→▇ via linear normalization", () => {
|
||||
const cells = toBeatCells([1, 7]);
|
||||
expect(cells[0]?.level).toBe(0);
|
||||
expect(cells[0]?.char).toBe("▁");
|
||||
expect(cells[1]?.level).toBe(SPARK_CHARS.length - 1);
|
||||
expect(cells[1]?.char).toBe("▇");
|
||||
});
|
||||
|
||||
it("centers all cells when every value is equal", () => {
|
||||
const cells = toBeatCells([5, 5, 5]);
|
||||
expect(cells.every((c) => c.level === 3)).toBe(true);
|
||||
expect(cells.every((c) => c.char === "▄")).toBe(true);
|
||||
});
|
||||
|
||||
it("gives every cell a visible min height", () => {
|
||||
const cells = toBeatCells([0, 10]);
|
||||
expect(cells[0]?.heightPct).toBeGreaterThanOrEqual(12);
|
||||
expect(cells[1]?.heightPct).toBe(100);
|
||||
});
|
||||
|
||||
it("returns [] for empty or non-finite-only input", () => {
|
||||
expect(toBeatCells([])).toEqual([]);
|
||||
expect(toBeatCells([Number.NaN, Number.POSITIVE_INFINITY])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSparkline", () => {
|
||||
it("joins chars into a sparkline string", () => {
|
||||
expect(toSparkline([1, 4, 7])).toBe("▁▄▇");
|
||||
expect(toSparkline([])).toBe("");
|
||||
});
|
||||
});
|
||||
53
apps/web/lib/pace/beatmap.ts
Normal file
53
apps/web/lib/pace/beatmap.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 爽点节拍图:把 pace.beat_map(int 序列)映射成 ▁▃▅ 风格的 sparkline 格子(UX §6.4)。
|
||||
// 纯逻辑,便于 node 环境单测;渲染时配合高度类与 prefers-reduced-motion。
|
||||
|
||||
// 七级火花字符(低→高),与高度档位一一对应。
|
||||
export const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇"] as const;
|
||||
|
||||
export interface BeatCell {
|
||||
// 原始强度值。
|
||||
value: number;
|
||||
// 归一化档位 0..6(映射到 SPARK_CHARS / 高度百分比)。
|
||||
level: number;
|
||||
// 对应火花字符(▁..▇)。
|
||||
char: string;
|
||||
// 渲染用高度百分比(12%..100%,最低也可见)。
|
||||
heightPct: number;
|
||||
}
|
||||
|
||||
const MIN_HEIGHT_PCT = 12;
|
||||
const MAX_LEVEL = SPARK_CHARS.length - 1;
|
||||
|
||||
// 把 beat_map 映射成可渲染的格子序列。
|
||||
// 归一化按「本序列 min..max」线性分桶到 0..6;全相等 → 全部居中档(level 3)。
|
||||
export function toBeatCells(beatMap: readonly number[]): BeatCell[] {
|
||||
const values = beatMap.filter((v) => typeof v === "number" && Number.isFinite(v));
|
||||
if (values.length === 0) return [];
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const span = max - min;
|
||||
|
||||
return values.map((value) => {
|
||||
const level =
|
||||
span === 0
|
||||
? Math.round(MAX_LEVEL / 2)
|
||||
: Math.round(((value - min) / span) * MAX_LEVEL);
|
||||
const clamped = Math.min(MAX_LEVEL, Math.max(0, level));
|
||||
const heightPct =
|
||||
MIN_HEIGHT_PCT + (clamped / MAX_LEVEL) * (100 - MIN_HEIGHT_PCT);
|
||||
return {
|
||||
value,
|
||||
level: clamped,
|
||||
char: SPARK_CHARS[clamped] ?? SPARK_CHARS[0],
|
||||
heightPct: Math.round(heightPct),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 文本版 sparkline(无障碍/紧凑场景;UX §6.4 示例 ▁▃▅▂▁▇)。
|
||||
export function toSparkline(beatMap: readonly number[]): string {
|
||||
return toBeatCells(beatMap)
|
||||
.map((c) => c.char)
|
||||
.join("");
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { latestReview, normalizeConflicts } from "./history";
|
||||
import {
|
||||
latestReview,
|
||||
normalizeConflicts,
|
||||
normalizeForeshadowSug,
|
||||
normalizePace,
|
||||
} from "./history";
|
||||
|
||||
const item = (
|
||||
conflicts: Record<string, unknown>[],
|
||||
@@ -43,6 +48,56 @@ describe("normalizeConflicts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeForeshadowSug", () => {
|
||||
it("tightens flat list with kind, defaulting kind to planted", () => {
|
||||
const out = normalizeForeshadowSug({
|
||||
...item([]),
|
||||
foreshadow_sug: [
|
||||
{ kind: "resolved", code: "F-012", title: "神秘玉佩", where: "第4段" },
|
||||
{ title: "残破地图" },
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([
|
||||
{
|
||||
kind: "resolved",
|
||||
code: "F-012",
|
||||
title: "神秘玉佩",
|
||||
where: "第4段",
|
||||
note: null,
|
||||
},
|
||||
{ kind: "planted", code: null, title: "残破地图", where: null, note: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when missing", () => {
|
||||
expect(normalizeForeshadowSug(item([]))).toEqual([]);
|
||||
expect(normalizeForeshadowSug(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePace", () => {
|
||||
it("tightens dict into PaceReport, filtering bad water/beats", () => {
|
||||
const out = normalizePace({
|
||||
...item([]),
|
||||
pace: {
|
||||
water: [{ where: "第3段", reason: "注水" }, "junk"],
|
||||
hook: true,
|
||||
beat_map: [1, 3, "x", 5],
|
||||
},
|
||||
});
|
||||
expect(out).toEqual({
|
||||
water: [{ where: "第3段", reason: "注水" }],
|
||||
hook: true,
|
||||
beat_map: [1, 3, 5],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when pace is missing or not a dict", () => {
|
||||
expect(normalizePace(item([]))).toBeNull();
|
||||
expect(normalizePace(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("latestReview", () => {
|
||||
it("returns first item (history is newest-first)", () => {
|
||||
const a = item([]);
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
// 纯逻辑(可单测);schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。
|
||||
|
||||
import type { ReviewHistoryItem } from "@/lib/api/types";
|
||||
import type { ReviewConflict } from "./sse";
|
||||
import type {
|
||||
ForeshadowKind,
|
||||
ForeshadowSuggestion,
|
||||
PaceIssue,
|
||||
PaceReport,
|
||||
ReviewConflict,
|
||||
} from "./sse";
|
||||
|
||||
function asString(v: unknown, fallback = ""): string {
|
||||
return typeof v === "string" ? v : fallback;
|
||||
}
|
||||
|
||||
function asOptionalString(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
function asIntArray(v: unknown): number[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is number => typeof x === "number" && Number.isFinite(x));
|
||||
}
|
||||
|
||||
// 把一条留痕的 conflicts 收紧(缺字段给安全默认;保持顺序=下标身份,对齐冲突 gate)。
|
||||
export function normalizeConflicts(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
@@ -26,6 +41,46 @@ export function normalizeConflicts(
|
||||
}));
|
||||
}
|
||||
|
||||
// 把一条留痕的 foreshadow_sug(扁平 list,每条带 kind)收紧成建议(缺字段给安全默认)。
|
||||
// 对齐 collect 列映射(C4 扩 T3.3):planted/resolved 扁平成单 list、每条加 kind。
|
||||
export function normalizeForeshadowSug(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
): ForeshadowSuggestion[] {
|
||||
const raw = item?.foreshadow_sug ?? [];
|
||||
return raw.map((s) => ({
|
||||
kind: asKind(s["kind"]),
|
||||
code: asOptionalString(s["code"]),
|
||||
title: asString(s["title"], "(未命名线索)"),
|
||||
where: asOptionalString(s["where"]),
|
||||
note: asOptionalString(s["note"]),
|
||||
}));
|
||||
}
|
||||
|
||||
function asKind(v: unknown): ForeshadowKind {
|
||||
return v === "resolved" ? "resolved" : "planted";
|
||||
}
|
||||
|
||||
// 把一条留痕的 pace(dict)收紧成节奏报告;缺失/非 dict → null(不渲染节拍图)。
|
||||
export function normalizePace(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
): PaceReport | null {
|
||||
const raw = item?.pace;
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const dict = raw as Record<string, unknown>;
|
||||
const waterRaw = Array.isArray(dict["water"]) ? dict["water"] : [];
|
||||
const water: PaceIssue[] = waterRaw
|
||||
.filter((w): w is Record<string, unknown> => typeof w === "object" && w !== null)
|
||||
.map((w) => ({
|
||||
where: asString(w["where"]),
|
||||
reason: asString(w["reason"]),
|
||||
}));
|
||||
return {
|
||||
water,
|
||||
hook: dict["hook"] === true,
|
||||
beat_map: asIntArray(dict["beat_map"]),
|
||||
};
|
||||
}
|
||||
|
||||
// 最近一条留痕(GET .../reviews 已按新→旧排序)。
|
||||
export function latestReview(
|
||||
items: readonly ReviewHistoryItem[] | undefined,
|
||||
|
||||
@@ -49,6 +49,35 @@ describe("parseReviewBlock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses foreshadow and pace frames (C4 扩 T3.3)", () => {
|
||||
expect(
|
||||
parseReviewBlock(
|
||||
'event:foreshadow\ndata:{"kind":"resolved","code":"F-012","title":"神秘玉佩","where":"第4段","note":"疑似回收"}',
|
||||
),
|
||||
).toEqual({
|
||||
event: "foreshadow",
|
||||
data: {
|
||||
kind: "resolved",
|
||||
code: "F-012",
|
||||
title: "神秘玉佩",
|
||||
where: "第4段",
|
||||
note: "疑似回收",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseReviewBlock(
|
||||
'event:pace\ndata:{"water":[{"where":"第3段","reason":"注水"}],"hook":false,"beat_map":[1,3,5,2,1,7]}',
|
||||
),
|
||||
).toEqual({
|
||||
event: "pace",
|
||||
data: {
|
||||
water: [{ where: "第3段", reason: "注水" }],
|
||||
hook: false,
|
||||
beat_map: [1, 3, 5, 2, 1, 7],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for unknown event or malformed json", () => {
|
||||
expect(parseReviewBlock('event:token\ndata:{"text":"x"}')).toBeNull();
|
||||
expect(parseReviewBlock("event:conflict\ndata:{not json")).toBeNull();
|
||||
@@ -107,6 +136,39 @@ describe("reduceReview", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("accumulates foreshadow suggestions and replaces pace report (last wins)", () => {
|
||||
const events: ReviewSseEvent[] = [
|
||||
{
|
||||
event: "foreshadow",
|
||||
data: { kind: "planted", title: "残破地图", where: "第5段" },
|
||||
},
|
||||
{
|
||||
event: "foreshadow",
|
||||
data: { kind: "resolved", code: "F-012", title: "神秘玉佩" },
|
||||
},
|
||||
{
|
||||
event: "pace",
|
||||
data: { water: [], hook: true, beat_map: [1, 2, 3] },
|
||||
},
|
||||
{
|
||||
event: "pace",
|
||||
data: {
|
||||
water: [{ where: "第3段", reason: "注水" }],
|
||||
hook: false,
|
||||
beat_map: [3, 1, 5],
|
||||
},
|
||||
},
|
||||
];
|
||||
const state = events.reduce(reduceReview, initialReviewState);
|
||||
expect(state.foreshadow.map((f) => f.kind)).toEqual([
|
||||
"planted",
|
||||
"resolved",
|
||||
]);
|
||||
expect(state.pace?.hook).toBe(false);
|
||||
expect(state.pace?.beat_map).toEqual([3, 1, 5]);
|
||||
expect(state.phase).toBe("reviewing");
|
||||
});
|
||||
|
||||
it("marks done and captures error preserving collected data", () => {
|
||||
let s = reduceReview(initialReviewState, {
|
||||
event: "section",
|
||||
|
||||
@@ -19,6 +19,29 @@ export interface ReviewConflict {
|
||||
|
||||
export type SectionStatus = "started" | "done" | "incomplete";
|
||||
|
||||
// 伏笔建议(C4 扩 T3.3):planted=新埋待确认 / resolved=疑似回收(只读建议)。
|
||||
export type ForeshadowKind = "planted" | "resolved";
|
||||
|
||||
export interface ForeshadowSuggestion {
|
||||
kind: ForeshadowKind;
|
||||
code?: string | null;
|
||||
title: string;
|
||||
where?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
// 节奏报告(C4 扩 T3.3):注水段 + 章末钩子 + 爽点节拍图。
|
||||
export interface PaceIssue {
|
||||
where: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PaceReport {
|
||||
water: PaceIssue[];
|
||||
hook: boolean;
|
||||
beat_map: number[];
|
||||
}
|
||||
|
||||
export interface SectionEvent {
|
||||
event: "section";
|
||||
data: { name: string; status: SectionStatus };
|
||||
@@ -27,6 +50,24 @@ export interface ConflictEvent {
|
||||
event: "conflict";
|
||||
data: ReviewConflict;
|
||||
}
|
||||
export interface ForeshadowEvent {
|
||||
event: "foreshadow";
|
||||
data: {
|
||||
kind: ForeshadowKind;
|
||||
code?: string | null;
|
||||
title: string;
|
||||
where?: string | null;
|
||||
note?: string | null;
|
||||
};
|
||||
}
|
||||
export interface PaceEvent {
|
||||
event: "pace";
|
||||
data: {
|
||||
water: PaceIssue[];
|
||||
hook: boolean;
|
||||
beat_map: number[];
|
||||
};
|
||||
}
|
||||
export interface DoneEvent {
|
||||
event: "done";
|
||||
data: { length: number };
|
||||
@@ -38,10 +79,19 @@ export interface ErrorEvent {
|
||||
export type ReviewSseEvent =
|
||||
| SectionEvent
|
||||
| ConflictEvent
|
||||
| ForeshadowEvent
|
||||
| PaceEvent
|
||||
| DoneEvent
|
||||
| ErrorEvent;
|
||||
|
||||
const KNOWN_EVENTS = new Set(["section", "conflict", "done", "error"]);
|
||||
const KNOWN_EVENTS = new Set([
|
||||
"section",
|
||||
"conflict",
|
||||
"foreshadow",
|
||||
"pace",
|
||||
"done",
|
||||
"error",
|
||||
]);
|
||||
|
||||
// 把一个完整 SSE 块(多行)解析成事件;无法解析则返回 null(跳过)。
|
||||
export function parseReviewBlock(block: string): ReviewSseEvent | null {
|
||||
@@ -108,6 +158,8 @@ export interface ReviewStreamState {
|
||||
phase: ReviewPhase;
|
||||
sections: ReviewSection[];
|
||||
conflicts: ReviewConflict[];
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
error: { code: string; message: string; request_id?: string | null } | null;
|
||||
}
|
||||
|
||||
@@ -115,6 +167,8 @@ export const initialReviewState: ReviewStreamState = {
|
||||
phase: "idle",
|
||||
sections: [],
|
||||
conflicts: [],
|
||||
foreshadow: [],
|
||||
pace: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -136,6 +190,18 @@ export function reduceReview(
|
||||
phase: "reviewing",
|
||||
conflicts: [...state.conflicts, event.data],
|
||||
};
|
||||
case "foreshadow":
|
||||
return {
|
||||
...state,
|
||||
phase: "reviewing",
|
||||
foreshadow: [...state.foreshadow, { ...event.data }],
|
||||
};
|
||||
case "pace":
|
||||
return {
|
||||
...state,
|
||||
phase: "reviewing",
|
||||
pace: { ...event.data },
|
||||
};
|
||||
case "done":
|
||||
return { ...state, phase: "done" };
|
||||
case "error":
|
||||
|
||||
@@ -7,16 +7,24 @@ import {
|
||||
ReviewFrameBuffer,
|
||||
initialReviewState,
|
||||
reduceReview,
|
||||
type ForeshadowSuggestion,
|
||||
type PaceReport,
|
||||
type ReviewConflict,
|
||||
type ReviewStreamState,
|
||||
} from "./sse";
|
||||
|
||||
export interface ReviewSeed {
|
||||
conflicts: ReviewConflict[];
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "start" }
|
||||
| { type: "events"; events: ReturnType<ReviewFrameBuffer["push"]> }
|
||||
| { type: "abort" }
|
||||
| { type: "fail"; code: string; message: string }
|
||||
| { type: "seed"; conflicts: ReviewConflict[] };
|
||||
| { type: "seed"; seed: ReviewSeed };
|
||||
|
||||
function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
|
||||
switch (action.type) {
|
||||
@@ -33,7 +41,12 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
|
||||
error: { code: action.code, message: action.message },
|
||||
};
|
||||
case "seed":
|
||||
return { ...initialReviewState, conflicts: action.conflicts };
|
||||
return {
|
||||
...initialReviewState,
|
||||
conflicts: action.seed.conflicts,
|
||||
foreshadow: action.seed.foreshadow,
|
||||
pace: action.seed.pace,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -45,8 +58,8 @@ export interface UseReviewStream {
|
||||
// 用当前编辑器草稿重新审稿。
|
||||
start: (projectId: string, chapterNo: number, draft: string) => Promise<void>;
|
||||
stop: () => void;
|
||||
// 进页用历史留痕的冲突种入(无需重审即可裁决)。
|
||||
seed: (conflicts: ReviewConflict[]) => void;
|
||||
// 进页用历史留痕(冲突 + 伏笔建议 + 节奏)种入(无需重审即可裁决/查看)。
|
||||
seed: (seed: ReviewSeed) => void;
|
||||
}
|
||||
|
||||
// 消费 POST .../review 的 SSE 流:fetch+ReadableStream(EventSource 不支持 POST)。
|
||||
@@ -62,8 +75,8 @@ export function useReviewStream(): UseReviewStream {
|
||||
dispatch({ type: "abort" });
|
||||
}, []);
|
||||
|
||||
const seed = useCallback((conflicts: ReviewConflict[]) => {
|
||||
dispatch({ type: "seed", conflicts });
|
||||
const seed = useCallback((seedData: ReviewSeed) => {
|
||||
dispatch({ type: "seed", seed: seedData });
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user