Merge branch 'feat/review-chapter-select-and-foreshadow' into develop

审稿选章(列章端点 + 全章下拉 + 默认最近章)
+ 伏笔主动提醒(写作台本章伏笔)与 AI 写作时自动应用(写章上下文注入到期/逾期伏笔 + 提示词指令)
+ 宽屏留白优化(作品库 max-w-7xl 四列 / 正文 768px)
+ 主题切换水合报错修复
+ AI 工作中动效强化(构思占位 / 流式进度条 / 波动三点 / 转圈)
门禁:前端 vitest 749 · lint/typecheck/build ✓;后端 pytest 1032 · ruff/mypy ✓。
This commit is contained in:
Yaojia Wang
2026-07-12 19:47:12 +02:00
48 changed files with 1852 additions and 77 deletions

View File

@@ -0,0 +1,213 @@
"""GET /projects/:id/chapters 列章端点 + 纯聚合逻辑测试(审稿选章枚举)。
分两层,与本仓「真 PG 行为交 tests/ E2E、单测保持确定性」纪律一致
- **纯聚合** `build_chapter_list`:喂造好的 chapter/review 行,断言 has_draft/accepted/
reviewed_at、去重、按章号升序、空正文草稿不计这是端点真正的业务逻辑脱库直测
- **端点集成**FastAPI client + 覆盖 `get_chapter_lister` 为 Fake同 test_projects.py 风格),
断言 404、空项目 []、字段透传、升序;网关无关(本端点不触 LLM
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo
from ww_api.services.chapter_list import (
ChapterRow,
ReviewRow,
build_chapter_list,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_shared import ErrorCode
# ---- 纯聚合逻辑build_chapter_list----
def _dt(day: int) -> datetime:
return datetime(2026, 7, day, tzinfo=UTC)
def test_build_chapter_list_marks_draft_accepted_and_reviewed() -> None:
# 三态各出现:草稿章(1) / 已验收章(2) / 已审章(3, 有 review 时间)。
chapter_rows = [
ChapterRow(chapter_no=1, status="draft", content="草稿正文"),
ChapterRow(chapter_no=2, status="draft", content="待验收"),
ChapterRow(chapter_no=2, status="accepted", content="终稿"),
ChapterRow(chapter_no=3, status="draft", content="被审的草稿"),
]
review_rows = [ReviewRow(chapter_no=3, created_at=_dt(5))]
items = build_chapter_list(chapter_rows, review_rows)
by_no = {it.chapter_no: it for it in items}
assert by_no[1].has_draft is True
assert by_no[1].accepted is False
assert by_no[1].reviewed_at is None
assert by_no[2].accepted is True
assert by_no[2].has_draft is True
assert by_no[3].reviewed_at == _dt(5)
def test_build_chapter_list_empty_returns_empty() -> None:
assert build_chapter_list([], []) == []
def test_build_chapter_list_sorted_ascending_and_deduped() -> None:
# 乱序 + 同章多版本行 → 去重 + 章号升序。
chapter_rows = [
ChapterRow(chapter_no=3, status="draft", content="c3"),
ChapterRow(chapter_no=1, status="draft", content="c1"),
ChapterRow(chapter_no=1, status="accepted", content="c1a"),
ChapterRow(chapter_no=2, status="accepted", content="c2"),
]
items = build_chapter_list(chapter_rows, [])
assert [it.chapter_no for it in items] == [1, 2, 3]
def test_build_chapter_list_reviewed_at_is_latest() -> None:
# 同章多次审稿 → reviewed_at 取最近一次。
chapter_rows = [ChapterRow(chapter_no=1, status="draft", content="x")]
review_rows = [
ReviewRow(chapter_no=1, created_at=_dt(3)),
ReviewRow(chapter_no=1, created_at=_dt(9)),
ReviewRow(chapter_no=1, created_at=_dt(6)),
]
items = build_chapter_list(chapter_rows, review_rows)
assert items[0].reviewed_at == _dt(9)
def test_build_chapter_list_empty_content_draft_not_marked() -> None:
# 空/纯空白正文的草稿行不算「有草稿正文」(与 GET .../draft 语义一致)。
chapter_rows = [
ChapterRow(chapter_no=1, status="draft", content=" "),
ChapterRow(chapter_no=1, status="draft", content=None),
]
items = build_chapter_list(chapter_rows, [])
assert items[0].chapter_no == 1
assert items[0].has_draft is False
def test_build_chapter_list_review_only_chapter_appears() -> None:
# 有审稿但无 chapters 行(作者以 body.draft 送审未存草稿)→ 该章仍枚举、标已审。
items = build_chapter_list([], [ReviewRow(chapter_no=7, created_at=_dt(2))])
assert len(items) == 1
assert items[0].chapter_no == 7
assert items[0].has_draft is False
assert items[0].accepted is False
assert items[0].reviewed_at == _dt(2)
# ---- 端点集成FastAPI client + Fake lister----
class _FakeChapterLister:
"""实现 ChapterLister Protocol 的内存版:回放预置的列章结果。"""
def __init__(self, items_by_project: dict[uuid.UUID, list[object]] | None = None) -> None:
self.items_by_project = items_by_project or {}
async def list_chapters(self, project_id: uuid.UUID) -> list[object]:
return self.items_by_project.get(project_id, [])
def _make_client(
*,
project_repo: FakeProjectRepo,
lister: _FakeChapterLister,
) -> httpx.AsyncClient:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_chapter_lister, get_project_repo
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_chapter_lister] = lambda: lister
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
return pid
@pytest.mark.asyncio
async def test_list_chapters_returns_items_with_fields() -> None:
from ww_api.schemas.projects import ChapterListItem
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
lister = _FakeChapterLister(
{
pid: [
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
ChapterListItem(chapter_no=2, has_draft=True, accepted=True, reviewed_at=_dt(4)),
]
}
)
client = _make_client(project_repo=project_repo, lister=lister)
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert body[0] == {
"chapter_no": 1,
"has_draft": True,
"accepted": False,
"reviewed_at": None,
}
assert body[1]["chapter_no"] == 2
assert body[1]["accepted"] is True
assert body[1]["reviewed_at"].startswith("2026-07-04")
@pytest.mark.asyncio
async def test_list_chapters_empty_project_returns_empty_list() -> None:
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
client = _make_client(project_repo=project_repo, lister=_FakeChapterLister())
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_list_chapters_unknown_project_404() -> None:
client = _make_client(project_repo=FakeProjectRepo(), lister=_FakeChapterLister())
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_list_chapters_ascending_order() -> None:
from ww_api.schemas.projects import ChapterListItem
project_repo = FakeProjectRepo()
pid = _seed_project(project_repo)
lister = _FakeChapterLister(
{
pid: [
ChapterListItem(chapter_no=1, has_draft=True, accepted=False, reviewed_at=None),
ChapterListItem(chapter_no=2, has_draft=False, accepted=True, reviewed_at=None),
ChapterListItem(chapter_no=5, has_draft=True, accepted=False, reviewed_at=None),
]
}
)
client = _make_client(project_repo=project_repo, lister=lister)
async with client:
resp = await client.get(f"/projects/{pid}/chapters")
assert resp.status_code == 200
assert [it["chapter_no"] for it in resp.json()] == [1, 2, 5]

View File

@@ -56,6 +56,7 @@ from ww_api.schemas.injection import (
from ww_api.schemas.projects import ( from ww_api.schemas.projects import (
AcceptRequest, AcceptRequest,
AcceptResponse, AcceptResponse,
ChapterListItem,
DraftResponse, DraftResponse,
DraftSaveRequest, DraftSaveRequest,
DraftStreamRequest, DraftStreamRequest,
@@ -75,10 +76,12 @@ from ww_api.services.accept_service import (
assert_conflicts_resolved, assert_conflicts_resolved,
run_accept_transaction, run_accept_transaction,
) )
from ww_api.services.chapter_list import ChapterLister
from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.digest_extraction import extract_digest_facts from ww_api.services.digest_extraction import extract_digest_facts
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
get_chapter_lister,
get_chapter_repo, get_chapter_repo,
get_clarify_gateway, get_clarify_gateway,
get_digest_append_repo, get_digest_append_repo,
@@ -115,6 +118,7 @@ _SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)]
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)] GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)] ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)] DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
@@ -166,6 +170,28 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
return _to_response(view) return _to_response(view)
@router.get("/{project_id}/chapters", responses=_NOT_FOUND)
async def list_chapters(
project_id: uuid.UUID,
project_repo: ProjectRepoDep,
lister: ChapterListerDep,
) -> list[ChapterListItem]:
"""列出该项目「真实存在的章」+ 可审/已审标记(审稿页选章下拉枚举)。
来源 `chapters`(草稿/accepted+ `chapter_reviews`,按章号去重升序。只读、不触 LLM。
项目不存在 → 404同其它端点owner 走 project_repo stub 校验)。
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
items = await lister.list_chapters(project_id)
log.info(
"chapters_listed",
project_id=str(project_id),
chapter_count=len(items),
)
return items
async def _injection_response( async def _injection_response(
repos: MemoryRepos, repos: MemoryRepos,
project_id: uuid.UUID, project_id: uuid.UUID,

View File

@@ -189,6 +189,23 @@ class DraftView(BaseModel):
length: int length: int
# ---- 列章(审稿选章枚举)----
class ChapterListItem(BaseModel):
"""GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记snake_case
数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。
`has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本;
`reviewed_at`=最近一次审稿时间(无则 null
"""
chapter_no: int
has_draft: bool = Field(description="是否有非空草稿正文(可审)")
accepted: bool = Field(description="是否已验收(存在 accepted 版本)")
reviewed_at: datetime | None = Field(default=None, description="最近一次审稿时间;未审为 null")
# ---- 审稿T2.5---- # ---- 审稿T2.5----

View File

@@ -0,0 +1,109 @@
"""列章只读服务:枚举项目「真实存在的章」+ 可审/已审标记(审稿选章下拉用)。
数据来源:`chapters`(草稿/accepted 版本)+ `chapter_reviews`。核心业务逻辑是把这两张表
的行**聚合**为「按章号去重、升序」的列章视图,抽成纯函数 `build_chapter_list` 以便脱库直测
(真 PG 行为交 tests/ E2E符合本仓单测确定性纪律。`SqlChapterLister` 只负责取行 + 调聚合。
不变量:只读、不写库、不触 LLM按 project_id 隔离owner 校验在端点走 project_repo同其它端点
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.chapter_repo import ACCEPTED_STATUS, DRAFT_STATUS
from ww_db.models import Chapter, ChapterReview
from ww_api.schemas.projects import ChapterListItem
@dataclass(frozen=True)
class ChapterRow:
"""`chapters` 行的最小读投影(聚合所需字段)。"""
chapter_no: int
status: str
content: str | None
@dataclass(frozen=True)
class ReviewRow:
"""`chapter_reviews` 行的最小读投影(章号 + 审稿时间)。"""
chapter_no: int
created_at: datetime
def build_chapter_list(
chapter_rows: list[ChapterRow], review_rows: list[ReviewRow]
) -> list[ChapterListItem]:
"""把 chapters/reviews 行聚合为按章号去重、升序的列章视图(纯函数,无副作用)。
- `has_draft`:该章存在 status='draft' 且正文非空白的行(与 GET .../draft「空草稿=无」一致)。
- `accepted`:该章存在 status='accepted' 行。
- `reviewed_at`:该章审稿行 created_at 的最大值(无审稿则 None
- 章号来自两表并集(含仅审稿未存草稿的章),去重后升序。
"""
has_draft: dict[int, bool] = {}
accepted: dict[int, bool] = {}
reviewed_at: dict[int, datetime] = {}
for row in chapter_rows:
if row.status == DRAFT_STATUS and row.content is not None and row.content.strip():
has_draft[row.chapter_no] = True
if row.status == ACCEPTED_STATUS:
accepted[row.chapter_no] = True
for review in review_rows:
current = reviewed_at.get(review.chapter_no)
if current is None or review.created_at > current:
reviewed_at[review.chapter_no] = review.created_at
chapter_nos = sorted({r.chapter_no for r in chapter_rows} | {r.chapter_no for r in review_rows})
return [
ChapterListItem(
chapter_no=no,
has_draft=has_draft.get(no, False),
accepted=accepted.get(no, False),
reviewed_at=reviewed_at.get(no),
)
for no in chapter_nos
]
class ChapterLister(Protocol):
"""列章只读接口(按 project_id 隔离)——端点依赖此协议,测试可注入 fake。"""
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]: ...
class SqlChapterLister:
"""SQLAlchemy 实现:两条只读查询取行 → 交纯聚合。单项目章数有界,聚合放 Python 侧KISS"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def list_chapters(self, project_id: uuid.UUID) -> list[ChapterListItem]:
chapter_result = await self._s.execute(
select(Chapter.chapter_no, Chapter.status, Chapter.content).where(
Chapter.project_id == project_id
)
)
review_result = await self._s.execute(
select(ChapterReview.chapter_no, ChapterReview.created_at).where(
ChapterReview.project_id == project_id
)
)
chapter_rows = [
ChapterRow(chapter_no=r.chapter_no, status=r.status, content=r.content)
for r in chapter_result.all()
]
review_rows = [
ReviewRow(chapter_no=r.chapter_no, created_at=r.created_at) for r in review_result.all()
]
return build_chapter_list(chapter_rows, review_rows)

View File

@@ -53,6 +53,7 @@ from ww_api.security.credentials import (
CredentialKeyError, CredentialKeyError,
decrypt_api_key, decrypt_api_key,
) )
from ww_api.services.chapter_list import ChapterLister, SqlChapterLister
from ww_api.services.credentials import ( from ww_api.services.credentials import (
AUTH_TYPE_OAUTH, AUTH_TYPE_OAUTH,
STUB_OWNER_ID, STUB_OWNER_ID,
@@ -99,6 +100,13 @@ def get_chapter_repo(
return SqlChapterRepo(session) return SqlChapterRepo(session)
def get_chapter_lister(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ChapterLister:
"""列章只读服务GET /projects/:id/chapters。只读、不写库测试注入 fake lister。"""
return SqlChapterLister(session)
def get_memory_repos( def get_memory_repos(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> MemoryRepos: ) -> MemoryRepos:

View File

@@ -134,6 +134,21 @@ body {
} }
} }
/* 主题切换图标:显隐由 data-theme 决定(而非 JS 状态),两个图标始终在 DOM 里,
服务端/客户端首帧结构一致——既避免水合不一致,又无首帧闪烁。 */
.theme-icon-moon {
display: inline-block;
}
.theme-icon-sun {
display: none;
}
[data-theme="night"] .theme-icon-moon {
display: none;
}
[data-theme="night"] .theme-icon-sun {
display: inline-block;
}
/* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion见下。 */ /* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion见下。 */
.toast-enter { .toast-enter {
animation: toast-in var(--dur-base) var(--ease-standard); animation: toast-in var(--dur-base) var(--ease-standard);
@@ -149,7 +164,81 @@ body {
} }
} }
/* AI 工作中·三点波动(比 animate-pulse 更明显:竖向弹跳 + 亮度,供 ThinkingIndicator 用)。 */
.ai-dot {
animation: ai-dot 1.2s ease-in-out infinite;
}
@keyframes ai-dot {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-3px);
opacity: 1;
}
}
/* AI 流式/处理中·不确定进度条(细条来回扫,明确传达"正在工作")。 */
.ai-stream-track {
position: relative;
overflow: hidden;
background: var(--color-cinnabar-wash);
}
.ai-stream-track::after {
content: "";
position: absolute;
inset-block: 0;
left: -40%;
width: 40%;
border-radius: 9999px;
background: var(--color-cinnabar);
animation: ai-stream 1.4s ease-in-out infinite;
}
@keyframes ai-stream {
0% {
left: -40%;
width: 35%;
}
50% {
width: 55%;
}
100% {
left: 100%;
width: 35%;
}
}
/* AI 构思中·占位微光(正文区首 token 前的呼吸感,比骨架更"活")。 */
.ai-breathe {
animation: ai-breathe 1.8s ease-in-out infinite;
}
@keyframes ai-breathe {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ai-dot {
animation: none;
opacity: 0.85;
}
.ai-stream-track::after {
animation: none;
left: 0;
width: 100%;
opacity: 0.35;
}
.ai-breathe {
animation: none;
}
.typewriter-cursor { .typewriter-cursor {
animation: none; animation: none;
} }

View File

@@ -23,7 +23,7 @@ export default async function DashboardPage() {
return ( return (
<AppShell> <AppShell>
<PageContainer> <PageContainer width="wide">
<PageHeader <PageHeader
title="我的作品" title="我的作品"
description="从一个灵感进入正文、设定、审稿与验收闭环。" description="从一个灵感进入正文、设定、审稿与验收闭环。"

View File

@@ -2,12 +2,17 @@ import { notFound } from "next/navigation";
import { ReviewReport } from "@/components/review/ReviewReport"; import { ReviewReport } from "@/components/review/ReviewReport";
import { import {
fetchChapters,
fetchDraft, fetchDraft,
fetchOutline, fetchOutline,
fetchProject, fetchProject,
fetchReviews, fetchReviews,
} from "@/lib/api/server"; } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import {
buildReviewChapterOptions,
defaultReviewChapter,
} from "@/lib/review/chapterNav";
import { latestReview } from "@/lib/review/history"; import { latestReview } from "@/lib/review/history";
import type { ChapterEntry } from "@/lib/workbench/chapter"; import type { ChapterEntry } from "@/lib/workbench/chapter";
@@ -16,14 +21,11 @@ interface PageProps {
searchParams: Promise<{ chapter?: string }>; searchParams: Promise<{ chapter?: string }>;
} }
const DEFAULT_CHAPTER_NO = 1;
// 审稿报告页UX §6.4。Server Component 取项目 + 审稿留痕历史(新→旧); // 审稿报告页UX §6.4。Server Component 取项目 + 审稿留痕历史(新→旧);
// ReviewReportClient承载 SSE 重审 / 裁决 / 验收交互。 // ReviewReportClient承载 SSE 重审 / 裁决 / 验收交互。
export default async function ReviewPage({ params, searchParams }: PageProps) { export default async function ReviewPage({ params, searchParams }: PageProps) {
const { id } = await params; const { id } = await params;
const { chapter } = await searchParams; const { chapter } = await searchParams;
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
// 仅当项目确实取不到时才判 404审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。 // 仅当项目确实取不到时才判 404审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
let project: ProjectResponse; let project: ProjectResponse;
@@ -33,6 +35,11 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
notFound(); notFound();
} }
// 真实存在的章(含可审/已审标记,错误→[]):供选章枚举 + 决定默认章。
const chapterList = await fetchChapters(id);
// 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章。
const chapterNo = parsePositiveInt(chapter) ?? defaultReviewChapter(chapterList);
// 审稿留痕GET .../reviews失败→空历史 // 审稿留痕GET .../reviews失败→空历史
let initialReview; let initialReview;
try { try {
@@ -57,6 +64,13 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
title: c.beats?.[0], title: c.beats?.[0],
})); }));
// 选章下拉选项:以真实章为准(覆盖「写过但不在大纲」的章),大纲标题补短名,标注可审/已审。
const chapterOptions = buildReviewChapterOptions(
chapterList,
chapters,
chapterNo,
);
return ( return (
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态, // key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
// 避免 ReviewReport 内 useState 基线沿用上一章。 // 避免 ReviewReport 内 useState 基线沿用上一章。
@@ -67,6 +81,7 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
initialReview={initialReview} initialReview={initialReview}
initialDraft={initialDraft} initialDraft={initialDraft}
chapters={chapters} chapters={chapters}
chapterOptions={chapterOptions}
/> />
); );
} }

View File

@@ -26,13 +26,6 @@ function readStoredThemeMode(): ThemeMode {
} }
} }
// 初始模式直接读 ThemeScript 在水合前写入的 <html data-theme>,避免夜读首帧渲染错误图标。
// SSR 守卫:服务端无 document → 回落默认(与 ThemeScript 的默认一致)。
function initialThemeMode(): ThemeMode {
if (typeof document === "undefined") return DEFAULT_THEME_MODE;
return normalizeThemeMode(document.documentElement.dataset.theme);
}
function writeStoredThemeMode(mode: ThemeMode) { function writeStoredThemeMode(mode: ThemeMode) {
try { try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode); window.localStorage.setItem(THEME_STORAGE_KEY, mode);
@@ -42,7 +35,9 @@ function writeStoredThemeMode(mode: ThemeMode) {
} }
export function ThemeToggle() { export function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>(initialThemeMode); // 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。
// 可见的图标由 CSS 按 <html data-theme> 决定ThemeScript 已在水合前写入),故无首帧闪烁。
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
useEffect(() => { useEffect(() => {
// 水合后以 localStorage 为权威源校正dataset 已由 ThemeScript 同样依据写入,通常一致)。 // 水合后以 localStorage 为权威源校正dataset 已由 ThemeScript 同样依据写入,通常一致)。
@@ -60,8 +55,8 @@ export function ThemeToggle() {
}); });
}; };
const Icon = mode === "night" ? Sun : Moon; // 图标显隐交给 CSS按 data-theme两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。
// 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。
return ( return (
<Button <Button
onClick={toggle} onClick={toggle}
@@ -71,7 +66,8 @@ export function ThemeToggle() {
title={themeToggleLabel(mode)} title={themeToggleLabel(mode)}
className="border-transparent" className="border-transparent"
> >
<Icon className="h-4 w-4" aria-hidden="true" /> <Moon className="theme-icon-moon h-4 w-4" aria-hidden="true" />
<Sun className="theme-icon-sun h-4 w-4" aria-hidden="true" />
<span className="sr-only">{themeModeLabel(mode)}</span> <span className="sr-only">{themeModeLabel(mode)}</span>
</Button> </Button>
); );

View File

@@ -17,12 +17,12 @@ export function ThinkingIndicator({
role="status" role="status"
aria-live="polite" aria-live="polite"
> >
<span className="inline-flex gap-0.5" aria-hidden="true"> <span className="inline-flex gap-1" aria-hidden="true">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<span <span
key={i} key={i}
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse" className="ai-dot h-2 w-2 rounded-full bg-current"
style={{ animationDelay: `${i * 200}ms` }} style={{ animationDelay: `${i * 150}ms` }}
/> />
))} ))}
</span> </span>

View File

@@ -171,8 +171,8 @@ export function CharacterGenerator({
<Button <Button
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
loading={generating}
disabled={ disabled={
generating ||
brief.trim().length === 0 || brief.trim().length === 0 ||
(mode === "mix" (mode === "mix"
? mixTotal < MIN_CHARACTER_COUNT ? mixTotal < MIN_CHARACTER_COUNT
@@ -181,7 +181,7 @@ export function CharacterGenerator({
variant="primary" variant="primary"
> >
<Sparkles className="h-4 w-4" aria-hidden="true" /> <Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button> </Button>
</div> </div>

View File

@@ -66,11 +66,12 @@ export function WorldGenerator({ projectId }: WorldGeneratorProps) {
</Field> </Field>
<Button <Button
onClick={() => void onGenerate()} onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0} loading={generating}
disabled={brief.trim().length === 0}
variant="primary" variant="primary"
> >
<Sparkles className="h-4 w-4" aria-hidden="true" /> <Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成世界观"}
</Button> </Button>
</div> </div>

View File

@@ -150,7 +150,7 @@ export function ProjectLibrary({ projects }: ProjectLibraryProps) {
} }
/> />
) : viewMode === "cards" ? ( ) : viewMode === "cards" ? (
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"> <ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{visibleProjects.map((p) => ( {visibleProjects.map((p) => (
<li key={p.id}> <li key={p.id}>
<ProjectCard project={p} /> <ProjectCard project={p} />

View File

@@ -17,7 +17,11 @@ import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { reviewChapterHref, reviewChapterOptions } from "@/lib/review/chapterNav"; import {
reviewChapterHref,
reviewChapterOptions,
type ReviewChapterOption,
} from "@/lib/review/chapterNav";
import type { ChapterEntry } from "@/lib/workbench/chapter"; import type { ChapterEntry } from "@/lib/workbench/chapter";
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types"; import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
@@ -74,8 +78,10 @@ interface ReviewReportProps {
chapterNo: number; chapterNo: number;
initialReview: ReviewHistoryItem | undefined; initialReview: ReviewHistoryItem | undefined;
initialDraft: string; initialDraft: string;
// 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器 // 章节导航目录(大纲章节):无 chapterOptions 时回退用
chapters?: ChapterEntry[]; chapters?: ChapterEntry[];
// 选章选项(以真实章为准,含可审/已审标记);优先于 chapters 渲染选择器。
chapterOptions?: ReviewChapterOption[];
} }
// 审稿报告页主体UX §6.4 / §8.3 / §9 // 审稿报告页主体UX §6.4 / §8.3 / §9
@@ -87,6 +93,7 @@ export function ReviewReport({
initialReview, initialReview,
initialDraft, initialDraft,
chapters, chapters,
chapterOptions,
}: ReviewReportProps) { }: ReviewReportProps) {
const router = useRouter(); const router = useRouter();
const review = useReviewStream(); const review = useReviewStream();
@@ -460,7 +467,7 @@ export function ReviewReport({
<Select <Select
controlSize="sm" controlSize="sm"
aria-label="切换审稿章节" aria-label="切换审稿章节"
className="hidden max-w-[12rem] shrink-0 sm:block" className="max-w-[12rem] shrink-0"
value={chapterNo} value={chapterNo}
onChange={(e) => onChange={(e) =>
router.push( router.push(
@@ -468,8 +475,10 @@ export function ReviewReport({
) )
} }
> >
{reviewChapterOptions(chapters ?? [], chapterNo).map((opt) => ( {(
<option key={opt.no} value={opt.no}> chapterOptions ?? reviewChapterOptions(chapters ?? [], chapterNo)
).map((opt: ReviewChapterOption) => (
<option key={opt.no} value={opt.no} disabled={opt.disabled}>
{opt.label} {opt.label}
</option> </option>
))} ))}

View File

@@ -254,9 +254,9 @@ export function GeneratorRunner({
onChange={(v) => setField(field.name, v)} onChange={(v) => setField(field.name, v)}
/> />
))} ))}
<Button type="submit" disabled={generating} variant="primary"> <Button type="submit" loading={generating} variant="primary">
<Sparkles className="h-4 w-4" aria-hidden="true" /> <Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</Button> </Button>
{generating ? ( {generating ? (
<ThinkingIndicator <ThinkingIndicator
@@ -297,13 +297,12 @@ export function GeneratorRunner({
{canIngest && gen.ingestStatus !== "conflict" ? ( {canIngest && gen.ingestStatus !== "conflict" ? (
<Button <Button
onClick={() => void runIngest(false)} onClick={() => void runIngest(false)}
disabled={ingesting || (!singleObject && selected.size === 0)} loading={ingesting}
disabled={!singleObject && selected.size === 0}
variant="outline" variant="outline"
> >
<Database className="h-4 w-4" aria-hidden="true" /> <Database className="h-4 w-4" aria-hidden="true" />
{ingesting {singleObject
? "入库中…"
: singleObject
? `入库为规则至 ${table}` ? `入库为规则至 ${table}`
: `入库选中(${selected.size})至 ${table}`} : `入库选中(${selected.size})至 ${table}`}
</Button> </Button>

View File

@@ -5,11 +5,14 @@ import {
type ButtonSize, type ButtonSize,
type ButtonVariant, type ButtonVariant,
} from "@/lib/ui/variants"; } from "@/lib/ui/variants";
import { Spinner } from "./Spinner";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode; children: ReactNode;
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
// 忙碌态:显示转圈 + 禁用 + aria-busy统一各处"处理中"的过程感。
loading?: boolean;
} }
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
@@ -19,6 +22,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
variant, variant,
size, size,
type = "button", type = "button",
loading = false,
disabled,
...props ...props
}, },
ref, ref,
@@ -28,8 +33,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref} ref={ref}
type={type} type={type}
className={buttonClass({ variant, size, className })} className={buttonClass({ variant, size, className })}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props} {...props}
> >
{loading ? <Spinner className="h-4 w-4" /> : null}
{children} {children}
</button> </button>
); );

View File

@@ -0,0 +1,49 @@
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { cn } from "@/lib/ui/variants";
interface GenerationSkeletonProps {
// 状态文案如「续写中」「AI 正在构思本章…」)。
label: string;
// 呼吸微光占位行数(默认 3
lines?: number;
// sm面板内小占位lg正文区大占位更粗行 + 稍大标签)。
size?: "sm" | "lg";
className?: string;
}
// 占位行宽度序列(错落感,示意即将落笔的段落)。
const LINE_WIDTHS = ["90%", "78%", "84%", "68%", "72%"];
// AI 同步/构思等待占位:三点波动标签 + `ai-breathe` 呼吸微光占位行,
// 比单三点更明显地传达「正在生成」。占位行 aria-hiddenrole=status/aria-live 由
// ThinkingIndicator 提供读屏可知在生成。ai-breathe 自带 reduced-motion 回退。
export function GenerationSkeleton({
label,
lines = 3,
size = "sm",
className,
}: GenerationSkeletonProps) {
return (
<div className={cn("space-y-2.5", className)}>
<ThinkingIndicator
label={label}
className={cn("text-cinnabar", size === "lg" ? "text-sm" : "text-xs")}
/>
<div className="space-y-2" aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={cn(
"ai-breathe rounded bg-line/60",
size === "lg" ? "h-3.5" : "h-3",
)}
style={{
width: LINE_WIDTHS[i % LINE_WIDTHS.length],
animationDelay: `${i * 160}ms`,
}}
/>
))}
</div>
</div>
);
}

View File

@@ -7,7 +7,7 @@ export type PageWidth = "prose" | "default" | "wide";
const widths: Record<PageWidth, string> = { const widths: Record<PageWidth, string> = {
prose: "max-w-3xl", prose: "max-w-3xl",
default: "max-w-5xl", default: "max-w-5xl",
wide: "max-w-6xl", wide: "max-w-7xl",
}; };
interface PageContainerProps { interface PageContainerProps {

View File

@@ -0,0 +1,18 @@
import { cn } from "@/lib/ui/variants";
interface SpinnerProps {
className?: string;
}
// 转圈忙碌指示环形顶部留缺口motion-safe 旋转reduced-motion 下为静止环。
export function Spinner({ className }: SpinnerProps) {
return (
<span
aria-hidden="true"
className={cn(
"inline-block h-4 w-4 shrink-0 rounded-full border-2 border-current border-t-transparent motion-safe:animate-spin",
className,
)}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from "@/lib/ui/variants";
interface StreamingBarProps {
label?: string;
className?: string;
}
// AI 流式/处理中·不确定进度条:细朱砂条来回扫,明确传达"正在工作"。
// 显隐由调用方控制只在生成时挂载reduced-motion 下为静态朱砂条(见 globals.css
export function StreamingBar({ label = "AI 生成中", className }: StreamingBarProps) {
return (
<div
role="progressbar"
aria-label={label}
className={cn("ai-stream-track h-0.5 w-full rounded-full", className)}
/>
);
}

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "next/link";
import { import {
ArrowUpRight,
Minus, Minus,
Pin, Pin,
PinOff, PinOff,
@@ -15,6 +17,9 @@ import {
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { ChapterForeshadowList } from "@/components/workbench/ChapterForeshadowList";
import { chapterForeshadowTotal } from "@/lib/foreshadow/chapterForeshadow";
import { useChapterForeshadow } from "@/lib/foreshadow/useChapterForeshadow";
import { buttonClass } from "@/lib/ui/variants"; import { buttonClass } from "@/lib/ui/variants";
import { import {
isPinned, isPinned,
@@ -183,6 +188,8 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
) : null} ) : null}
</section> </section>
<ChapterForeshadowSection projectId={projectId} chapterNo={chapterNo} />
<section className="mt-6 border-t border-line-soft pt-5"> <section className="mt-6 border-t border-line-soft pt-5">
<PanelLabel></PanelLabel> <PanelLabel></PanelLabel>
<p className="mt-1.5 text-caption leading-6 text-ink-soft"> <p className="mt-1.5 text-caption leading-6 text-ink-soft">
@@ -193,6 +200,55 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
); );
} }
// 「本章伏笔」主动提醒:按当前章号把伏笔分可回收 / 待回收 / 已逾期三类,
// 让作者在写这章时就想起该回收哪条、哪条已逾期。只读,编辑走「伏笔看板」。
function ChapterForeshadowSection({ projectId, chapterNo }: ChapterAssistantProps) {
const { groups, status, reload } = useChapterForeshadow(projectId, chapterNo);
const boardHref = `/projects/${projectId}/foreshadow`;
const isEmpty = chapterForeshadowTotal(groups) === 0;
return (
<section className="mt-6 border-t border-line-soft pt-5">
<PanelLabel
action={<span className="text-2xs text-muted-soft"></span>}
>
</PanelLabel>
{status === "loading" ? (
<p className="mt-2 text-caption text-ink-soft"></p>
) : status === "error" ? (
<StatusNote variant="danger" className="mt-2 text-xs" title="伏笔暂不可用">
<button
type="button"
onClick={reload}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : isEmpty ? (
<p className="mt-2 text-caption leading-6 text-ink-soft">
</p>
) : (
<div className="mt-3">
<ChapterForeshadowList groups={groups} />
</div>
)}
<Link
href={boardHref}
className="mt-3 inline-flex items-center gap-1 text-caption text-cinnabar hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"
>
<ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</section>
);
}
interface EntityRowProps { interface EntityRowProps {
entity: InjectionEntity; entity: InjectionEntity;
pinned: boolean; pinned: boolean;

View File

@@ -0,0 +1,104 @@
import { Flag } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { StatusDot, type StatusTone } from "@/components/ui/StatusDot";
import {
chapterForeshadowTotal,
foreshadowStatusLabel,
type ChapterForeshadowGroups,
} from "@/lib/foreshadow/chapterForeshadow";
import type { ForeshadowView } from "@/lib/api/types";
import { badgeClass, cn, type BadgeVariant } from "@/lib/ui/variants";
// 「本章伏笔」分组只读清单:写作台右栏 + 速查抽屉共用DRY
// 三类各成一小块(空块不渲染);每条给状态点 + 编号 + 标题 + 状态徽标。
// 无动画,天然尊重 reduced-motion。空态由调用方决定文案。
interface GroupSpec {
key: keyof ChapterForeshadowGroups;
label: string;
tone: StatusTone;
// 已逾期用琥珀强调整块外框。
emphasize?: boolean;
// 可回收在标签前加 ⚑,提示「本章可安排回收」。
flag?: boolean;
}
// 顺序即优先级:可回收(现在能做)→ 已逾期(急)→ 待回收(在后,仅提示)。
const GROUP_SPECS: readonly GroupSpec[] = [
{ key: "recyclable", label: "可回收", tone: "success", flag: true },
{ key: "overdue", label: "已逾期", tone: "warning", emphasize: true },
{ key: "pending", label: "待回收", tone: "neutral" },
];
// 单条状态 → 徽标色OVERDUE 琥珀 / PARTIAL 朱 / OPEN 中性)。
function statusBadgeVariant(status: string): BadgeVariant {
if (status === "OVERDUE") return "warning";
if (status === "PARTIAL") return "accent";
return "neutral";
}
interface ChapterForeshadowListProps {
groups: ChapterForeshadowGroups;
}
export function ChapterForeshadowList({ groups }: ChapterForeshadowListProps) {
if (chapterForeshadowTotal(groups) === 0) return null;
return (
<div className="space-y-3">
{GROUP_SPECS.map((spec) => (
<ForeshadowGroupBlock key={spec.key} spec={spec} items={groups[spec.key]} />
))}
</div>
);
}
interface ForeshadowGroupBlockProps {
spec: GroupSpec;
items: ForeshadowView[];
}
function ForeshadowGroupBlock({ spec, items }: ForeshadowGroupBlockProps) {
if (items.length === 0) return null;
return (
<div>
<p className="flex items-center gap-1.5 text-2xs uppercase tracking-wide text-muted-soft">
{spec.flag ? (
<Flag className="h-3 w-3 text-pass" aria-hidden="true" />
) : (
<StatusDot tone={spec.tone} />
)}
<span>{spec.label}</span>
<span className="tabular-nums text-ink-soft">{items.length}</span>
</p>
<ul className="mt-1.5 space-y-1">
{items.map((item) => (
<li
key={item.code}
className={cn(
"rounded border px-2 py-1.5",
spec.emphasize
? "border-overdue/35 bg-overdue/10"
: "border-line bg-bg",
)}
>
<div className="flex items-center gap-2">
<span className="shrink-0 font-mono text-2xs text-ink-soft">
{item.code}
</span>
<span className="truncate text-sm text-ink">{item.title}</span>
<span
className={badgeClass({
variant: statusBadgeVariant(item.status),
className: "ml-auto shrink-0",
})}
>
{foreshadowStatusLabel(item.status)}
</span>
</div>
</li>
))}
</ul>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { StreamingBar } from "@/components/ui/StreamingBar";
import { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
import type { AiMessageView } from "@/lib/api/types"; import type { AiMessageView } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
@@ -198,6 +199,11 @@ export function ChapterRewritePanel({
</Button> </Button>
</div> </div>
{/* 重写流式期间:气泡区顶部挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */}
{rewrite.isStreaming ? (
<StreamingBar label="重写生成中" className="mt-3" />
) : null}
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */} {/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
{flatCount > 0 || status === "error" ? ( {flatCount > 0 || status === "error" ? (
<div <div

View File

@@ -22,12 +22,17 @@ import {
useContextDrawerData, useContextDrawerData,
type ContextResource, type ContextResource,
} from "@/lib/workbench/useContextDrawer"; } from "@/lib/workbench/useContextDrawer";
import { chapterForeshadowTotal } from "@/lib/foreshadow/chapterForeshadow";
import { useChapterForeshadow } from "@/lib/foreshadow/useChapterForeshadow";
import { ChapterForeshadowList } from "./ChapterForeshadowList";
// 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。 // 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。
export const CONTEXT_DRAWER_ENABLED = true; export const CONTEXT_DRAWER_ENABLED = true;
interface ContextDrawerProps { interface ContextDrawerProps {
projectId: string; projectId: string;
// 当前章号:伏笔速查按此分「可回收 / 待回收 / 已逾期」。
chapterNo: number;
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
// 关闭时把焦点还给触发按钮a11y // 关闭时把焦点还给触发按钮a11y
@@ -39,6 +44,7 @@ interface ContextDrawerProps {
// a11y 复用命令面板/Drawer 范式role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。 // a11y 复用命令面板/Drawer 范式role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。
export function ContextDrawer({ export function ContextDrawer({
projectId, projectId,
chapterNo,
open, open,
onClose, onClose,
triggerRef, triggerRef,
@@ -98,7 +104,12 @@ export function ContextDrawer({
<TabBar activeTab={activeTab} onSelect={setActiveTab} /> <TabBar activeTab={activeTab} onSelect={setActiveTab} />
<div className="flex-1 overflow-auto overscroll-contain px-4 py-4"> <div className="flex-1 overflow-auto overscroll-contain px-4 py-4">
<TabPanel projectId={projectId} activeTab={activeTab} data={data} /> <TabPanel
projectId={projectId}
chapterNo={chapterNo}
activeTab={activeTab}
data={data}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -144,12 +155,13 @@ function TabBar({ activeTab, onSelect }: TabBarProps) {
interface TabPanelProps { interface TabPanelProps {
projectId: string; projectId: string;
chapterNo: number;
activeTab: ContextTabKey; activeTab: ContextTabKey;
data: ReturnType<typeof useContextDrawerData>; data: ReturnType<typeof useContextDrawerData>;
} }
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。 // 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
function TabPanel({ projectId, activeTab, data }: TabPanelProps) { function TabPanel({ projectId, chapterNo, activeTab, data }: TabPanelProps) {
const href = contextTabHref(projectId, activeTab); const href = contextTabHref(projectId, activeTab);
return ( return (
<div role="tabpanel" className="space-y-3"> <div role="tabpanel" className="space-y-3">
@@ -160,7 +172,7 @@ function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
<OutlinePanel resource={data.outline} onRetry={data.reloadOutline} /> <OutlinePanel resource={data.outline} onRetry={data.reloadOutline} />
) : null} ) : null}
{activeTab === "foreshadow" ? ( {activeTab === "foreshadow" ? (
<PlaceholderPanel summary="伏笔账本:埋设/回收窗口与逾期告警在完整看板逐条管理。" /> <ForeshadowPanel projectId={projectId} chapterNo={chapterNo} />
) : null} ) : null}
{activeTab === "rules" ? ( {activeTab === "rules" ? (
<PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" /> <PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" />
@@ -307,7 +319,32 @@ function OutlinePanel({ resource, onRetry }: OutlinePanelProps) {
); );
} }
// 占位面板(伏笔/规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。 interface ForeshadowPanelProps {
projectId: string;
chapterNo: number;
}
// 伏笔速查(只读):按当前章号分「可回收 / 待回收 / 已逾期」,与右栏本章伏笔同数据源、同分类函数。
function ForeshadowPanel({ projectId, chapterNo }: ForeshadowPanelProps) {
const { groups, status, reload } = useChapterForeshadow(projectId, chapterNo);
if (status === "loading") return <LoadingNote />;
if (status === "error") {
return <ErrorNote message="伏笔暂不可用。" onRetry={reload} />;
}
if (chapterForeshadowTotal(groups) === 0) {
return <EmptyNote text="这一章暂无待回收或已逾期的伏笔(按已验收进度)。" />;
}
return (
<div>
<p className="mb-2 text-2xs uppercase tracking-wide text-muted-soft">
·
</p>
<ChapterForeshadowList groups={groups} />
</div>
);
}
// 占位面板(规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
function PlaceholderPanel({ summary }: { summary: string }) { function PlaceholderPanel({ summary }: { summary: string }) {
return ( return (
<StatusNote variant="info"> <StatusNote variant="info">

View File

@@ -3,8 +3,8 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { Check, Plus, X } from "lucide-react"; import { Check, Plus, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
import { SectionHeader } from "@/components/ui/SectionHeader"; import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { useContinue } from "@/lib/workbench/useContinue"; import { useContinue } from "@/lib/workbench/useContinue";
@@ -72,12 +72,6 @@ export function ContinuePanel({
</Button> </Button>
</div> </div>
{candidates.length === 0 && busy ? (
<div className="mt-3">
<ThinkingIndicator label="续写中" className="text-xs text-cinnabar" />
</div>
) : null}
{candidates.length === 0 && status === "error" ? ( {candidates.length === 0 && status === "error" ? (
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败"> <StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
<button <button
@@ -112,10 +106,13 @@ export function ContinuePanel({
))} ))}
</ul> </ul>
{/* 续写为同步请求(无逐字流):生成期间放一块呼吸微光骨架占位,比单三点更明显地示意正在生成。 */}
{busy ? <GenerationSkeleton label="续写中" className="mt-3" /> : null}
<div className="mt-3"> <div className="mt-3">
<Button <Button
onClick={() => void runGenerate()} onClick={() => void runGenerate()}
disabled={busy} loading={busy}
variant="secondary" variant="secondary"
size="sm" size="sm"
> >

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
import { cn } from "@/lib/ui/variants"; import { cn } from "@/lib/ui/variants";
import type { AiMessageView } from "@/lib/api/types"; import type { AiMessageView } from "@/lib/api/types";
import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation"; import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation";
@@ -73,7 +73,8 @@ function Bubble({ message, action, onAccept }: BubbleProps) {
)} )}
> >
{showThinking ? ( {showThinking ? (
<ThinkingIndicator label="生成中" className="text-sm text-cinnabar" /> // 空正文的进行中 ai 气泡(如同步 refine 的整段等待):呼吸微光骨架,比单三点更明显。
<GenerationSkeleton label="生成中" lines={2} className="min-w-[8rem]" />
) : ( ) : (
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink"> <p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
{message.content} {message.content}

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { cn, focusRing, proseBody } from "@/lib/ui/variants"; import { cn, focusRing, proseBody } from "@/lib/ui/variants";
import { ThinkingPlaceholder } from "./ThinkingPlaceholder";
export interface EditorSelection { export interface EditorSelection {
start: number; start: number;
@@ -14,6 +15,9 @@ interface EditorProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
streaming: boolean; streaming: boolean;
// 构思中(流式已开始但首 token 未到在正文区覆盖「AI 正在构思本章…」占位,
// 首个 token 到达即消失换成正文。纯展示层,不触碰流式逻辑。
isThinking?: boolean;
// 选区变化上报(供「润色选段」等选区级 AI 动作取材。空选区也会上报start===end // 选区变化上报(供「润色选段」等选区级 AI 动作取材。空选区也会上报start===end
onSelectionChange?: (selection: EditorSelection) => void; onSelectionChange?: (selection: EditorSelection) => void;
} }
@@ -24,6 +28,7 @@ export function Editor({
value, value,
onChange, onChange,
streaming, streaming,
isThinking = false,
onSelectionChange, onSelectionChange,
}: EditorProps) { }: EditorProps) {
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
@@ -46,7 +51,7 @@ export function Editor({
}, [value]); }, [value]);
return ( return (
<div className="mx-auto max-w-prose"> <div className="relative mx-auto max-w-prose">
<label htmlFor="chapter-editor" className="sr-only"> <label htmlFor="chapter-editor" className="sr-only">
</label> </label>
@@ -65,12 +70,13 @@ export function Editor({
focusRing, focusRing,
)} )}
/> />
{streaming ? ( {streaming && !isThinking ? (
<span <span
className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle" className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle"
aria-hidden="true" aria-hidden="true"
/> />
) : null} ) : null}
{isThinking ? <ThinkingPlaceholder /> : null}
</div> </div>
); );
} }

View File

@@ -283,7 +283,8 @@ export function RefinePanel({
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
onClick={() => void onRecommunicate(false)} onClick={() => void onRecommunicate(false)}
disabled={busy || clarifying || instruction.trim().length === 0} loading={busy}
disabled={clarifying || instruction.trim().length === 0}
variant="secondary" variant="secondary"
size="sm" size="sm"
> >

View File

@@ -0,0 +1,13 @@
import { GenerationSkeleton } from "@/components/ui/GenerationSkeleton";
// 正文区「AI 正在构思本章」占位(补首 token 前的最大缺口——长首字延迟不再像卡死)。
// 覆盖编辑区absolute inset-0 + bg-bg 遮住空/陈旧正文),三点波动 + 呼吸微光占位行示意即将落笔;
// 首个 token 到达stream 有正文)即由 Editor 卸载换成正文。role=status/aria-live 由内部
// ThinkingIndicator 提供,读屏可知在生成。放在 Editor 的 relative 容器内。
export function ThinkingPlaceholder() {
return (
<div className="absolute inset-0 z-10 rounded bg-bg pt-1">
<GenerationSkeleton label="AI 正在构思本章…" size="lg" lines={5} />
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer"; import { Drawer } from "@/components/Drawer";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { StreamingBar } from "@/components/ui/StreamingBar";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages"; import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave"; import { useAutosave } from "@/lib/autosave/useAutosave";
@@ -90,6 +91,8 @@ export function Workbench({
const toast = useToast(); const toast = useToast();
const autosave = useAutosave(project.id, chapterNo, initialText); const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream(); const stream = useDraftStream();
// 构思中 = 流式已开始但首 token 未到(正文区覆盖构思占位);有字后即为在吐字。
const isThinking = stream.isStreaming && stream.state.text.length === 0;
const lastStreamText = useRef(""); const lastStreamText = useRef("");
const canRefine = selection !== null && selection.text.trim().length > 0; const canRefine = selection !== null && selection.text.trim().length > 0;
const chapterTriggerRef = useRef<HTMLButtonElement>(null); const chapterTriggerRef = useRef<HTMLButtonElement>(null);
@@ -382,6 +385,10 @@ export function Workbench({
onClose={() => setRewriteOpen(false)} onClose={() => setRewriteOpen(false)}
/> />
) : null} ) : null}
{/* 写本章流式期间:编辑区顶部常挂一条不确定进度条,明确「进行中」(生成结束卸载)。 */}
{stream.isStreaming ? (
<StreamingBar label="AI 正在写本章" className="shrink-0" />
) : null}
<div ref={editorScrollRef} className="flex-1 overflow-auto px-6 py-8"> <div ref={editorScrollRef} className="flex-1 overflow-auto px-6 py-8">
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<StatusNote variant="info" className="mb-4"> <StatusNote variant="info" className="mb-4">
@@ -392,6 +399,7 @@ export function Workbench({
value={text} value={text}
onChange={onEditorChange} onChange={onEditorChange}
streaming={stream.isStreaming} streaming={stream.isStreaming}
isThinking={isThinking}
onSelectionChange={setSelection} onSelectionChange={setSelection}
/> />
</div> </div>
@@ -444,6 +452,7 @@ export function Workbench({
{/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */} {/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */}
<ContextDrawer <ContextDrawer
projectId={project.id} projectId={project.id}
chapterNo={chapterNo}
open={contextOpen} open={contextOpen}
onClose={() => setContextOpen(false)} onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef} triggerRef={contextTriggerRef}

View File

@@ -93,6 +93,29 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/projects/{project_id}/chapters": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Chapters
* @description 列出该项目「真实存在的章」+ 可审/已审标记(审稿页选章下拉枚举)。
*
* 来源 `chapters`(草稿/accepted+ `chapter_reviews`,按章号去重升序。只读、不触 LLM。
* 项目不存在 → 404同其它端点owner 走 project_repo stub 校验)。
*/
get: operations["list_chapters_projects__project_id__chapters_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/injection": { "/projects/{project_id}/chapters/{chapter_no}/injection": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1123,6 +1146,33 @@ export interface components {
*/ */
count: number; count: number;
}; };
/**
* ChapterListItem
* @description GET /projects/:id/chapters 单项:一个真实存在的章 + 可审/已审标记snake_case
*
* 数据来源为 `chapters`(草稿/accepted 版本)与 `chapter_reviews`,按章号去重升序。
* `has_draft`=有非空草稿正文(可审);`accepted`=已有 accepted 版本;
* `reviewed_at`=最近一次审稿时间(无则 null
*/
ChapterListItem: {
/** Chapter No */
chapter_no: number;
/**
* Has Draft
* @description 是否有非空草稿正文(可审)
*/
has_draft: boolean;
/**
* Accepted
* @description 是否已验收(存在 accepted 版本)
*/
accepted: boolean;
/**
* Reviewed At
* @description 最近一次审稿时间;未审为 null
*/
reviewed_at?: string | null;
};
/** /**
* CharacterCardView * CharacterCardView
* @description 单张角色卡(贴 ww_agents.CharacterCard预览 + 入库请求共用形)。 * @description 单张角色卡(贴 ww_agents.CharacterCard预览 + 入库请求共用形)。
@@ -2857,6 +2907,46 @@ export interface operations {
}; };
}; };
}; };
list_chapters_projects__project_id__chapters_get: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ChapterListItem"][];
};
};
/** @description 资源不存在 */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_injection_projects__project_id__chapters__chapter_no__injection_get: { get_injection_projects__project_id__chapters__chapter_no__injection_get: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -1,5 +1,6 @@
import { serverApiBase } from "./config"; import { serverApiBase } from "./config";
import type { import type {
ChapterListItem,
CharacterListResponse, CharacterListResponse,
DraftView, DraftView,
ForeshadowBoardResponse, ForeshadowBoardResponse,
@@ -150,6 +151,18 @@ export async function fetchOutline(
} }
} }
// 列章GET .../chapters裸数组审稿选章枚举真实存在的章 + 可审/已审标记。
// 任何错误降级为空列表(进页不阻塞,回退到大纲/当前章导航)。
export async function fetchChapters(
projectId: string,
): Promise<ChapterListItem[]> {
try {
return await getJson<ChapterListItem[]>(`/projects/${projectId}/chapters`);
} catch {
return [];
}
}
// 提示词/模板库列表GET /templates全局只读裸数组 // 提示词/模板库列表GET /templates全局只读裸数组
// 任何错误(含后端未上线)降级为空列表,模板页照常渲染(进页不阻塞)。 // 任何错误(含后端未上线)降级为空列表,模板页照常渲染(进页不阻塞)。
export async function fetchTemplates(): Promise<TemplateResponse[]> { export async function fetchTemplates(): Promise<TemplateResponse[]> {

View File

@@ -33,6 +33,8 @@ export type ReviewHistoryResponse =
export type ConflictDecision = components["schemas"]["ConflictDecision"]; export type ConflictDecision = components["schemas"]["ConflictDecision"];
export type AcceptRequest = components["schemas"]["AcceptRequest"]; export type AcceptRequest = components["schemas"]["AcceptRequest"];
export type AcceptResponse = components["schemas"]["AcceptResponse"]; export type AcceptResponse = components["schemas"]["AcceptResponse"];
// 列章GET .../chapters裸数组审稿选章枚举真实存在的章 + 可审/已审标记。
export type ChapterListItem = components["schemas"]["ChapterListItem"];
// Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。 // Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。
export type ChainRunRequest = components["schemas"]["ChainRunRequest"]; export type ChainRunRequest = components["schemas"]["ChainRunRequest"];

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import type { ForeshadowView } from "@/lib/api/types";
import {
chapterForeshadowTotal,
classifyChapterForeshadow,
foreshadowStatusLabel,
} from "./chapterForeshadow";
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001",
title: "线索",
status: "OPEN",
...over,
});
describe("classifyChapterForeshadow", () => {
it("puts items whose window covers the current chapter into 可回收", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "F-A", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
view({ code: "F-B", status: "PARTIAL", expected_close_to: 50 }),
],
50,
);
expect(groups.recyclable.map((v) => v.code)).toEqual(["F-A", "F-B"]);
expect(groups.pending).toEqual([]);
expect(groups.overdue).toEqual([]);
});
it("puts OPEN/PARTIAL with a later or missing window into 待回收", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "F-LATER", status: "OPEN", expected_close_from: 80, expected_close_to: 90 }),
view({ code: "F-NOWIN", status: "PARTIAL" }),
],
50,
);
expect(groups.pending.map((v) => v.code)).toEqual(["F-LATER", "F-NOWIN"]);
expect(groups.recyclable).toEqual([]);
});
it("puts OVERDUE into 已逾期 even when a window would otherwise cover the chapter", () => {
const groups = classifyChapterForeshadow(
[view({ code: "F-OD", status: "OVERDUE", expected_close_from: 40, expected_close_to: 60 })],
50,
);
expect(groups.overdue.map((v) => v.code)).toEqual(["F-OD"]);
expect(groups.recyclable).toEqual([]);
});
it("drops CLOSED and unknown statuses from every group", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "F-CLOSED", status: "CLOSED", expected_close_to: 60 }),
view({ code: "F-WEIRD", status: "WEIRD" }),
],
50,
);
expect(chapterForeshadowTotal(groups)).toBe(0);
});
it("handles the window boundary: inclusive at from and to, excluded just outside", () => {
const win = (code: string): ForeshadowView =>
view({ code, status: "OPEN", expected_close_from: 40, expected_close_to: 60 });
expect(classifyChapterForeshadow([win("lo")], 40).recyclable).toHaveLength(1);
expect(classifyChapterForeshadow([win("hi")], 60).recyclable).toHaveLength(1);
expect(classifyChapterForeshadow([win("before")], 39).pending).toHaveLength(1);
expect(classifyChapterForeshadow([win("after")], 61).pending).toHaveLength(1);
});
it("returns empty groups for empty / undefined input without mutating a shared object", () => {
const a = classifyChapterForeshadow(undefined, 1);
const b = classifyChapterForeshadow([], 1);
expect(chapterForeshadowTotal(a)).toBe(0);
expect(chapterForeshadowTotal(b)).toBe(0);
// 不可变:两次调用返回相互独立的空组
a.pending.push(view({}));
expect(b.pending).toEqual([]);
});
});
describe("chapterForeshadowTotal", () => {
it("sums the three lanes", () => {
const groups = classifyChapterForeshadow(
[
view({ code: "r", status: "OPEN", expected_close_to: 50 }),
view({ code: "p", status: "OPEN" }),
view({ code: "o", status: "OVERDUE" }),
],
50,
);
expect(chapterForeshadowTotal(groups)).toBe(3);
});
});
describe("foreshadowStatusLabel", () => {
it("maps known statuses to author-facing labels and falls back for unknown", () => {
expect(foreshadowStatusLabel("OPEN")).toBe("待推进");
expect(foreshadowStatusLabel("PARTIAL")).toBe("推进中");
expect(foreshadowStatusLabel("OVERDUE")).toBe("已逾期");
expect(foreshadowStatusLabel("WEIRD")).toBe("WEIRD");
});
});

View File

@@ -0,0 +1,51 @@
// 「本章伏笔」主动提醒的纯分类逻辑(写作台右栏 / 速查抽屉共用)。
// 输入全量伏笔 + 当前章号,用 board 的 isWindowApproaching + status 分三类。
// 纯函数、不可变(返回全新对象,不改动入参);便于 node 环境单测。
import type { ForeshadowView } from "@/lib/api/types";
import { isWindowApproaching, LANE_LABELS, type ForeshadowStatus } from "./board";
// 本章伏笔三分类:
// - recyclable 可回收:本章落在 [from,to] 窗口内OPEN/PARTIAL由 isWindowApproaching 判定)。
// - pending 待回收OPEN/PARTIAL 但尚未进入回收窗口(窗口在后 / 未设窗口)。
// - overdue 已逾期status==OVERDUE后端验收后扫描得出略滞后于当前草稿
// CLOSED已回收与未知 status 不进任何一类——本章无需再提醒。
export interface ChapterForeshadowGroups {
recyclable: ForeshadowView[];
pending: ForeshadowView[];
overdue: ForeshadowView[];
}
// 按当前章号把伏笔分三类。纯函数:用 reduce + 展开返回新对象,不可变。
// 种子每次新建(不复用共享常量),空输入也返回独立的新对象。
export function classifyChapterForeshadow(
items: readonly ForeshadowView[] | undefined,
currentChapter: number,
): ChapterForeshadowGroups {
return (items ?? []).reduce<ChapterForeshadowGroups>(
(groups, item) => {
if (item.status === "OVERDUE") {
return { ...groups, overdue: [...groups.overdue, item] };
}
if (isWindowApproaching(item, currentChapter)) {
return { ...groups, recyclable: [...groups.recyclable, item] };
}
if (item.status === "OPEN" || item.status === "PARTIAL") {
return { ...groups, pending: [...groups.pending, item] };
}
return groups; // CLOSED / 未知 status本章不提醒
},
{ recyclable: [], pending: [], overdue: [] },
);
}
// 三类合计条数(是否有可提醒项,便于空态判断)。
export function chapterForeshadowTotal(groups: ChapterForeshadowGroups): number {
return groups.recyclable.length + groups.pending.length + groups.overdue.length;
}
// 单条伏笔的作者向状态文案(复用看板 LANE_LABELS未知 status 回退原值)。
export function foreshadowStatusLabel(status: string): string {
return LANE_LABELS[status as ForeshadowStatus] ?? status;
}

View File

@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ForeshadowView } from "@/lib/api/types";
import { useChapterForeshadow } from "./useChapterForeshadow";
// 后端客户端是 hook 的外部副作用边界,单测一律 mock。
const get = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { GET: (...a: unknown[]) => get(...a) },
}));
function makeRow(over: Partial<ForeshadowView>): ForeshadowView {
return { code: "F-001", title: "线索", status: "OPEN", ...over } as ForeshadowView;
}
describe("useChapterForeshadow", () => {
beforeEach(() => get.mockReset());
afterEach(() => vi.clearAllMocks());
it("拉取成功后按当前章号分类", async () => {
get.mockResolvedValue({
data: {
foreshadow: [
makeRow({ code: "R", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
makeRow({ code: "P", status: "OPEN", expected_close_from: 80 }),
makeRow({ code: "O", status: "OVERDUE" }),
],
},
error: null,
});
const { result } = renderHook(() => useChapterForeshadow("p1", 50));
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.groups.recyclable.map((v) => v.code)).toEqual(["R"]);
expect(result.current.groups.pending.map((v) => v.code)).toEqual(["P"]);
expect(result.current.groups.overdue.map((v) => v.code)).toEqual(["O"]);
});
it("拉取失败 → status=error、空分组", async () => {
get.mockResolvedValue({ data: null, error: { error: {} } });
const { result } = renderHook(() => useChapterForeshadow("p1", 3));
await waitFor(() => expect(result.current.status).toBe("error"));
expect(result.current.groups.recyclable).toEqual([]);
expect(result.current.groups.pending).toEqual([]);
expect(result.current.groups.overdue).toEqual([]);
});
it("切章不重新请求,仅重新分类", async () => {
get.mockResolvedValue({
data: {
foreshadow: [
makeRow({ code: "W", status: "OPEN", expected_close_from: 40, expected_close_to: 60 }),
],
},
error: null,
});
const { result, rerender } = renderHook(
({ ch }: { ch: number }) => useChapterForeshadow("p1", ch),
{ initialProps: { ch: 50 } },
);
await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.groups.recyclable.map((v) => v.code)).toEqual(["W"]);
// 切到窗口外的章:仍是同一份数据,重新分类进 pending且未再次 GET。
rerender({ ch: 10 });
await waitFor(() =>
expect(result.current.groups.pending.map((v) => v.code)).toEqual(["W"]),
);
expect(result.current.groups.recyclable).toEqual([]);
expect(get).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,71 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client";
import type { ForeshadowView } from "@/lib/api/types";
import {
classifyChapterForeshadow,
type ChapterForeshadowGroups,
} from "./chapterForeshadow";
// 写作台「本章伏笔」提醒的数据层拉本项目全量伏笔GET /foreshadow只读
// 再按当前章号本地分类。项目内一次拉取,切章仅重新分类(不重复请求)。
export type ChapterForeshadowStatus = "loading" | "ready" | "error";
export interface UseChapterForeshadow {
groups: ChapterForeshadowGroups;
status: ChapterForeshadowStatus;
reload: () => void;
}
const FORESHADOW_PATH = "/projects/{project_id}/foreshadow" as const;
const EMPTY_ITEMS: ForeshadowView[] = [];
export function useChapterForeshadow(
projectId: string,
chapterNo: number,
): UseChapterForeshadow {
const [items, setItems] = useState<ForeshadowView[]>(EMPTY_ITEMS);
const [status, setStatus] = useState<ChapterForeshadowStatus>("loading");
// reload 触发器:自增即重新拉取。
const [nonce, setNonce] = useState(0);
useEffect(() => {
let cancelled = false;
setStatus("loading");
void (async () => {
try {
const { data, error } = await api.GET(FORESHADOW_PATH, {
params: { path: { project_id: projectId } },
});
if (cancelled) return;
if (error || !data) {
setItems(EMPTY_ITEMS);
setStatus("error");
return;
}
setItems(data.foreshadow ?? EMPTY_ITEMS);
setStatus("ready");
} catch {
// 网络层失败(后端不可达/CORSopenapi-fetch 直接抛而非返回 error 信封。
if (cancelled) return;
setItems(EMPTY_ITEMS);
setStatus("error");
}
})();
return () => {
cancelled = true;
};
}, [projectId, nonce]);
// 切章不重拉,仅重新分类(伏笔窗口相对章号变化)。
const groups = useMemo(
() => classifyChapterForeshadow(items, chapterNo),
[items, chapterNo],
);
const reload = useCallback(() => setNonce((n) => n + 1), []);
return { groups, status, reload };
}

View File

@@ -1,8 +1,25 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { reviewChapterHref, reviewChapterOptions } from "./chapterNav"; import {
buildReviewChapterOptions,
defaultReviewChapter,
reviewChapterHref,
reviewChapterOptions,
} from "./chapterNav";
import type { ChapterListItem } from "@/lib/api/types";
import type { ChapterEntry } from "@/lib/workbench/chapter"; import type { ChapterEntry } from "@/lib/workbench/chapter";
const item = (
chapter_no: number,
over: Partial<ChapterListItem> = {},
): ChapterListItem => ({
chapter_no,
has_draft: true,
accepted: false,
reviewed_at: null,
...over,
});
describe("reviewChapterHref", () => { describe("reviewChapterHref", () => {
it("builds the review route for a chapter number", () => { it("builds the review route for a chapter number", () => {
expect(reviewChapterHref("proj-1", 3)).toBe( expect(reviewChapterHref("proj-1", 3)).toBe(
@@ -15,7 +32,7 @@ describe("reviewChapterHref", () => {
}); });
}); });
describe("reviewChapterOptions", () => { describe("reviewChapterOptions (legacy, outline-only)", () => {
const chapters: ChapterEntry[] = [ const chapters: ChapterEntry[] = [
{ no: 1, title: "开篇" }, { no: 1, title: "开篇" },
{ no: 2 }, { no: 2 },
@@ -32,14 +49,72 @@ describe("reviewChapterOptions", () => {
expect(opts.map((o) => o.no)).toEqual([1, 2, 3, 9]); expect(opts.map((o) => o.no)).toEqual([1, 2, 3, 9]);
}); });
it("labels each option with chapter number and optional title", () => { it("labels each option with chapter number and optional title (never disabled)", () => {
const opts = reviewChapterOptions(chapters, 1); const opts = reviewChapterOptions(chapters, 1);
expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇" }); expect(opts[0]).toEqual({ no: 1, label: "第 1 章 · 开篇", disabled: false });
expect(opts[1]).toEqual({ no: 2, label: "第 2 章" }); expect(opts[1]).toEqual({ no: 2, label: "第 2 章", disabled: false });
}); });
});
it("returns just the current chapter when there is no outline", () => {
const opts = reviewChapterOptions([], 4); describe("buildReviewChapterOptions (real chapters + outline titles)", () => {
expect(opts).toEqual([{ no: 4, label: "第 4 章" }]); const outline: ChapterEntry[] = [
{ no: 1, title: "开篇" },
{ no: 2, title: "转折" },
];
it("enumerates real chapters, sorted, with outline titles + status suffix", () => {
const list = [
item(1, { accepted: true }),
item(2, { reviewed_at: "2026-01-01T00:00:00Z" }),
];
const opts = buildReviewChapterOptions(list, outline, 2);
expect(opts).toEqual([
{ no: 1, label: "第 1 章 · 开篇 · 已验收", disabled: false },
{ no: 2, label: "第 2 章 · 转折 · 已审", disabled: false },
]);
});
it("covers chapters written but not in the outline, and shows outline structure", () => {
const list = [item(1), item(5)]; // 第 5 章 写过但大纲没有;大纲有第 2 章但未写
const opts = buildReviewChapterOptions(list, outline, 1);
expect(opts.map((o) => o.no)).toEqual([1, 2, 5]);
expect(opts.find((o) => o.no === 5)?.label).toBe("第 5 章 · 草稿");
// 大纲有、未写的章出现在结构里但置灰(不可审)
expect(opts.find((o) => o.no === 2)?.disabled).toBe(true);
});
it("disables chapters with no draft, but never the current chapter", () => {
const list = [item(1), item(2, { has_draft: false })];
const optsOnOther = buildReviewChapterOptions(list, outline, 1);
expect(optsOnOther.find((o) => o.no === 2)?.disabled).toBe(true);
// 当前就在第 2 章时不置灰(否则无法停留)
const optsOnEmpty = buildReviewChapterOptions(list, outline, 2);
expect(optsOnEmpty.find((o) => o.no === 2)?.disabled).toBe(false);
});
it("always includes the current chapter even if absent from list and outline", () => {
const opts = buildReviewChapterOptions([item(1)], outline, 7);
expect(opts.map((o) => o.no)).toEqual([1, 2, 7]);
expect(opts.find((o) => o.no === 7)).toEqual({
no: 7,
label: "第 7 章",
disabled: false,
});
});
});
describe("defaultReviewChapter", () => {
it("prefers the latest chapter that has a draft", () => {
const list = [item(1), item(2), item(3, { has_draft: false })];
expect(defaultReviewChapter(list)).toBe(2);
});
it("falls back to the max chapter number when none have drafts", () => {
const list = [item(1, { has_draft: false }), item(4, { has_draft: false })];
expect(defaultReviewChapter(list)).toBe(4);
});
it("defaults to chapter 1 for an empty list", () => {
expect(defaultReviewChapter([])).toBe(1);
}); });
}); });

View File

@@ -1,15 +1,19 @@
import type { ChapterListItem } from "@/lib/api/types";
import type { ChapterEntry } from "@/lib/workbench/chapter"; import type { ChapterEntry } from "@/lib/workbench/chapter";
import { buildChapterEntries } from "@/lib/workbench/chapter"; import { buildChapterEntries } from "@/lib/workbench/chapter";
export interface ReviewChapterOption { export interface ReviewChapterOption {
no: number; no: number;
label: string; label: string;
// 有草稿正文=可审;无草稿章置灰(审稿依赖草稿,选空章会 422
disabled: boolean;
} }
export function reviewChapterHref(projectId: string, chapterNo: number): string { export function reviewChapterHref(projectId: string, chapterNo: number): string {
return `/projects/${projectId}/review?chapter=${chapterNo}`; return `/projects/${projectId}/review?chapter=${chapterNo}`;
} }
// 旧签名:仅按大纲章 + 当前章构建(无草稿状态)。保留兼容,无 chapterList 时回退用。
export function reviewChapterOptions( export function reviewChapterOptions(
chapters: readonly ChapterEntry[], chapters: readonly ChapterEntry[],
currentChapterNo: number, currentChapterNo: number,
@@ -19,5 +23,58 @@ export function reviewChapterOptions(
label: chapter.title label: chapter.title
? `${chapter.no} 章 · ${chapter.title}` ? `${chapter.no} 章 · ${chapter.title}`
: `${chapter.no}`, : `${chapter.no}`,
disabled: false,
})); }));
} }
function statusSuffix(item: ChapterListItem | undefined): string {
if (!item) return "";
if (item.accepted) return " · 已验收";
if (item.reviewed_at) return " · 已审";
if (item.has_draft) return " · 草稿";
return " · 空";
}
// 新签名:以「真实存在的章」(chapterList) 为准枚举,覆盖「写过但不在大纲」的章,
// 用大纲标题补短名,并标注可审/已审/已验收;无草稿章置灰(但当前章永不置灰)。
export function buildReviewChapterOptions(
chapterList: readonly ChapterListItem[],
outline: readonly ChapterEntry[],
currentChapterNo: number,
): ReviewChapterOption[] {
const titleByNo = new Map(outline.map((c) => [c.no, c.title]));
const itemByNo = new Map(chapterList.map((c) => [c.chapter_no, c]));
// 枚举真实写过的章(chapterList) 大纲章(outline展示全书结构) 当前章。
const nos = new Set<number>([
...chapterList.map((c) => c.chapter_no),
...outline.map((c) => c.no),
currentChapterNo,
]);
return [...nos]
.sort((a, b) => a - b)
.map((no) => {
const item = itemByNo.get(no);
const title = titleByNo.get(no);
const base = title ? `${no} 章 · ${title}` : `${no}`;
return {
no,
label: base + statusSuffix(item),
// 无草稿的章不可审(审稿依赖草稿);仅当前章例外,始终可停留。
disabled: no !== currentChapterNo && !item?.has_draft,
};
});
}
// 无 ?chapter 时的默认章:优先最近一个有草稿的章,其次最大章号,兜底第 1 章。
export function defaultReviewChapter(
chapterList: readonly ChapterListItem[],
): number {
const withDraft = chapterList
.filter((c) => c.has_draft)
.map((c) => c.chapter_no);
if (withDraft.length > 0) return Math.max(...withDraft);
if (chapterList.length > 0) {
return Math.max(...chapterList.map((c) => c.chapter_no));
}
return 1;
}

View File

@@ -88,7 +88,7 @@ const config: Config = {
transitionTimingFunction: { transitionTimingFunction: {
standard: "var(--ease-standard)", standard: "var(--ease-standard)",
}, },
maxWidth: { prose: "720px" }, maxWidth: { prose: "768px" },
}, },
}, },
plugins: [], plugins: [],

View File

@@ -11,7 +11,7 @@
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249", "de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
"expand": "74a5d71c4ffc11b78bf80c7ab3fc3eebfc1192ffff2b0bfa9957ec2c33d9d05e", "expand": "74a5d71c4ffc11b78bf80c7ab3fc3eebfc1192ffff2b0bfa9957ec2c33d9d05e",
"fine-outline": "0ae0b71f4144e82ffa2a7a0059505525857f7d9797e94ced25da3129355a46ed", "fine-outline": "0ae0b71f4144e82ffa2a7a0059505525857f7d9797e94ced25da3129355a46ed",
"foreshadow": "405696461fef445e4609227c40df6c4812737735edea7e1966ee84afe7635645", "foreshadow": "b3f80c86d5f410ac4abe1fbfcacf0ad3c8ef547a0082cb0ea7a63b2edf5f6f24",
"glossary": "54e95d21d10b9209287c10517134f485ca5ce4a3ef08291aac954dcf492d73d0", "glossary": "54e95d21d10b9209287c10517134f485ca5ce4a3ef08291aac954dcf492d73d0",
"golden-finger": "416e8591d99ea1296db550bc428012aa937eed47eea03ef435e92c6843005e21", "golden-finger": "416e8591d99ea1296db550bc428012aa937eed47eea03ef435e92c6843005e21",
"name": "ee759bc967773e3300813f250f233c872fd3a4f0cb00086509ffc3552d67700b", "name": "ee759bc967773e3300813f250f233c872fd3a4f0cb00086509ffc3552d67700b",

View File

@@ -1,7 +1,7 @@
你是长篇连载小说的「伏笔续审」foreshadow-analyst——一位专盯长线叙事「埋设—回收」契约的资深审稿人。你的唯一职责把本章草稿与作品已登记伏笔逐项比对找出本章**新埋的伏笔**与**疑似回收/收束**的伏笔,产出结构化建议清单。你只读、只产建议,不改稿、不写库。 你是长篇连载小说的「伏笔续审」foreshadow-analyst——一位专盯长线叙事「埋设—回收」契约的资深审稿人。你的唯一职责把本章草稿与作品已登记伏笔逐项比对找出本章**新埋的伏笔**与**疑似回收/收束**的伏笔,产出结构化建议清单。你只读、只产建议,不改稿、不写库。
## 比对依据(注入材料) ## 比对依据(注入材料)
- 已登记伏笔foreshadow每条含编码code、标题、当前状态、期望回收窗口 - 已登记伏笔foreshadow每条含编码code、标题、当前状态;登记了回收窗口的还带「期望回收 X-Y章」逾期者标「已逾期」
- 本章草稿正文。 - 本章草稿正文。
材料未提供的内容一律视为「不存在」,不得凭世界观常识或前文记忆脑补。 材料未提供的内容一律视为「不存在」,不得凭世界观常识或前文记忆脑补。

View File

@@ -25,6 +25,11 @@
- 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。 - 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。
- 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。 - 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。
## 伏笔:到点顺势收,别为收而收
- 注入材料里的伏笔可能带「期望回收窗口」或「已逾期」标记。若本章正落在某条伏笔的回收窗口内、或它已被标记逾期,可**顺着当下的情节自然地推进或兑现它**——让它收在剧情本就该走到的地方。
- 回收要**服务当下的冲突与情感**:能自然收束就收;一时收得牵强,就先推进一步、留待更合适的时机,也好过硬来。**切忌为凑一个「回收」而打断节奏、突兀交代**——宁可这一章只把线往前带一带,也不要为收而收、硬凑。
## 文字质感 ## 文字质感
- **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。 - **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。

View File

@@ -9,6 +9,7 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo, ForeshadowLedgerView
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
@@ -30,6 +31,12 @@ from ww_core.memory import (
render_cards, render_cards,
select_relevant_entities, select_relevant_entities,
) )
from ww_core.memory.foreshadow_inject import (
ForeshadowLine,
merge_foreshadow_lines,
render_foreshadow_line,
select_auto_lines,
)
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001") PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
@@ -130,6 +137,44 @@ class FakeReviewRepo:
raise NotImplementedError raise NotImplementedError
@dataclass
class FakeForeshadowLedgerRepo:
"""内存伏笔账本 fake按章自动挑选——只需 `list_by_status` 供 assemble 全量反读。
其余写方法只为满足 `ForeshadowLedgerRepo` Protocol 结构类型assemble 读路径不用。
"""
rows: list[ForeshadowLedgerView] = field(default_factory=list)
async def list_by_status(
self, project_id: uuid.UUID, status: str | None = None
) -> list[ForeshadowLedgerView]:
return [r for r in self.rows if status is None or r.status == status]
async def register(self, *args: Any, **kwargs: Any) -> ForeshadowLedgerView: # pragma: no cover
raise NotImplementedError
async def get(
self, *args: Any, **kwargs: Any
) -> ForeshadowLedgerView | None: # pragma: no cover
raise NotImplementedError
async def transition(
self, *args: Any, **kwargs: Any
) -> ForeshadowLedgerView: # pragma: no cover
raise NotImplementedError
async def record_progress(
self, *args: Any, **kwargs: Any
) -> ForeshadowLedgerView: # pragma: no cover
raise NotImplementedError
async def scan_overdue(
self, *args: Any, **kwargs: Any
) -> list[ForeshadowLedgerView]: # pragma: no cover
raise NotImplementedError
def build_repos( def build_repos(
*, *,
outline: dict[int, OutlineView] | None = None, outline: dict[int, OutlineView] | None = None,
@@ -141,6 +186,7 @@ def build_repos(
rules: list[RuleView] | None = None, rules: list[RuleView] | None = None,
spec: ProjectSpecView | None = None, spec: ProjectSpecView | None = None,
review: ReviewRepo | None = None, review: ReviewRepo | None = None,
foreshadow_ledger: ForeshadowLedgerRepo | None = None,
) -> MemoryRepos: ) -> MemoryRepos:
return MemoryRepos( return MemoryRepos(
outline=FakeOutlineRepo(outline or {}), outline=FakeOutlineRepo(outline or {}),
@@ -152,6 +198,7 @@ def build_repos(
rules=FakeRulesRepo(rules or []), rules=FakeRulesRepo(rules or []),
project=FakeProjectSpecRepo(spec), project=FakeProjectSpecRepo(spec),
review=review, review=review,
foreshadow_ledger=foreshadow_ledger,
) )
@@ -659,6 +706,238 @@ async def test_no_prior_conflict_notes_for_first_chapter() -> None:
assert "上一章冲突提醒" not in ctx.volatile assert "上一章冲突提醒" not in ctx.volatile
# ---- 伏笔注入丰富化 + 按章自动挑选(本任务) ----
def _line(
code: str,
status: str = "OPEN",
*,
from_: int | None = None,
to: int | None = None,
title: str = "某伏笔",
) -> ForeshadowLine:
return ForeshadowLine(
code=code,
title=title,
status=status,
expected_close_from=from_,
expected_close_to=to,
)
def _ledger(
code: str,
status: str = "OPEN",
*,
from_: int | None = None,
to: int | None = None,
title: str = "某伏笔",
) -> ForeshadowLedgerView:
return ForeshadowLedgerView(
code=code,
title=title,
status=status,
expected_close_from=from_,
expected_close_to=to,
)
# --- 纯函数:渲染格式(窗口 + 逾期标记 + 优雅降级) ---
def test_render_line_with_window() -> None:
line = _line("F1", "OPEN", from_=5, to=8, title="神秘石符")
assert render_foreshadow_line(line, 5) == "【伏笔】F1 神秘石符 [OPEN] 期望回收 5-8章"
def test_render_line_overdue_marker_by_chapter() -> None:
# 第 6 章已越过期望回收上界 4 → 追加逾期标记。
line = _line("F2", "OPEN", from_=3, to=4, title="断剑")
assert render_foreshadow_line(line, 6) == "【伏笔】F2 断剑 [OPEN] 期望回收 3-4章已逾期"
def test_render_line_overdue_marker_by_status() -> None:
# status==OVERDUE 直接标逾期,不论当前章号是否越界。
line = _line("F3", "OVERDUE", from_=3, to=8, title="旧账")
assert render_foreshadow_line(line, 5) == "【伏笔】F3 旧账 [OVERDUE] 期望回收 3-8章已逾期"
def test_render_line_window_missing_gracefully_omitted() -> None:
line = _line("F4", "OPEN", title="无窗伏笔")
out = render_foreshadow_line(line, 5)
assert out == "【伏笔】F4 无窗伏笔 [OPEN]"
assert "期望回收" not in out
def test_render_line_partial_window_omitted() -> None:
# 只有上界、无下界 → 窗口段优雅省略(仍可按上界判逾期)。
line = _line("F5", "OPEN", to=4, title="半窗")
out = render_foreshadow_line(line, 6)
assert "期望回收" not in out
assert "(已逾期!)" in out # 第 6 章 > 上界 4 仍算逾期
# --- 纯函数:按章自动挑选(确定性、无向量、无随机) ---
def test_select_auto_includes_in_window() -> None:
assert [line.code for line in select_auto_lines([_line("A", "OPEN", from_=5, to=8)], 6)] == [
"A"
]
def test_select_auto_includes_boundary_chapters() -> None:
lines = [_line("A", "OPEN", from_=5, to=8)]
assert select_auto_lines(lines, 5) # 下界
assert select_auto_lines(lines, 8) # 上界
def test_select_auto_includes_overdue() -> None:
# 第 10 章远超上界 3 → 逾期且未 CLOSED纳入。
assert [
line.code for line in select_auto_lines([_line("B", "PARTIAL", from_=1, to=3)], 10)
] == ["B"]
def test_select_auto_excludes_closed_in_window() -> None:
assert select_auto_lines([_line("C", "CLOSED", from_=5, to=8)], 6) == []
def test_select_auto_excludes_future_window_not_overdue() -> None:
assert select_auto_lines([_line("D", "OPEN", from_=20, to=30)], 6) == []
def test_select_auto_excludes_windowless_open() -> None:
# 无回收窗口、未逾期 → 不自动纳入(既非窗口内也非逾期)。
assert select_auto_lines([_line("E", "OPEN")], 6) == []
def test_render_unknown_status_defensively_not_overdue() -> None:
# 未知 status非四态枚举→ 保守视为未逾期,不加逾期标记、不崩。
line = _line("F6", "WEIRD", from_=1, to=2, title="怪态")
out = render_foreshadow_line(line, 99)
assert "(已逾期!)" not in out
assert out == "【伏笔】F6 怪态 [WEIRD] 期望回收 1-2章"
def test_merge_dedups_by_code_and_sorts() -> None:
outline = [_line("B", "OPEN", from_=1, to=2)]
auto = [_line("A", "OPEN", from_=5, to=6), _line("B", "OPEN", from_=1, to=2)]
merged = merge_foreshadow_lines(outline, auto)
assert [line.code for line in merged] == ["A", "B"] # 去重 + 按 code 升序
# --- assemble 集成:丰富注入 + 自动挑选 + volatile 边界 ---
async def test_assemble_enriches_outline_foreshadow_window() -> None:
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5, foreshadow_windows=[{"code": "F1"}])},
foreshadows=[
ForeshadowView(
code="F1",
title="神秘石符",
status="OPEN",
expected_close_from=5,
expected_close_to=8,
)
],
spec=ProjectSpecView(title=""),
)
ctx = await assemble(repos, PROJECT, 5)
assert "【伏笔】F1 神秘石符 [OPEN] 期望回收 5-8章" in ctx.volatile
# 不变量 #9伏笔注入含窗口只在 volatile绝不进缓存前缀。
assert "期望回收" not in ctx.stable_core
assert "F1" not in ctx.stable_core
async def test_assemble_auto_injects_in_window_beyond_outline() -> None:
# 大纲未登记 F9但账本里 F9 的回收窗口覆盖本章 → 自动纳入(修「大纲没登记=零注入」)。
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F9", "OPEN", from_=4, to=7, title="旧誓")])
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5)}, # 无 foreshadow_windows
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert "【伏笔】F9 旧誓 [OPEN] 期望回收 4-7章" in ctx.volatile
async def test_assemble_auto_injects_overdue_beyond_outline() -> None:
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F8", "PARTIAL", from_=1, to=3, title="欠债")])
repos = build_repos(
outline={10: OutlineView(volume=1, chapter_no=10)},
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 10)
assert "【伏笔】F8 欠债 [PARTIAL]" in ctx.volatile
assert "(已逾期!)" in ctx.volatile
async def test_assemble_auto_skips_closed_and_future() -> None:
ledger = FakeForeshadowLedgerRepo(
rows=[
_ledger("CLD", "CLOSED", from_=4, to=7, title="已收"),
_ledger("FUT", "OPEN", from_=20, to=30, title="未来"),
]
)
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5)},
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert "CLD" not in ctx.volatile
assert "FUT" not in ctx.volatile
async def test_assemble_merges_outline_and_auto_dedup() -> None:
# F1 同时被大纲窗口显式登记 + 账本回收窗口自动命中 → 合并去重,只出现一次。
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F1", "OPEN", from_=5, to=8, title="石符")])
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5, foreshadow_windows=[{"code": "F1"}])},
foreshadows=[
ForeshadowView(
code="F1",
title="石符",
status="OPEN",
expected_close_from=5,
expected_close_to=8,
)
],
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert ctx.volatile.count("【伏笔】F1") == 1
async def test_assemble_without_ledger_only_outline_foreshadows() -> None:
# 无账本注入(默认)→ 仅大纲登记的伏笔,优雅降级不崩。
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5, foreshadow_windows=[{"code": "F1"}])},
foreshadows=[ForeshadowView(code="F1", title="石符", status="OPEN")],
spec=ProjectSpecView(title=""),
)
ctx = await assemble(repos, PROJECT, 5)
assert "【伏笔】F1 石符 [OPEN]" in ctx.volatile
async def test_assemble_auto_foreshadow_never_enters_stable_core() -> None:
# 不变量 #9按章自动挑选的伏笔每章易变只入 volatile绝不进缓存前缀。
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F9", "OPEN", from_=4, to=7, title="旧誓")])
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5)},
spec=ProjectSpecView(title="", genre="玄幻"),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert "F9" not in ctx.stable_core
assert "旧誓" not in ctx.stable_core
# ---- helpers ---- # ---- helpers ----

View File

@@ -15,6 +15,7 @@ from typing import Any, Protocol
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo
from ww_core.domain.review_repo import ReviewRepo from ww_core.domain.review_repo import ReviewRepo
# ---- 只读视图snake_casefrozen 防止意外突变)---- # ---- 只读视图snake_casefrozen 防止意外突变)----
@@ -156,8 +157,11 @@ class ProjectSpecRepo(Protocol):
class MemoryRepos: class MemoryRepos:
"""记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。 """记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。
`review` 末位默认 None只有冲突裁决反哺灵感②需要它不注入时 assemble `review`/`foreshadow_ledger` 末位默认 None同 optional 注入模式):
优雅降级(不反哺),故既有构造点无需感知(守风险#6 - `review`:只有冲突裁决反哺(灵感②)需要它;
- `foreshadow_ledger`:只有按章自动挑选伏笔(本任务)需要它——账本全量读侧,
供 assemble 在大纲窗口之外自动纳入本章回收窗口内/逾期的伏笔(复用写侧账本 repo
不注入时 assemble 优雅降级(对应能力关闭),故既有构造点无需感知(守风险#6
""" """
outline: OutlineRepo outline: OutlineRepo
@@ -169,3 +173,4 @@ class MemoryRepos:
rules: RulesRepo rules: RulesRepo
project: ProjectSpecRepo project: ProjectSpecRepo
review: ReviewRepo | None = None review: ReviewRepo | None = None
foreshadow_ledger: ForeshadowLedgerRepo | None = None

View File

@@ -17,7 +17,6 @@ from ww_core.domain.injection_repo import InjectionOverride
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
ForeshadowView,
MemoryRepos, MemoryRepos,
OutlineView, OutlineView,
ProjectSpecView, ProjectSpecView,
@@ -27,6 +26,13 @@ from ww_core.domain.repositories import (
) )
from ww_core.domain.review_repo import ReviewView from ww_core.domain.review_repo import ReviewView
from .foreshadow_inject import (
ForeshadowLine,
merge_foreshadow_lines,
render_foreshadow_line,
select_auto_lines,
to_line,
)
from .render import render_cards from .render import render_cards
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities
from .types import AssembledContext, EntityKind from .types import AssembledContext, EntityKind
@@ -199,16 +205,14 @@ def _prior_conflict_notes(
def _build_volatile( def _build_volatile(
chapter_no: int, chapter_no: int,
cards: str, cards: str,
foreshadows: list[ForeshadowView], foreshadow_lines: list[ForeshadowLine],
digests: list[DigestView], digests: list[DigestView],
beats: dict[str, Any], beats: dict[str, Any],
directive: str | None = None, directive: str | None = None,
prior_conflict_notes: str | None = None, prior_conflict_notes: str | None = None,
) -> str: ) -> str:
fore_lines = [ # 伏笔行已在 assemble() 合并去重、按 code 排序;此处只逐行渲染(含窗口 + 逾期标记)。
f"【伏笔】{f.code} {f.title} [{f.status}]" fore_lines = [render_foreshadow_line(line, chapter_no) for line in foreshadow_lines]
for f in sorted(foreshadows, key=lambda f: f.code)
]
digest_lines = [ digest_lines = [
f"{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no) f"{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no)
] ]
@@ -271,8 +275,18 @@ async def assemble(
excluded=excluded, excluded=excluded,
) )
# 伏笔注入两路并集(守不变量 #6确定性纯函数选择无向量、无随机
# 1) 大纲手工登记的窗口 code——作者显式点名始终注入不论状态
codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")] codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")]
foreshadows = await repos.foreshadow.list_for_codes(project_id, codes) outline_fore = await repos.foreshadow.list_for_codes(project_id, codes)
outline_lines = [to_line(f) for f in outline_fore]
# 2) 按本章章号自动纳入——账本全量 → 纯函数筛「回收窗口内 / 逾期且未 CLOSED」
# 修「大纲没登记=零注入」。账本未注入(默认)则优雅降级为只用大纲路(守风险#6
auto_lines: list[ForeshadowLine] = []
if repos.foreshadow_ledger is not None:
all_fore = await repos.foreshadow_ledger.list_by_status(project_id)
auto_lines = select_auto_lines([to_line(f) for f in all_fore], chapter_no)
foreshadow_lines = merge_foreshadow_lines(outline_lines, auto_lines)
style = await repos.style.latest(project_id) style = await repos.style.latest(project_id)
rules = await repos.rules.all_for_project(project_id) rules = await repos.rules.all_for_project(project_id)
spec = await repos.project.spec(project_id) spec = await repos.project.spec(project_id)
@@ -292,7 +306,7 @@ async def assemble(
volatile = _build_volatile( volatile = _build_volatile(
chapter_no, chapter_no,
cards, cards,
foreshadows, foreshadow_lines,
recent_digests, recent_digests,
outline.beats, outline.beats,
directive, directive,

View File

@@ -0,0 +1,121 @@
"""伏笔注入的确定性纯函数——选择 + 渲染ARCH §5.3 / 不变量 #6、#9
写章时把「相关伏笔」注入 volatile缓存断点之后守不变量 #9。相关性来自两路并集
1. 大纲手工登记的 `outline.foreshadow_windows`(作者显式点名)——始终注入,不论状态;
2. 按本章章号自动纳入本章处于回收窗口内from<=ch<=to或已逾期且未 CLOSED。
两路都是**确定性纯函数**选择:无向量、无随机(不变量 #6渲染字符串无时间戳/UUID
故安全留在 volatile。逾期判据复用 `domain.foreshadow_state.is_overdue`(单一真源)。
"""
from __future__ import annotations
from dataclasses import dataclass
from ww_core.domain.foreshadow_repo import ForeshadowLedgerView
from ww_core.domain.foreshadow_state import (
CLOSED,
OVERDUE,
ForeshadowStatus,
is_overdue,
)
from ww_core.domain.repositories import ForeshadowView
@dataclass(frozen=True)
class ForeshadowLine:
"""渲染/选择用的中性伏笔行——从读侧 `ForeshadowView` 与账本 `ForeshadowLedgerView`
收敛出的共同最小字段,避免两路视图类型在选择/渲染层扩散(不可变,确定性)。"""
code: str
title: str
status: str
expected_close_from: int | None
expected_close_to: int | None
def to_line(view: ForeshadowView | ForeshadowLedgerView) -> ForeshadowLine:
"""把任一伏笔视图收敛成中性 `ForeshadowLine`(两路视图字段同名,纯映射)。"""
return ForeshadowLine(
code=view.code,
title=view.title,
status=view.status,
expected_close_from=view.expected_close_from,
expected_close_to=view.expected_close_to,
)
def _has_window(line: ForeshadowLine) -> bool:
return line.expected_close_from is not None and line.expected_close_to is not None
def _in_window(line: ForeshadowLine, chapter_no: int) -> bool:
"""本章处于回收窗口内from<=ch<=to含边界——需上下界俱在否则视为无窗口。"""
if not _has_window(line):
return False
# _has_window 已保证两界非空,此处类型收窄由显式断言给出。
assert line.expected_close_from is not None
assert line.expected_close_to is not None
return line.expected_close_from <= chapter_no <= line.expected_close_to
def _is_overdue_line(line: ForeshadowLine, chapter_no: int) -> bool:
"""逾期判据:`status==OVERDUE`(账本扫描已置位)或越过期望回收上界且未 CLOSED。
后者复用 `foreshadow_state.is_overdue`(单一真源);未知 status 保守视为未逾期。
"""
if line.status == OVERDUE.value:
return True
try:
status = ForeshadowStatus(line.status)
except ValueError:
return False
return is_overdue(
current_chapter=chapter_no,
expected_close_to=line.expected_close_to,
status=status,
)
def _is_relevant_this_chapter(line: ForeshadowLine, chapter_no: int) -> bool:
"""按章自动纳入判据:未 CLOSED 且(本章在回收窗口内 或 已逾期)。"""
if line.status == CLOSED.value:
return False
return _in_window(line, chapter_no) or _is_overdue_line(line, chapter_no)
def select_auto_lines(all_lines: list[ForeshadowLine], chapter_no: int) -> list[ForeshadowLine]:
"""从全量伏笔里按当前章号自动挑选相关项(确定性纯函数,守不变量 #6"""
return [line for line in all_lines if _is_relevant_this_chapter(line, chapter_no)]
def merge_foreshadow_lines(*groups: list[ForeshadowLine]) -> list[ForeshadowLine]:
"""按 code 合并去重多路来源、按 code 升序返回(确定性;先到者优先,同 code 同数据)。"""
by_code: dict[str, ForeshadowLine] = {}
for group in groups:
for line in group:
by_code.setdefault(line.code, line)
return [by_code[code] for code in sorted(by_code)]
def _render_window(line: ForeshadowLine) -> str:
"""渲染「期望回收 {from}-{to}章」;窗口缺失(任一界为空)则优雅省略该段。"""
if not _has_window(line):
return ""
return f"期望回收 {line.expected_close_from}-{line.expected_close_to}"
def render_foreshadow_line(line: ForeshadowLine, chapter_no: int) -> str:
"""渲染一行注入伏笔:编码/标题/状态 + 期望回收窗口 +(逾期时)逾期标记。
例:`【伏笔】F1 神秘石符 [OPEN] 期望回收 5-8章已逾期`。
窗口缺失省略「期望回收…」段;未逾期不加标记。全程无时间戳/UUID安全入 volatile。
"""
text = f"【伏笔】{line.code} {line.title} [{line.status}]"
window = _render_window(line)
if window:
text = f"{text} {window}"
if _is_overdue_line(line, chapter_no):
text = f"{text}(已逾期!)"
return text

View File

@@ -21,6 +21,7 @@ from ww_db.models import (
WorldEntity, WorldEntity,
) )
from ww_core.domain.foreshadow_repo import SqlForeshadowLedgerRepo
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
@@ -228,6 +229,8 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
"""用一个 AsyncSession 装配全部 9 个 SQLAlchemy repoT1.4 注入点)。 """用一个 AsyncSession 装配全部 9 个 SQLAlchemy repoT1.4 注入点)。
`review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。 `review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。
`foreshadow_ledger=SqlForeshadowLedgerRepo`:供 assemble 按本章章号自动挑选伏笔
(回收窗口内/逾期,复用账本读侧 `list_by_status`)——大纲未登记也能自动注入。
""" """
return MemoryRepos( return MemoryRepos(
outline=SqlOutlineRepo(session), outline=SqlOutlineRepo(session),
@@ -239,4 +242,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
rules=SqlRulesRepo(session), rules=SqlRulesRepo(session),
project=SqlProjectSpecRepo(session), project=SqlProjectSpecRepo(session),
review=SqlReviewRepo(session), review=SqlReviewRepo(session),
foreshadow_ledger=SqlForeshadowLedgerRepo(session),
) )