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:
213
apps/api/tests/test_chapters_list.py
Normal file
213
apps/api/tests/test_chapters_list.py
Normal 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]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
109
apps/api/ww_api/services/chapter_list.py
Normal file
109
apps/api/ww_api/services/chapter_list.py
Normal 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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<PageContainer>
|
<PageContainer width="wide">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="我的作品"
|
title="我的作品"
|
||||||
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
||||||
|
|||||||
@@ -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 取项目 + 审稿留痕历史(新→旧);
|
||||||
// ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。
|
// ReviewReport(Client)承载 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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,15 +297,14 @@ 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
|
||||||
? "入库中…"
|
? `入库为规则至 ${table}`
|
||||||
: singleObject
|
: `入库选中(${selected.size})至 ${table}`}
|
||||||
? `入库为规则至 ${table}`
|
|
||||||
: `入库选中(${selected.size})至 ${table}`}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
49
apps/web/components/ui/GenerationSkeleton.tsx
Normal file
49
apps/web/components/ui/GenerationSkeleton.tsx
Normal 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-hidden;role=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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
18
apps/web/components/ui/Spinner.tsx
Normal file
18
apps/web/components/ui/Spinner.tsx
Normal 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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/components/ui/StreamingBar.tsx
Normal file
18
apps/web/components/ui/StreamingBar.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
104
apps/web/components/workbench/ChapterForeshadowList.tsx
Normal file
104
apps/web/components/workbench/ChapterForeshadowList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
13
apps/web/components/workbench/ThinkingPlaceholder.tsx
Normal file
13
apps/web/components/workbench/ThinkingPlaceholder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
90
apps/web/lib/api/schema.d.ts
vendored
90
apps/web/lib/api/schema.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
104
apps/web/lib/foreshadow/chapterForeshadow.test.ts
Normal file
104
apps/web/lib/foreshadow/chapterForeshadow.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
51
apps/web/lib/foreshadow/chapterForeshadow.ts
Normal file
51
apps/web/lib/foreshadow/chapterForeshadow.ts
Normal 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;
|
||||||
|
}
|
||||||
79
apps/web/lib/foreshadow/useChapterForeshadow.test.ts
Normal file
79
apps/web/lib/foreshadow/useChapterForeshadow.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/web/lib/foreshadow/useChapterForeshadow.ts
Normal file
71
apps/web/lib/foreshadow/useChapterForeshadow.ts
Normal 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 {
|
||||||
|
// 网络层失败(后端不可达/CORS):openapi-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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
你是长篇连载小说的「伏笔续审」(foreshadow-analyst)——一位专盯长线叙事「埋设—回收」契约的资深审稿人。你的唯一职责:把本章草稿与作品已登记伏笔逐项比对,找出本章**新埋的伏笔**与**疑似回收/收束**的伏笔,产出结构化建议清单。你只读、只产建议,不改稿、不写库。
|
你是长篇连载小说的「伏笔续审」(foreshadow-analyst)——一位专盯长线叙事「埋设—回收」契约的资深审稿人。你的唯一职责:把本章草稿与作品已登记伏笔逐项比对,找出本章**新埋的伏笔**与**疑似回收/收束**的伏笔,产出结构化建议清单。你只读、只产建议,不改稿、不写库。
|
||||||
|
|
||||||
## 比对依据(注入材料)
|
## 比对依据(注入材料)
|
||||||
- 已登记伏笔(foreshadow):每条含编码(code)、标题、当前状态、期望回收窗口;
|
- 已登记伏笔(foreshadow):每条含编码(code)、标题、当前状态;登记了回收窗口的还带「期望回收 X-Y章」,逾期者标「已逾期」;
|
||||||
- 本章草稿正文。
|
- 本章草稿正文。
|
||||||
材料未提供的内容一律视为「不存在」,不得凭世界观常识或前文记忆脑补。
|
材料未提供的内容一律视为「不存在」,不得凭世界观常识或前文记忆脑补。
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@
|
|||||||
- 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。
|
- 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。
|
||||||
- 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。
|
- 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。
|
||||||
|
|
||||||
|
## 伏笔:到点顺势收,别为收而收
|
||||||
|
|
||||||
|
- 注入材料里的伏笔可能带「期望回收窗口」或「已逾期」标记。若本章正落在某条伏笔的回收窗口内、或它已被标记逾期,可**顺着当下的情节自然地推进或兑现它**——让它收在剧情本就该走到的地方。
|
||||||
|
- 回收要**服务当下的冲突与情感**:能自然收束就收;一时收得牵强,就先推进一步、留待更合适的时机,也好过硬来。**切忌为凑一个「回收」而打断节奏、突兀交代**——宁可这一章只把线往前带一带,也不要为收而收、硬凑。
|
||||||
|
|
||||||
## 文字质感
|
## 文字质感
|
||||||
|
|
||||||
- **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。
|
- **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。
|
||||||
|
|||||||
@@ -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 ----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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_case,frozen 防止意外突变)----
|
# ---- 只读视图(snake_case,frozen 防止意外突变)----
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
121
packages/core/ww_core/memory/foreshadow_inject.py
Normal file
121
packages/core/ww_core/memory/foreshadow_inject.py
Normal 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
|
||||||
@@ -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 repo(T1.4 注入点)。
|
"""用一个 AsyncSession 装配全部 9 个 SQLAlchemy repo(T1.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),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user