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