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:
Yaojia Wang
2026-06-18 14:21:17 +02:00
parent 68f194a043
commit 5fb7bfb1de
74 changed files with 6529 additions and 126 deletions

View File

@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import { ForeshadowBoard } from "@/components/foreshadow/ForeshadowBoard";
import { fetchForeshadow, fetchProject } from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
}
// 伏笔看板页UX §6.8。Server Component 取项目 + 全量伏笔GET .../foreshadow
// ForeshadowBoardClient按 status 分四泳道并承载登记/状态变更交互。
export default async function ForeshadowPage({ params }: PageProps) {
const { id } = await params;
try {
const project = await fetchProject(id);
const board = await fetchForeshadow(id);
return (
<ForeshadowBoard project={project} initialItems={board.foreshadow ?? []} />
);
} catch {
notFound();
}
}

View File

@@ -0,0 +1,20 @@
import { notFound } from "next/navigation";
import { OutlineEditor } from "@/components/outline/OutlineEditor";
import { fetchProject } from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
}
// 大纲编辑器页UX §6.7。Server Component 取项目OutlineEditorClient按需
// 触发 POST .../outline 生成大纲M3 无 GET 大纲端点,进页空、生成后填充)。
export default async function OutlinePage({ params }: PageProps) {
const { id } = await params;
try {
const project = await fetchProject(id);
return <OutlineEditor project={project} initialChapters={[]} />;
} catch {
notFound();
}
}

View File

@@ -8,10 +8,20 @@ interface AppShellProps {
// 顶栏右侧的上下文信息(如作品名 / 进度)。
title?: string;
subtitle?: string;
// 项目内页给出 projectId → 左导航展开项目级入口(大纲/伏笔/审稿UX §3.1)。
projectId?: string;
// 当前激活的项目级入口键(用于高亮)。
activeNav?: "write" | "outline" | "foreshadow" | "review";
}
// 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。
export function AppShell({ children, title, subtitle }: AppShellProps) {
export function AppShell({
children,
title,
subtitle,
projectId,
activeNav,
}: AppShellProps) {
return (
<div className="min-h-screen">
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-6">
@@ -38,7 +48,7 @@ export function AppShell({ children, title, subtitle }: AppShellProps) {
</nav>
</header>
<div className="flex">
<LeftNav />
<LeftNav projectId={projectId} activeNav={activeNav} />
<main className="min-h-[calc(100vh-4rem)] flex-1 bg-bg">{children}</main>
</div>
</div>

View File

@@ -3,64 +3,126 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
export type ActiveNav = "write" | "outline" | "foreshadow" | "review";
interface LeftNavProps {
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿UX §3.1)。
projectId?: string;
activeNav?: ActiveNav;
}
interface NavItem {
href: string;
label: string;
// M1 仅作品库 + 设置可达;其余为后续里程碑占位(禁用)。
enabled: boolean;
// 项目级入口的激活键(与 activeNav 比对高亮)。
key?: ActiveNav;
}
const NAV_ITEMS: NavItem[] = [
const GLOBAL_ITEMS: NavItem[] = [
{ href: "/", label: "作品库", enabled: true },
{ href: "/settings/providers", label: "设置", enabled: true },
{ href: "#outline", label: "大纲", enabled: false },
{ href: "#codex", label: "设定库", enabled: false },
{ href: "#foreshadow", label: "伏笔", enabled: false },
{ href: "#review", label: "审稿", enabled: false },
{ href: "#style", label: "文风", enabled: false },
];
// 左导航:当前项朱砂竖条 + 浅晕底UX §5.1)。
export function LeftNav() {
function projectItems(projectId: string): NavItem[] {
return [
{
href: `/projects/${projectId}/write`,
label: "写作",
enabled: true,
key: "write",
},
{
href: `/projects/${projectId}/outline`,
label: "大纲",
enabled: true,
key: "outline",
},
{
href: `/projects/${projectId}/foreshadow`,
label: "伏笔",
enabled: true,
key: "foreshadow",
},
{
href: `/projects/${projectId}/review`,
label: "审稿",
enabled: true,
key: "review",
},
{ href: "#codex", label: "设定库", enabled: false },
{ href: "#style", label: "文风", enabled: false },
];
}
// 左导航:当前项朱砂竖条 + 浅晕底UX §5.1 / §3.1)。
export function LeftNav({ projectId, activeNav }: LeftNavProps) {
const pathname = usePathname();
const scoped = projectId ? projectItems(projectId) : [];
return (
<nav
className="w-44 shrink-0 border-r border-line bg-panel py-4"
aria-label="主导航"
>
<ul className="flex flex-col gap-1 px-2">
{NAV_ITEMS.map((item) => {
const active = item.enabled && pathname === item.href;
if (!item.enabled) {
return (
<li key={item.label}>
<span
className="block cursor-not-allowed rounded px-3 py-2 text-sm text-ink-soft/50"
aria-disabled="true"
title="后续里程碑开放"
>
{item.label}
</span>
</li>
);
}
return (
<li key={item.label}>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
active
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
}`}
>
{item.label}
</Link>
</li>
);
})}
{GLOBAL_ITEMS.map((item) => (
<NavLink
key={item.label}
item={item}
active={item.enabled && pathname === item.href}
/>
))}
{scoped.length > 0 ? (
<li
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
aria-hidden="true"
>
</li>
) : null}
{scoped.map((item) => (
<NavLink
key={item.label}
item={item}
active={
item.key !== undefined &&
(item.key === activeNav || pathname === item.href)
}
/>
))}
</ul>
</nav>
);
}
function NavLink({ item, active }: { item: NavItem; active: boolean }) {
if (!item.enabled) {
return (
<li>
<span
className="block cursor-not-allowed rounded px-3 py-2 text-sm text-ink-soft/50"
aria-disabled="true"
title="后续里程碑开放"
>
{item.label}
</span>
</li>
);
}
return (
<li>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
active
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
}`}
>
{item.label}
</Link>
</li>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useMemo } from "react";
import { AppShell } from "@/components/AppShell";
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
import {
LANES,
groupByStatus,
type ForeshadowStatus,
} from "@/lib/foreshadow/board";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import { KanbanColumn } from "./KanbanColumn";
import { RegisterForm } from "./RegisterForm";
interface ForeshadowBoardProps {
project: ProjectResponse;
initialItems: ForeshadowView[];
}
// 伏笔看板主体UX §6.8):四泳道 + 登记/状态变更交互。
// RSC 取全量种入Client 承载登记/转移(乐观 + 回滚 + Toast
export function ForeshadowBoard({
project,
initialItems,
}: ForeshadowBoardProps) {
const { items, busy, register, transition } = useForeshadow(initialItems);
const lanes = useMemo(() => groupByStatus(items), [items]);
const onTransition = (
code: string,
toStatus: ForeshadowStatus | null,
progressNote: string,
): void => {
void transition(code, {
projectId: project.id,
toStatus,
progressEntry: progressNote ? { note: progressNote } : null,
});
};
return (
<AppShell
title={`${project.title}`}
subtitle="伏笔看板"
projectId={project.id}
activeNav="foreshadow"
>
<div className="flex h-[calc(100vh-4rem)] flex-col p-4">
<div className="mb-4 flex items-start justify-between gap-4">
<h1 className="font-serif text-lg text-ink"></h1>
<RegisterForm
projectId={project.id}
busy={busy}
onRegister={register}
/>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{LANES.map((status) => (
<KanbanColumn
key={status}
status={status}
items={lanes[status]}
busy={busy}
onTransition={onTransition}
/>
))}
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import type { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
interface ForeshadowCardProps {
item: ForeshadowView;
overdue: boolean;
busy: boolean;
onTransition: (
code: string,
toStatus: ForeshadowStatus | null,
progressNote: string,
) => void;
}
const NEXT_STATUS: Record<string, ForeshadowStatus[]> = {
OPEN: ["PARTIAL", "CLOSED"],
PARTIAL: ["CLOSED"],
OVERDUE: ["PARTIAL", "CLOSED"],
CLOSED: [],
};
// 伏笔卡UX §6.8code/title/回收窗口/importance/progress 时间线 + 状态变更/加进展。
// OVERDUE 用琥珀强调(不单靠色:加 ⚠ 图标 + 文案a11y §10
export function ForeshadowCard({
item,
overdue,
busy,
onTransition,
}: ForeshadowCardProps) {
const [note, setNote] = useState("");
const transitions = NEXT_STATUS[item.status] ?? [];
const windowText = recoveryWindow(item);
return (
<li
className={`rounded border bg-panel p-3 ${
overdue ? "border-overdue ring-1 ring-overdue/40" : "border-line"
}`}
>
<div className="flex items-baseline gap-2">
<span className="font-mono text-xs text-cinnabar">{item.code}</span>
{overdue ? (
<span className="text-xs text-overdue" aria-label="逾期">
</span>
) : null}
</div>
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
{typeof item.planted_at === "number" ? (
<div> {item.planted_at} </div>
) : null}
{windowText ? <div> {windowText}</div> : null}
{item.importance ? <div>{item.importance}</div> : null}
</dl>
{item.progress && item.progress.length > 0 ? (
<ul className="mt-2 border-l-2 border-line pl-2 text-xs text-ink-soft">
{item.progress.map((p, i) => (
<li key={i}>· {progressLabel(p)}</li>
))}
</ul>
) : null}
{transitions.length > 0 || item.status !== "CLOSED" ? (
<div className="mt-3 space-y-2">
{transitions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{transitions.map((to) => (
<button
key={to}
type="button"
disabled={busy}
onClick={() => onTransition(item.code, to, "")}
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar hover:text-cinnabar disabled:opacity-40"
>
{to === "CLOSED" ? "回收" : to}
</button>
))}
</div>
) : null}
{item.status !== "CLOSED" ? (
<div className="flex gap-1.5">
<label htmlFor={`prog-${item.code}`} className="sr-only">
{item.code}
</label>
<input
id={`prog-${item.code}`}
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="加进展23章发光"
className="min-w-0 flex-1 rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink focus:border-cinnabar focus:outline-none"
/>
<button
type="button"
disabled={busy || note.trim().length === 0}
onClick={() => {
onTransition(item.code, null, note.trim());
setNote("");
}}
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar disabled:opacity-40"
>
</button>
</div>
) : null}
</div>
) : null}
</li>
);
}
function recoveryWindow(item: ForeshadowView): string | null {
const from = item.expected_close_from;
const to = item.expected_close_to;
if (typeof from === "number" && typeof to === "number") return `${from}-${to}`;
if (typeof to === "number") return `${to}`;
if (typeof from === "number") return `${from}`;
return null;
}
function progressLabel(entry: Record<string, unknown>): string {
const chapter = entry["chapter"];
const note = entry["note"];
const noteStr = typeof note === "string" ? note : JSON.stringify(entry);
return typeof chapter === "number" ? `${chapter}章:${noteStr}` : noteStr;
}

View File

@@ -0,0 +1,61 @@
"use client";
import type { ForeshadowView } from "@/lib/api/types";
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
import { ForeshadowCard } from "./ForeshadowCard";
interface KanbanColumnProps {
status: ForeshadowStatus;
items: ForeshadowView[];
busy: boolean;
onTransition: (
code: string,
toStatus: ForeshadowStatus | null,
progressNote: string,
) => void;
}
// 单个状态泳道UX §6.8。OVERDUE 列整列琥珀强调(表头 + 卡片)。
export function KanbanColumn({
status,
items,
busy,
onTransition,
}: KanbanColumnProps) {
const overdue = status === "OVERDUE";
return (
<section
className="flex min-w-0 flex-col rounded border border-line bg-bg"
aria-label={`${status} 泳道`}
>
<header
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
overdue
? "border-overdue bg-overdue/10 text-overdue"
: "border-line bg-panel text-ink-soft"
}`}
>
<span className="font-mono text-xs font-semibold">
{overdue ? "⚠ " : ""}
{status}
</span>
<span className="font-mono text-xs">{items.length}</span>
</header>
<ul className="flex-1 space-y-2 overflow-auto p-2">
{items.length === 0 ? (
<li className="px-1 py-3 text-xs text-ink-soft/60"></li>
) : (
items.map((item) => (
<ForeshadowCard
key={item.code}
item={item}
overdue={overdue}
busy={busy}
onTransition={onTransition}
/>
))
)}
</ul>
</section>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import { useState } from "react";
import type { RegisterInput } from "@/lib/foreshadow/board";
interface RegisterFormProps {
projectId: string;
busy: boolean;
onRegister: (input: RegisterInput) => Promise<boolean>;
}
const EMPTY = {
code: "",
title: "",
plantedAt: "",
content: "",
expectedCloseFrom: "",
expectedCloseTo: "",
importance: "",
};
// 登记新伏笔UX §6.8)。受控表单 → 组装 RegisterInput成功后清空。
export function RegisterForm({
projectId,
busy,
onRegister,
}: RegisterFormProps) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState({ ...EMPTY });
const set = (key: keyof typeof EMPTY, value: string): void =>
setForm((prev) => ({ ...prev, [key]: value }));
const submit = async (): Promise<void> => {
const ok = await onRegister({
projectId,
code: form.code,
title: form.title,
plantedAt: toInt(form.plantedAt),
content: form.content || null,
expectedCloseFrom: toInt(form.expectedCloseFrom),
expectedCloseTo: toInt(form.expectedCloseTo),
importance: form.importance || null,
});
if (ok) {
setForm({ ...EMPTY });
setOpen(false);
}
};
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
>
</button>
);
}
const canSubmit =
form.code.trim().length > 0 && form.title.trim().length > 0 && !busy;
return (
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="rounded border border-line bg-panel p-4"
>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Field label="代号 *" id="f-code">
<input
id="f-code"
value={form.code}
onChange={(e) => set("code", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="标题 *" id="f-title" span2>
<input
id="f-title"
value={form.title}
onChange={(e) => set("title", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="埋设章" id="f-planted">
<input
id="f-planted"
inputMode="numeric"
value={form.plantedAt}
onChange={(e) => set("plantedAt", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="回收窗口起" id="f-from">
<input
id="f-from"
inputMode="numeric"
value={form.expectedCloseFrom}
onChange={(e) => set("expectedCloseFrom", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="回收窗口止" id="f-to">
<input
id="f-to"
inputMode="numeric"
value={form.expectedCloseTo}
onChange={(e) => set("expectedCloseTo", e.target.value)}
className={inputCls}
/>
</Field>
<Field label="重要度" id="f-imp">
<input
id="f-imp"
value={form.importance}
onChange={(e) => set("importance", e.target.value)}
placeholder="主线/支线"
className={inputCls}
/>
</Field>
<Field label="线索内容" id="f-content" span3>
<input
id="f-content"
value={form.content}
onChange={(e) => set("content", e.target.value)}
className={inputCls}
/>
</Field>
</div>
<div className="mt-3 flex items-center gap-2">
<button
type="submit"
disabled={!canSubmit}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
>
{busy ? "登记中…" : "登记"}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded border border-line px-3 py-1.5 text-sm text-ink-soft hover:border-cinnabar"
>
</button>
</div>
</form>
);
}
const inputCls =
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
interface FieldProps {
label: string;
id: string;
span2?: boolean;
span3?: boolean;
children: React.ReactNode;
}
function Field({ label, id, span2, span3, children }: FieldProps) {
const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
return (
<div className={span}>
<label htmlFor={id} className="block text-xs text-ink-soft">
{label}
</label>
<div className="mt-0.5">{children}</div>
</div>
);
}
function toInt(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
const n = Number.parseInt(trimmed, 10);
return Number.isInteger(n) ? n : null;
}

View File

@@ -0,0 +1,51 @@
"use client";
import type { OutlineChapterView } from "@/lib/api/types";
import {
isCloseWindow,
windowBadgeLabel,
} from "@/lib/outline/outline";
interface OutlineChapterRowProps {
chapter: OutlineChapterView;
}
// 单章大纲行UX §6.7):章号 + beats + 伏笔窗口徽标(⚑)。
// 接近回收窗口的徽标用琥珀 + 「可回收」提示a11y图标 + 文案,不单靠色)。
export function OutlineChapterRow({ chapter }: OutlineChapterRowProps) {
const windows = chapter.foreshadow_windows ?? [];
return (
<li className="border-l-2 border-line py-2 pl-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-ink-soft">
{chapter.no}
</span>
{windows.map((w) => {
const close = isCloseWindow(chapter, w);
return (
<span
key={w.code}
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
className={`rounded px-1.5 py-0.5 text-xs ${
close
? "bg-overdue/15 text-overdue"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
>
{windowBadgeLabel(w)}
{close ? " · 可回收" : ""}
</span>
);
})}
</div>
{chapter.beats && chapter.beats.length > 0 ? (
<p className="mt-1 text-sm text-ink">
<span className="text-ink-soft">beats: </span>
{chapter.beats.join(" / ")}
</p>
) : (
<p className="mt-1 text-sm text-ink-soft/60"></p>
)}
</li>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
import { groupByVolume } from "@/lib/outline/outline";
import { useOutline } from "@/lib/outline/useOutline";
import { OutlineChapterRow } from "./OutlineChapterRow";
interface OutlineEditorProps {
project: ProjectResponse;
initialChapters: OutlineChapterView[];
}
// 大纲编辑器主体UX §6.7。RSC 种入已存大纲Client 承载生成POST .../outline
// 无凭据 → 503 LLM_UNAVAILABLE 经 error 检出、引导去设置。
export function OutlineEditor({
project,
initialChapters,
}: OutlineEditorProps) {
const { chapters, status, error, generate } = useOutline(initialChapters);
const [volume, setVolume] = useState(1);
const volumes = useMemo(() => groupByVolume(chapters), [chapters]);
const generating = status === "generating";
return (
<AppShell
title={`${project.title}`}
subtitle="大纲"
projectId={project.id}
activeNav="outline"
>
<div className="flex h-[calc(100vh-4rem)] flex-col p-6">
<div className="mb-4 flex items-center gap-3">
<h1 className="font-serif text-lg text-ink"></h1>
<label htmlFor="vol" className="ml-auto text-xs text-ink-soft">
</label>
<input
id="vol"
inputMode="numeric"
value={volume}
onChange={(e) => setVolume(Math.max(1, Number(e.target.value) || 1))}
className="w-16 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none"
/>
<button
type="button"
disabled={generating}
onClick={() => void generate(project.id, volume)}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
>
{generating ? "排大纲中…" : "✦ AI 排大纲"}
</button>
</div>
{error ? (
<p className="mb-4 text-sm text-conflict">
{error.code}{error.message}
{error.code === "LLM_UNAVAILABLE" ? (
<>
{" "}
<a
href="/settings/providers"
className="underline hover:text-cinnabar"
>
</a>
</>
) : null}
</p>
) : null}
<div className="min-h-0 flex-1 overflow-auto">
{volumes.length === 0 ? (
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft">
AI
</p>
) : (
volumes.map((group) => (
<section key={group.volume} className="mb-6">
<h2 className="mb-2 font-serif text-base text-ink">
{group.volume}
</h2>
<ul className="space-y-1">
{group.chapters.map((ch) => (
<OutlineChapterRow key={ch.no} chapter={ch} />
))}
</ul>
</section>
))
)}
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { toBeatCells, toSparkline } from "@/lib/pace/beatmap";
interface BeatMapProps {
beatMap: number[];
}
// 爽点节拍图UX §6.4beat_map(int 序列) → ▁▃▅ 柱状条。
// 渲染为定高柱CSS height%),同时给文本 sparkline 作无障碍标签a11y §10
// 静态高度,无动画 → 天然尊重 prefers-reduced-motion。
export function BeatMap({ beatMap }: BeatMapProps) {
const cells = toBeatCells(beatMap);
if (cells.length === 0) {
return <span className="text-xs text-ink-soft/60"></span>;
}
const spark = toSparkline(beatMap);
return (
<div
className="flex h-12 items-end gap-0.5"
role="img"
aria-label={`爽点节拍图 ${spark}`}
>
{cells.map((cell, i) => (
<div
key={i}
className="min-w-[4px] flex-1 rounded-t bg-cinnabar/70"
style={{ height: `${cell.heightPct}%` }}
title={`${i + 1}拍 强度${cell.value}`}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import type { ForeshadowSuggestion } from "@/lib/review/sse";
interface ForeshadowSuggestionsProps {
suggestions: ForeshadowSuggestion[];
// 审项是否未完成incomplete
incomplete: boolean;
}
// 伏笔建议区UX §6.4 ②planted「新埋待确认」/ resolved「疑似回收」只读建议。
// 不静默写库(不变量#3仅展示登记/回收经伏笔看板显式确认。
export function ForeshadowSuggestions({
suggestions,
incomplete,
}: ForeshadowSuggestionsProps) {
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(foreshadow-analyst)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
) : suggestions.length === 0 ? (
<p className="mt-1 text-xs text-ink-soft"></p>
) : (
<ul className="mt-2 space-y-2">
{suggestions.map((s, i) => (
<li
key={i}
className="rounded border border-line bg-panel p-2 text-xs"
>
<span
className={`mr-1 rounded px-1.5 py-0.5 ${
s.kind === "resolved"
? "bg-pass/15 text-pass"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
>
{s.kind === "resolved" ? "⚑ 疑似回收" : " 新埋待确认"}
</span>
{s.code ? (
<span className="font-mono text-ink-soft"> {s.code}</span>
) : null}
<p className="mt-1 text-ink">{s.title}</p>
{s.where ? (
<span className="text-ink-soft">{s.where}</span>
) : null}
{s.note ? (
<p className="mt-0.5 text-ink-soft">{s.note}</p>
) : null}
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import type { PaceReport } from "@/lib/review/sse";
import { BeatMap } from "./BeatMap";
interface PacePanelProps {
pace: PaceReport | null;
incomplete: boolean;
}
// 节奏报告区UX §6.4 ④):节拍图 ▁▃▅ + 注水段列表 + 章末钩子徽标。只读建议。
export function PacePanel({ pace, incomplete }: PacePanelProps) {
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(pace-checker)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
) : pace === null ? (
<p className="mt-1 text-xs text-ink-soft"></p>
) : (
<div className="mt-2 space-y-2 text-xs">
<div>
<p className="mb-1 text-ink-soft"></p>
<BeatMap beatMap={pace.beat_map} />
</div>
<p>
{pace.hook ? (
<span className="text-pass"> </span>
) : (
<span className="text-overdue"> </span>
)}
</p>
{pace.water.length > 0 ? (
<div>
<p className="text-overdue"> </p>
<ul className="mt-1 space-y-1">
{pace.water.map((w, i) => (
<li key={i} className="text-ink-soft">
{w.where}{w.reason}
</li>
))}
</ul>
</div>
) : (
<p className="text-pass"> </p>
)}
</div>
)}
</section>
);
}

View File

@@ -13,13 +13,19 @@ import {
type DecisionDraft,
type Verdict,
} from "@/lib/review/decisions";
import { normalizeConflicts } from "@/lib/review/history";
import {
normalizeConflicts,
normalizeForeshadowSug,
normalizePace,
} from "@/lib/review/history";
import { useAccept } from "@/lib/review/useAccept";
import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict } from "@/lib/review/sse";
import { AcceptPanel } from "./AcceptPanel";
import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard";
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
import { PacePanel } from "./PacePanel";
interface ReviewReportProps {
project: ProjectResponse;
@@ -48,19 +54,41 @@ export function ReviewReport({
const [missing, setMissing] = useState<Set<number>>(new Set());
const seededRef = useRef(false);
// 进页一次性把历史 conflicts 种入流状态(无需重审即可裁决)。
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
const seededConflicts = useMemo(
() => normalizeConflicts(initialReview),
[initialReview],
);
const seededForeshadow = useMemo(
() => normalizeForeshadowSug(initialReview),
[initialReview],
);
const seededPace = useMemo(
() => normalizePace(initialReview),
[initialReview],
);
useEffect(() => {
if (seededRef.current) return;
seededRef.current = true;
if (seededConflicts.length > 0) review.seed(seededConflicts);
}, [review, seededConflicts]);
const hasSeed =
seededConflicts.length > 0 ||
seededForeshadow.length > 0 ||
seededPace !== null;
if (hasSeed) {
review.seed({
conflicts: seededConflicts,
foreshadow: seededForeshadow,
pace: seededPace,
});
}
}, [review, seededConflicts, seededForeshadow, seededPace]);
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
const conflicts: ReviewConflict[] = review.state.conflicts;
const foreshadow = review.state.foreshadow;
const pace = review.state.pace;
const sectionStatus = (name: string): string | undefined =>
review.state.sections.find((s) => s.name === name)?.status;
// 重审完成done 边沿)→ 重置裁决草稿对齐新冲突集(冲突可能整组变化)。
const conflictCount = conflicts.length;
@@ -127,6 +155,8 @@ export function ReviewReport({
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo} 章 · 审稿报告`}
projectId={project.id}
activeNav="review"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[1fr_24rem]">
{/* 左:终稿正文 + 就地标注 */}
@@ -240,6 +270,17 @@ export function ReviewReport({
)}
</section>
<div className="mt-4 border-t border-line pt-4">
<ForeshadowSuggestions
suggestions={foreshadow}
incomplete={sectionStatus("foreshadow") === "incomplete"}
/>
<PacePanel
pace={pace}
incomplete={sectionStatus("pace") === "incomplete"}
/>
</div>
<section className="mt-4">
<AcceptPanel
conflictCount={conflictCount}

View File

@@ -55,6 +55,8 @@ export function Workbench({ project }: WorkbenchProps) {
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo}`}
projectId={project.id}
activeNav="write"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
<ChapterList currentChapterNo={chapterNo} />
@@ -137,6 +139,18 @@ function Toolbar({
<span className="font-mono text-xs text-ink-soft">
{wordCount.toLocaleString()}
</span>
<Link
href={`/projects/${projectId}/outline`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
>
</Link>
<Link
href={`/projects/${projectId}/foreshadow`}
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
>
</Link>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"

View File

@@ -169,7 +169,14 @@ export interface paths {
/**
* Accept Chapter
* @description 验收事务 + 冲突 gate§5.5gate事务前→ 终稿提炼 digest事务外R2
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
* BackgroundTaskM3-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` 缺省=1schema 无逐章卷号;本端点把整批章节落到同一卷)。
*/
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;

View File

@@ -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`);
}

View File

@@ -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"];

View 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();
});
});

View File

@@ -0,0 +1,141 @@
// 伏笔看板纯逻辑:四泳道分组、登记/转移请求体组装、422 reason 映射。
// 对齐 C3 扩GET/POST/PATCH .../foreshadow。纯逻辑便于 node 环境单测。
import type {
ForeshadowRegisterRequest,
ForeshadowTransitionRequest,
ForeshadowView,
} from "@/lib/api/types";
// 四状态泳道UX §6.8OVERDUE 用琥珀强调(渲染层处理)。
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.reasonC3 扩)→ 用户文案。
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;
}

View 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成功后把返回行追加进 items422 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 };
}

View 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);
});
});

View 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;
}

View 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 .../outlinebody {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 };
}

View 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("");
});
});

View File

@@ -0,0 +1,53 @@
// 爽点节拍图:把 pace.beat_mapint 序列)映射成 ▁▃▅ 风格的 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("");
}

View File

@@ -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([]);

View File

@@ -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.3planted/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";
}
// 把一条留痕的 pacedict收紧成节奏报告缺失/非 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,

View File

@@ -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",

View File

@@ -19,6 +19,29 @@ export interface ReviewConflict {
export type SectionStatus = "started" | "done" | "incomplete";
// 伏笔建议C4 扩 T3.3planted=新埋待确认 / 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":

View File

@@ -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+ReadableStreamEventSource 不支持 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(

File diff suppressed because one or more lines are too long