Compare commits
158 Commits
9d94957a9b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64732b0d1b | ||
|
|
1afeb2cdb5 | ||
|
|
9bc0e1f2e6 | ||
|
|
40cc3fc9e3 | ||
|
|
b46afd4f8c | ||
|
|
b810a3fa3c | ||
|
|
9aa0cddeaf | ||
|
|
5f28491ad6 | ||
|
|
4a334f79cc | ||
|
|
54d3a532af | ||
|
|
ac40f48e05 | ||
|
|
f4bea7f26d | ||
|
|
355a2d11cd | ||
|
|
45025c36bf | ||
|
|
5674158707 | ||
|
|
67e30a6863 | ||
|
|
d3e9ae972f | ||
|
|
83ba47ab8f | ||
|
|
fd6551b0ba | ||
|
|
b9f9134748 | ||
|
|
57b3183564 | ||
|
|
dbece64d4d | ||
|
|
3c87b01379 | ||
|
|
e9c5502c2d | ||
|
|
9f6cbb5598 | ||
|
|
59d7c75737 | ||
|
|
ceae24740a | ||
|
|
61a6931bb1 | ||
|
|
5317d601dd | ||
|
|
222cc48f5a | ||
|
|
1185069572 | ||
|
|
5af45f5034 | ||
|
|
f6e88d7438 | ||
|
|
5ba3eabda3 | ||
|
|
33a9b7656c | ||
|
|
59f2f60a7e | ||
|
|
ddf7fa061a | ||
|
|
dce882ca91 | ||
|
|
7c4b3c95be | ||
|
|
c675a74f1d | ||
|
|
1a188e6e5b | ||
|
|
9da938d39b | ||
|
|
31067abbed | ||
|
|
c34dbf5d2d | ||
|
|
2145d33cc7 | ||
|
|
a039a1e5d9 | ||
|
|
05f49d0b7f | ||
|
|
64145422eb | ||
|
|
16f2eae2d3 | ||
|
|
9cf4915d49 | ||
|
|
d80d41a08d | ||
|
|
125908ed83 | ||
|
|
47d5558e4a | ||
|
|
1bda024141 | ||
|
|
f160a7dac1 | ||
|
|
6a739cad5e | ||
|
|
ec91c8fdf7 | ||
|
|
c9ffada503 | ||
|
|
832ab7dfa1 | ||
|
|
9439b97804 | ||
|
|
32451a9bd0 | ||
|
|
9ee7c7b000 | ||
|
|
49e3cb6dc4 | ||
|
|
bf3dabea82 | ||
|
|
0892ff6cda | ||
|
|
1392830e57 | ||
|
|
c596a8d342 | ||
|
|
c22f6b41d1 | ||
|
|
a703a90c0c | ||
|
|
ea9a9d9953 | ||
|
|
8e29b33a11 | ||
|
|
4b1088a179 | ||
|
|
9b32743606 | ||
|
|
6b7391d1b7 | ||
|
|
6fd637596b | ||
|
|
4a09f273d9 | ||
|
|
331a07abc4 | ||
|
|
dd6d2fce2b | ||
|
|
e08b01ac58 | ||
|
|
d6b53d08b2 | ||
|
|
6e2b478ca9 | ||
|
|
5c02792e5f | ||
|
|
5665cc9a16 | ||
|
|
f4c2c4e8ec | ||
|
|
baa41d370b | ||
|
|
e1a24a2c2c | ||
|
|
26f10d023d | ||
|
|
25d48b7aa7 | ||
|
|
5dd336b011 | ||
|
|
ca8fbc89a1 | ||
|
|
ed0a679322 | ||
|
|
8a2b1b09dc | ||
|
|
51fe9f44fe | ||
|
|
afeac51422 | ||
|
|
cb8fddfc3b | ||
|
|
c69e613c97 | ||
|
|
e30c933c76 | ||
|
|
1652ad9d20 | ||
|
|
fb5caa3d89 | ||
|
|
542c2abac9 | ||
|
|
6ed9c89430 | ||
|
|
4977c760d8 | ||
|
|
3dd7d2861f | ||
|
|
351fbc99e8 | ||
|
|
44b0b8a7f0 | ||
|
|
04f4785292 | ||
|
|
9195cf36f3 | ||
|
|
2f0b84191a | ||
|
|
8389572cbb | ||
|
|
e703d0a65c | ||
|
|
72d7111581 | ||
|
|
03895cc0d4 | ||
|
|
5464539a91 | ||
|
|
6b08fbe123 | ||
|
|
51bfb6e320 | ||
|
|
3d2a8b8cee | ||
|
|
7babb4854b | ||
|
|
72b3c81146 | ||
|
|
2a7a865e98 | ||
|
|
02d19019f6 | ||
|
|
821ace5989 | ||
|
|
491d9342ef | ||
|
|
acde715515 | ||
|
|
452bd56d95 | ||
|
|
6854dac98f | ||
|
|
164e98887e | ||
|
|
6bb3559e25 | ||
|
|
9d67f237a2 | ||
|
|
68606b84dc | ||
|
|
f0f925b195 | ||
|
|
69030eacc3 | ||
|
|
e82aff7067 | ||
|
|
a22a16c9c4 | ||
|
|
69f3c2a29b | ||
|
|
d7fcb8a329 | ||
|
|
47eb42f274 | ||
|
|
f8cabc92e6 | ||
|
|
ffe9132f7d | ||
|
|
4afbbe0fe7 | ||
|
|
ab3f9e17b1 | ||
|
|
a2849bf6b7 | ||
|
|
dd80b6815d | ||
|
|
90a66437d7 | ||
|
|
3bd464d400 | ||
|
|
a6f5d085e5 | ||
|
|
a02c6b6e4f | ||
|
|
e9477d84d8 | ||
|
|
01257b8952 | ||
|
|
bf39f50b2f | ||
|
|
e60eff7aa1 | ||
|
|
9a623688e8 | ||
|
|
f4f01aeca9 | ||
|
|
3868f80502 | ||
|
|
7a40c7fbb5 | ||
|
|
2fe3bedfba | ||
|
|
a4ef250fc9 | ||
|
|
d0c301349c | ||
|
|
b5002a9864 |
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
# api 镜像构建上下文(context=仓库根)。api Dockerfile 仅选择性 COPY
|
||||
# pyproject.toml/uv.lock/packages/apps/api/alembic.ini,但整个仓库会作为构建上下文发给
|
||||
# daemon——排除大体积/无关目录,加速上传并避免宿主产物泄漏进镜像。
|
||||
.git
|
||||
.venv
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
htmlcov
|
||||
.coverage
|
||||
**/*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
example_skills
|
||||
@@ -6,6 +6,7 @@ DATABASE_URL_SYNC=postgresql+psycopg://writer:writer@localhost:5432/writer
|
||||
APP_ENV=dev
|
||||
LOG_JSON=false
|
||||
# 凭据加密密钥(Fernet, urlsafe-base64 32B)— **必填**:API 启动即校验,缺失/非法则拒绝启动。
|
||||
# 下方为开发占位 key(可直接用于本地);生产请换新值:
|
||||
# ⚠️ 下方为**不可用占位符**,必须替换:`cp .env.example .env` 后用下方命令生成真实 key 填入。
|
||||
# 切勿把真实 key 提交进版本库;任何曾经泄露过的 key 必须立即轮换(重新加密已存凭据)。
|
||||
# uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
CREDENTIAL_ENC_KEY=cnMpG6QQxJejuDLHTe_S-nq2snoKgXCqWFfsctEHB-4=
|
||||
CREDENTIAL_ENC_KEY=replace-me-with-generated-fernet-key
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -34,8 +34,8 @@ jobs:
|
||||
run: |
|
||||
uv run alembic upgrade head
|
||||
uv run alembic check
|
||||
- name: pytest
|
||||
run: uv run pytest -q
|
||||
- name: pytest + coverage gate (>=80%)
|
||||
run: uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80
|
||||
|
||||
# 打包冒烟:构建 ww-agents wheel → 装进裸 venv → import ww_agents 并断言 SPECS。
|
||||
# 证明 prompts/*.md 随 wheel 分发(源码树 pytest 测不出此漏带——见
|
||||
@@ -85,6 +85,6 @@ jobs:
|
||||
- name: typecheck
|
||||
working-directory: apps/web
|
||||
run: pnpm typecheck
|
||||
- name: test
|
||||
- name: test + coverage gate (>=80%)
|
||||
working-directory: apps/web
|
||||
run: pnpm test
|
||||
run: pnpm test:coverage
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,3 +19,5 @@ apps/web/lib/api/openapi.json
|
||||
# os
|
||||
.DS_Store
|
||||
.gstack/
|
||||
# coverage artifact(不入库;覆盖率跑生成,误提交由 CR 批 c596a8d 撤销)
|
||||
.coverage
|
||||
|
||||
@@ -481,7 +481,6 @@ class LlmRequest(BaseModel):
|
||||
input: str | list[Block] # 易变内容(断点之后)
|
||||
stream: bool = False
|
||||
output_schema: type[BaseModel] | None = None # 需结构化输出时(Pydantic 模型/instructor)
|
||||
thinking: bool = False # 是否启用思考/推理(有则启用)
|
||||
max_tokens: int | None = None
|
||||
scope: "Scope" # {project_id; user_id 原型可固定}
|
||||
|
||||
|
||||
38
CLAUDE.md
38
CLAUDE.md
@@ -89,16 +89,48 @@ Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed.
|
||||
- **Errors** logged with full context at the boundary, mapped to the error envelope (`ARCHITECTURE §7.1` is the source of truth for envelope shape + error codes). Frontend logs client errors with the same `request_id`.
|
||||
- **Redact**: never log API keys; truncate/hash full prompts and manuscript text (log lengths/hashes, not the novel).
|
||||
|
||||
### Definition of Done (每次编码完成的硬门禁) — 不通过不算完成
|
||||
|
||||
每一次代码改动(无论多小)在声明「完成」前,**必须**按顺序全部通过以下门禁。任一项失败就回去修,不得跳过、不得只跑部分、不得仅靠肉眼判断。
|
||||
|
||||
1. **TDD 已遵守** — 先写失败测试(RED)→ 实现到通过(GREEN)→ 重构(REFACTOR)。新增/修改的逻辑都有对应测试;改 bug 先写能复现的失败测试。见上文 §TDD。
|
||||
2. **测试覆盖率 ≥ 80%(已工具化 + CI 强制)** — 改动涉及的模块覆盖率不得低于 80%;新代码不许拉低整体覆盖率。跑命令核对真实数字,不要估:
|
||||
- 后端:`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`(低于 80% 直接非零退出。当前基线 ~88%)。配置在根 `pyproject.toml` 的 `[tool.coverage.*]`。
|
||||
- 前端:`cd apps/web && pnpm test:coverage`(`vitest.config.ts` 设 lines/functions/branches/statements 阈值 80。当前基线 ~95%)。
|
||||
- **前端覆盖率范围 = `lib/**`(纯逻辑 + React hooks)**:hooks(`use*.ts`)用 jsdom + `@testing-library/react` 的 `renderHook` 单测(测试文件首行 `// @vitest-environment jsdom`,mock `@/lib/api/client` + `@/components/Toast`,参见 `lib/generation/useWorldGen.test.ts`)。组件(.tsx)仍由 E2E/人工验收覆盖,不在 vitest 范围(写新 hook 时必须同步补 `renderHook` 测试)。
|
||||
3. **跑完三类自动化测试**(改动触及的那侧必跑,跨栈改动两侧都跑):
|
||||
- **后端 Unit + Integration**:`uv run pytest -q`(需要 pg 的集成测试先 `docker compose up -d pg`)。
|
||||
- **前端自动化测试**:`cd apps/web && pnpm test`(vitest)。
|
||||
- **E2E**(动到 write→review→accept 主链路时):跑相关 E2E 流程(mock gateway)。
|
||||
- LLM 一律 mock,**绝不在测试/CI 命中真实 LLAPI**(见上文 §TDD)。
|
||||
4. **全部门禁绿**(与 §Toolchain、CI 一致):
|
||||
- 后端:`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q` · 动过模型时 `uv run alembic check` 无漂移。
|
||||
- 前端:`cd apps/web && pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build`;动过后端 schema 先 `pnpm gen:api` 再校验。
|
||||
5. **符合架构与编程规范** — 不违反上文 §Architectural invariants、§Locked tech stack、§Python/§Frontend/§LangGraph 实践,以及全局编码风格(不可变更新、KISS/DRY/YAGNI、文件 <800 行、函数 <50 行、显式错误处理、边界校验、无硬编码密钥)。安全敏感改动(auth/输入处理/DB 查询/外部 API/加密)按 §code-review 触发安全自查。
|
||||
6. **提交(每次完成都 commit)** — 见下文 §Commit discipline。
|
||||
7. **回写状态/文档** — 按 §Per-task workflow 更新 `PROGRESS.md`;若实现暴露 spec 缺口,回写对应 spec(见 §Conventions)。
|
||||
|
||||
> 报告要诚实:测试失败就如实说明并贴输出;跳过了哪一步要讲明;只有真正跑过且全绿,才说「完成」。
|
||||
|
||||
### Commit discipline (提交纪律) — 每次完成即提交
|
||||
|
||||
- **每次完成一个可工作的改动就 commit**,小步提交、一次一个聚焦改动;不要把多个无关改动堆进一个提交。
|
||||
- **提交前** Definition of Done 的门禁(1–5)必须已全绿——不提交跑不过测试或 lint/类型不干净的代码。
|
||||
- **提交信息格式**:遵循 Conventional Commits —— `<type>: <description>`(type ∈ feat/fix/refactor/docs/test/chore/perf/ci),中文描述与本仓库现有提交风格一致;需要时附 body 说明「为什么」。
|
||||
- **提交信息禁止任何署名/归属尾注** — **不得包含 `Co-Authored-By:` 或任何 `Generated with` / AI 署名行**(全局已在 `~/.claude/settings.json` 关闭 attribution;此处再次明确,覆盖任何默认加尾注的行为)。
|
||||
- **分支**:不在 `main` 上直接提交;按 §Git Workflow 在特性分支工作。`develop` 为当前集成分支。
|
||||
- **push / 开 PR 仅在用户明确要求时**进行;PR body 同样不加 AI 署名。
|
||||
|
||||
## Toolchain (Phase 0 已落地)
|
||||
|
||||
工具:**uv**(Python workspace,4 members: `apps/api` + `packages/{shared,config,db}`) + **pnpm**(前端,`apps/web`)。Python 3.12+,Node 22。
|
||||
|
||||
- **环境变量**:`uv` 装到 `~/.local/bin`,命令前 `export PATH="$HOME/.local/bin:$PATH"`。
|
||||
- **依赖安装**:`uv sync`(后端,仓库根);`pnpm install`(前端,`cd apps/web`)。
|
||||
- **本地起服务**:`docker compose up`(pg + api + web)。仅起库:`docker compose up -d pg`。裸跑 API:`uv run uvicorn ww_api.main:app --reload`。
|
||||
- **本地起服务(宿主端口 +1000 防冲突:web 4000 / api 9000 / pg 6432;容器内仍 3000/8000/5432)**:`docker compose up`(pg + api + web,端口已 +1000)。仅起库:`docker compose up -d pg`(宿主 6432)。裸跑:API `uv run uvicorn ww_api.main:app --port 9000 --reload`(读根 `.env`→pg 6432 / CORS 放行 4000);前端 `cd apps/web && pnpm dev`(`next dev -p 4000`,读 `apps/web/.env.local`→`NEXT_PUBLIC_API_BASE=http://localhost:9000`)。`.env`/`.env.local` 为本机文件(gitignored)。
|
||||
- **迁移**:`uv run alembic upgrade head`;改模型后 `uv run alembic revision --autogenerate -m "..."`;漂移校验 `uv run alembic check`(需 pg 在跑)。
|
||||
- **后端门禁**:`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。单测:`uv run pytest path::test -q`。
|
||||
- **前端门禁**(`cd apps/web`):`pnpm lint` · `pnpm typecheck` · `pnpm test`(vitest)· `pnpm build`。
|
||||
- **后端门禁**:`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。覆盖率门禁:`uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80`。单测:`uv run pytest path::test -q`(单测不带 `--cov`,避免 fail-under 误报)。
|
||||
- **前端门禁**(`cd apps/web`):`pnpm lint` · `pnpm typecheck` · `pnpm test`(vitest)· `pnpm build`。覆盖率门禁:`pnpm test:coverage`(阈值 80%,范围 `lib/**`)。
|
||||
- **重生成 TS 客户端**:改后端 schema 后 `cd apps/web && pnpm gen:api`(离线:`uv run python -m ww_api.export_openapi` → `openapi-typescript` 生成 `lib/api/schema.d.ts`)。
|
||||
- **pnpm 配置**:在 `apps/web/pnpm-workspace.yaml`(pnpm 11 不再读 package.json/.npmrc)——含 `onlyBuiltDependencies` 白名单 + `verifyDepsBeforeRun: false`(见 `memory/gotchas.md`)。
|
||||
- **CI**:`.github/workflows/ci.yml`(backend job 带 pg service 跑 ruff/mypy/alembic/pytest;frontend job 跑 gen:api/lint/typecheck/test)。
|
||||
|
||||
457
DEPLOYMENT.md
Normal file
457
DEPLOYMENT.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# 部署指南(DEPLOYMENT)
|
||||
|
||||
> 本文是把「AI 辅助中文网文写作工作流」部署到服务器的实操指南。
|
||||
> 所有命令/端口/服务名均以仓库当前 `docker-compose.yml`、两份 `Dockerfile`、
|
||||
> `.env.example`、`packages/config/ww_config/settings.py`、`apps/api/ww_api/main.py`
|
||||
> 为准。**遇到与你手上 compose 不一致处,一律以实际 `docker-compose.yml` 为准。**
|
||||
>
|
||||
> ⚠️ **重要前提:本产品当前是「单用户原型」,没有任何认证 / 多租户**(`users`/`owner_id`
|
||||
> 是桩)。这直接决定了安全部署的红线——见 [§6 安全红线](#6-安全红线单用户原型的硬约束)。
|
||||
> **不要在没有网络层保护的情况下把它裸暴露到公网。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构与前置条件
|
||||
|
||||
### 1.1 三个组件
|
||||
|
||||
部署单元由三个容器组成(服务名即 `docker-compose.yml` 中的 key):
|
||||
|
||||
| 服务名 | 角色 | 镜像来源 | 容器端口 | compose 发布端口 | 健康检查 |
|
||||
|---|---|---|---|---|---|
|
||||
| `pg` | Postgres 16 数据库(唯一真源) | `postgres:16-alpine`(拉取) | 5432 | `5432:5432` | ✅ `pg_isready -U writer`(interval 5s / timeout 3s / retries 10) |
|
||||
| `api` | FastAPI 后端(async + SSE + LangGraph 编排) | 由 `apps/api/Dockerfile` 构建,构建上下文 = **仓库根 `.`** | 8000 | `8000:8000` | ✅ Dockerfile `HEALTHCHECK`:`python urllib` 打 `/health`(interval 30s / timeout 5s / start-period 20s / retries 3) |
|
||||
| `web` | Next.js 前端(standalone 产物) | 由 `apps/web/Dockerfile` 构建,构建上下文 = **`./apps/web`** | 3000 | `3000:3000` | ✅ Dockerfile `HEALTHCHECK`:`node http` 打 `/`(interval 30s / timeout 5s / start-period 20s / retries 3) |
|
||||
|
||||
依赖关系(compose `depends_on`):
|
||||
- `api` 依赖 `pg`,且**等 pg 健康**后才起(`condition: service_healthy`)。
|
||||
- `web` 依赖 `api`,但**只是启动顺序**(无 `condition`)——web 可能先于 api 就绪,属可容忍的运行期竞态。
|
||||
|
||||
数据持久化:命名卷 `pgdata` 挂到 `pg` 的 `/var/lib/postgresql/data`。**备份/迁移数据 = 备份这个卷**(见 §7)。
|
||||
|
||||
### 1.2 镜像怎么来
|
||||
|
||||
两个应用镜像都从源码 `Dockerfile` **本地构建**(仓库未发布预构建镜像):
|
||||
|
||||
- **`apps/api/Dockerfile`**:`python:3.12-slim` + 从 `ghcr.io/astral-sh/uv` 拷入 `uv`;`COPY pyproject.toml uv.lock* / packages / apps/api`;`uv sync --no-dev --frozen`(失败回退非 frozen);`CMD uv run uvicorn ww_api.main:app --host 0.0.0.0 --port 8000`。
|
||||
- **`apps/web/Dockerfile`**:多阶段。build 阶段 `node:22-slim` + corepack(pnpm),`pnpm install --frozen-lockfile` → `pnpm build`(Next `output: "standalone"`,见 `apps/web/next.config.mjs`);run 阶段只拷 `.next/standalone` + `.next/static` + `public`,`CMD node server.js`。
|
||||
|
||||
> web 镜像在构建期消费**已提交的** OpenAPI TS 客户端 `apps/web/lib/api/schema.d.ts`(该文件在 git 里,仅 `openapi.json` 被 gitignore)。因此 web 构建**不需要**联网跑 `pnpm gen:api`。**但**:后端 schema 改动后,必须先 `cd apps/web && pnpm gen:api` 重生成并提交 `schema.d.ts`,再构建 web 镜像,否则前后端契约漂移。
|
||||
|
||||
### 1.3 需要的环境变量(逐项)
|
||||
|
||||
变量来源 = `packages/config/ww_config/settings.py`(后端)+ `apps/web/lib/api/config.ts`(前端)。
|
||||
|
||||
**后端(api 容器):**
|
||||
|
||||
| 变量 | 是否必填 | 默认值 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `DATABASE_URL` | 建议显式设 | `postgresql+asyncpg://writer:writer@localhost:5432/writer` | **异步**连接串(asyncpg)。运行时 + `alembic upgrade head` 都读它(`packages/db/migrations/env.py` 用它建 async engine 跑迁移)。容器内主机名用 compose 服务名 `pg`。 |
|
||||
| `DATABASE_URL_SYNC` | 建议显式设 | `postgresql+psycopg://writer:writer@localhost:5432/writer` | **同步**连接串(psycopg)。`.env.example` 与 compose 均定义;供同步工具链使用。 |
|
||||
| `APP_ENV` | 否 | `dev` | 环境名。生产设 `prod`。 |
|
||||
| `LOG_JSON` | 否 | `false` | 结构化日志是否输出 JSON。**生产设 `true`**(structlog JSON,便于采集)。 |
|
||||
| `CREDENTIAL_ENC_KEY` | **✅ 必填** | 空(`SecretStr("")`) | provider 凭据对称加密密钥(Fernet, urlsafe-base64 32B)。**api 启动即校验**(`main._lifespan` 调 `_fernet(...)`):缺失/非法 → **进程直接启动失败**,不带病接流量。生产**必须**用新密钥,见 §2.3。 |
|
||||
| `CORS_ORIGINS` | 生产强烈建议 | `["http://localhost:3000","http://127.0.0.1:3000"]` | 浏览器跨源白名单,逗号分隔覆盖。**启动断言不含通配 `*`**(与 `allow_credentials=True` 不兼容,`main.create_app` 里 `assert "*" not in cors_origins`)。生产填前端真实来源,如 `https://writer.example.com`。 |
|
||||
|
||||
**前端(web 容器):**
|
||||
|
||||
| 变量 | 是否必填 | 默认值(`config.ts` 回退) | 说明 |
|
||||
|---|---|---|---|
|
||||
| `NEXT_PUBLIC_API_BASE` | 生产建议 | `http://localhost:8000` | **浏览器端**访问 API 的基址。⚠️ Next.js 的 `NEXT_PUBLIC_*` 在**构建期内联**进客户端 bundle——只在运行期设它**不会改**已构建产物。web `Dockerfile` 现声明 `ARG NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8000`),base/prod compose 的 `web.build.args` 均传入它;生产在**构建期**用 `docker compose build` 注入前端可达的公网 API 地址(通常反代后的 `https://writer.example.com` 之类)。 |
|
||||
| `API_BASE_INTERNAL` | 容器编排建议 | 回退到 `NEXT_PUBLIC_API_BASE` | **服务端组件(RSC)**取数用的内网基址。base/prod compose 的 `web.environment` 已设为 `http://api:8000`(compose 服务名),RSC 直连 api 容器。 |
|
||||
|
||||
### 1.4 LLM provider key 不进环境变量
|
||||
|
||||
**不要**把任何 LLM 供应商密钥(DeepSeek/Kimi/OpenAI/Anthropic/Qwen/GLM/Gemini 等)写进 env 或 compose。
|
||||
本产品的 provider 凭据是**部署后在应用「设置」页录入**,由后端用 `CREDENTIAL_ENC_KEY`(Fernet)
|
||||
**加密后存进 Postgres**(`provider_credentials` 表 / `settings_providers` 路由)。
|
||||
Kimi Code 走 OAuth 订阅授权(`kimi_oauth` 路由),同样落库。
|
||||
|
||||
推论:`CREDENTIAL_ENC_KEY` 是解开所有已存凭据的唯一钥匙——**丢了它 = 库里所有 provider 凭据不可解密**,只能重新录入。务必备份保管(见 §2.3 / §6)。
|
||||
|
||||
### 1.5 部署面缺口——已修复(现可照抄部署)
|
||||
|
||||
此前审阅 Dockerfile/compose 发现的 6 个「照抄即部署」缺口**均已在部署面文件消除**(不改应用源码)。现状如下:
|
||||
|
||||
1. **api 镜像已拷 `alembic.ini`** ✅:`apps/api/Dockerfile` 在 `COPY apps/api` 之后新增 `COPY alembic.ini ./`(迁移目录 `packages/db/migrations` 随 `COPY packages` 进镜像)。因此可直接在 api 容器内跑 `docker compose run --rm api uv run alembic upgrade head`(§2.4)。
|
||||
2. **compose `api` 已提供 `CREDENTIAL_ENC_KEY`** ✅:base `docker-compose.yml` 的 `api.environment` 现注入 `CREDENTIAL_ENC_KEY`(`${CREDENTIAL_ENC_KEY:-<dev 占位>}`,compose 自动读仓库根 `.env` 覆盖该默认)、`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS`。**base compose 现可直接 `up` 起 api**(无 `.env` 也走 dev 占位 key 起来;生产用 §2.2 覆盖文件强制真值)。
|
||||
3. **compose `web` 已设 `API_BASE_INTERNAL`** ✅:base 的 `web.environment` 设 `API_BASE_INTERNAL=http://api:8000`,RSC 服务端取数直连 api 容器。
|
||||
4. **`NEXT_PUBLIC_API_BASE` 支持构建期注入** ✅:web `Dockerfile` 在 `pnpm build` 前声明 `ARG NEXT_PUBLIC_API_BASE=http://localhost:8000` + `ENV`;base/prod compose 的 `web.build.args` 传入它。生产 `docker compose -f ... -f docker-compose.prod.yml build` 即把公网 API 基址内联进 bundle。
|
||||
5. **api/web 已内置 HEALTHCHECK** ✅:`apps/api/Dockerfile` 用 stdlib `urllib` 打 `/health`、`apps/web/Dockerfile` 用 `node http` 打 `/`(均 interval 30s / timeout 5s / start-period 20s / retries 3,镜像无 curl 故用运行时自带解释器)。编排/反代可直接读容器健康态。
|
||||
6. **base 弱口令/裸暴露——生产用覆盖层加固** ✅:base `docker-compose.yml` 保持 dev 便利(pg `writer/writer`、`5432:5432`、`APP_ENV=dev`);新增 **`docker-compose.prod.yml`** 覆盖层做生产加固——pg 强口令 `${POSTGRES_PASSWORD}`(`:?` 缺失即拒起)、pg 仅绑 `127.0.0.1:5432`、`APP_ENV=prod`、`LOG_JSON=true`、`CREDENTIAL_ENC_KEY`/`DATABASE_URL*`/`CORS_ORIGINS`/`NEXT_PUBLIC_API_BASE` 全从 env 且必填、`restart: unless-stopped`。
|
||||
|
||||
> 构建面同时补了两个 `.dockerignore`(仓库根 + `apps/web`):排除宿主 `node_modules`/`.next`/`.venv`/`.git`/缓存,避免宿主平台原生二进制(如 macOS 的 next-swc)经 `COPY . .` 覆盖容器内 Linux 版导致 `pnpm build` 失败,并加速构建上下文传输、保证可复现。
|
||||
>
|
||||
> 校验:`docker compose config`(base)与 `docker compose -f docker-compose.yml -f docker-compose.prod.yml config`(prod 合并)均通过;`docker compose build` 两镜像均成功构建。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案 A:单机 docker compose(推荐起步)
|
||||
|
||||
适合:单台服务器 / VPS,起步验证。前提:服务器已装 Docker(含 Docker Compose v2 插件)。
|
||||
|
||||
### 2.1 装 Docker + 拉代码
|
||||
|
||||
```bash
|
||||
# 服务器上(以 Ubuntu 为例)安装 Docker Engine + compose 插件
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
docker --version && docker compose version # 确认 compose v2 可用
|
||||
|
||||
# 拉代码
|
||||
git clone <你的仓库地址> writer-work-flow
|
||||
cd writer-work-flow
|
||||
git checkout main # 或你的发布分支
|
||||
```
|
||||
|
||||
### 2.2 准备生产配置(覆盖 base compose 的 dev 缺省)
|
||||
|
||||
base `docker-compose.yml` 是**开发导向**(pg 弱口令 `writer/writer`、`5432` 对外发布、`APP_ENV=dev`、
|
||||
`CREDENTIAL_ENC_KEY` 用 dev 占位默认)。生产加固的**覆盖文件 `docker-compose.prod.yml` 已随仓库提供**(无需手写),
|
||||
与 base 叠加使用即可。它把安全项全部改为**从宿主 env 强制注入**(缺失即拒绝启动):
|
||||
|
||||
- `pg`:`POSTGRES_PASSWORD=${POSTGRES_PASSWORD}` 强口令、端口仅绑 `127.0.0.1:5432`、`restart: unless-stopped`。
|
||||
- `api`:`APP_ENV=prod`、`LOG_JSON=true`、`CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`DATABASE_URL*`(用同一强口令)全从 env。
|
||||
- `web`:`NEXT_PUBLIC_API_BASE`(构建期 build arg + 运行期 env)指向公网 API、`API_BASE_INTERNAL=http://api:8000`。
|
||||
|
||||
你只需在**仓库根**准备一个 `.env`(compose 自动读取做 `${...}` 插值;**chmod 600、勿入库**,已被 `.gitignore` 忽略):
|
||||
|
||||
```bash
|
||||
# .env(compose 变量插值用;chmod 600,勿入库)
|
||||
POSTGRES_PASSWORD=<强随机口令>
|
||||
CREDENTIAL_ENC_KEY=<§2.3 生成的 Fernet key>
|
||||
CORS_ORIGINS=https://writer.example.com # 前端真实来源,逗号分隔;禁通配 *
|
||||
NEXT_PUBLIC_API_BASE=https://writer.example.com # 浏览器可达的公网 API(经反代)
|
||||
```
|
||||
|
||||
> prod 覆盖层对 `POSTGRES_PASSWORD`/`CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`NEXT_PUBLIC_API_BASE` 用
|
||||
> `${VAR:?...}`(必填)语义——**任一缺失,`docker compose ... config`/`up` 直接报错拒起**,从设计上杜绝
|
||||
> 「带 base 弱口令/占位 key 上生产」。完整清单见 [§2.6 生产 env 清单](#26-生产-env-清单)。
|
||||
|
||||
### 2.3 生成并妥善保管 CREDENTIAL_ENC_KEY
|
||||
|
||||
⚠️ **这是最关键的一步**。用 Fernet 生成一把新密钥(`.env.example` 里那把是开发占位,**生产禁用**):
|
||||
|
||||
```bash
|
||||
# 任一装了 cryptography 的环境(也可用仓库 uv 环境)
|
||||
uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# 或纯 python:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
```
|
||||
|
||||
把输出的一行密钥填进 §2.2 的 `.env` 的 `CREDENTIAL_ENC_KEY`,并**离线备份**(密码管理器/密钥库)。
|
||||
|
||||
> **丢失 = 灾难**:所有已录入的 provider 凭据(存在 Postgres 里、用它加密)将**永久不可解密**,
|
||||
> 只能清空后重新录入。轮换密钥需先用旧钥解密全部凭据、再用新钥重加密(当前无自动轮换脚本,
|
||||
> 见 §6)。**切勿**复用 `.env.example` 里的占位 key,那等于凭据未加密。
|
||||
|
||||
### 2.4 构建镜像 + 跑迁移 + 起服务
|
||||
|
||||
```bash
|
||||
# 在仓库根,带上覆盖文件
|
||||
export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
|
||||
# 1) 构建两个应用镜像
|
||||
$COMPOSE build
|
||||
|
||||
# 2) 先起数据库
|
||||
$COMPOSE up -d pg
|
||||
# 等 pg 健康(compose 已配 healthcheck)
|
||||
$COMPOSE ps # 看 pg 是否 healthy
|
||||
|
||||
# 3) 跑数据库迁移(含 LangGraph checkpointer 建表)—— 见下方两种方式
|
||||
```
|
||||
|
||||
**迁移的关键事实**:`alembic upgrade head` 一条命令会做**两件事**——
|
||||
(a) 建/升级 16 张业务表;(b) 迁移 `d3e4f5a6b7c8_langgraph_checkpoint_setup` 会调用 LangGraph 的
|
||||
`checkpointer.setup()` 建 `checkpoints/checkpoint_blobs/checkpoint_writes/checkpoint_migrations`
|
||||
四张检查点表。**checkpointer 的建表只在迁移里发生,绝不在应用运行时**(符合 CLAUDE.md「LangGraph」纪律)。
|
||||
所以**部署时必须先把迁移升到 head,应用才能正常写章/审稿/续跑**。
|
||||
|
||||
api 镜像现已随包 `alembic.ini`(§1.5 已修复 1),**推荐直接在 api 容器里跑迁移**(无需宿主装 uv):
|
||||
|
||||
**方式一(推荐)——在 api 容器里跑迁移:**
|
||||
|
||||
```bash
|
||||
$COMPOSE run --rm api uv run alembic upgrade head
|
||||
$COMPOSE run --rm api uv run alembic check # 可选:确认无漂移
|
||||
```
|
||||
|
||||
> 该临时容器与 api 服务共享同一 env(含 `DATABASE_URL*`/`CREDENTIAL_ENC_KEY`),直连 compose 内网的 `pg`,无需暴露端口。
|
||||
|
||||
**方式二(备选)——从宿主机 checkout 跑迁移**(宿主已装 uv 时可用;prod 覆盖层把 pg 绑到 `127.0.0.1:5432`):
|
||||
|
||||
```bash
|
||||
# 宿主机装 uv:curl -LsSf https://astral.sh/uv/install.sh | sh; export PATH="$HOME/.local/bin:$PATH"
|
||||
cd writer-work-flow && uv sync
|
||||
DATABASE_URL="postgresql+asyncpg://writer:${POSTGRES_PASSWORD}@127.0.0.1:5432/writer" \
|
||||
DATABASE_URL_SYNC="postgresql+psycopg://writer:${POSTGRES_PASSWORD}@127.0.0.1:5432/writer" \
|
||||
CREDENTIAL_ENC_KEY="${CREDENTIAL_ENC_KEY}" \
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
**4) 起全部服务:**
|
||||
|
||||
```bash
|
||||
$COMPOSE up -d
|
||||
$COMPOSE ps # api/web 应为 running(注意它们无 healthcheck,看不到 healthy)
|
||||
```
|
||||
|
||||
### 2.5 健康检查
|
||||
|
||||
```bash
|
||||
# API:根 + health 端点(均返回 200 JSON)
|
||||
curl -f http://127.0.0.1:8000/ # {"service":"ww-api","status":"ok"}
|
||||
curl -f http://127.0.0.1:8000/health # {"status":"ok"}
|
||||
|
||||
# Web:首页
|
||||
curl -fI http://127.0.0.1:3000/
|
||||
|
||||
# 看日志(api 启动会校验 CREDENTIAL_ENC_KEY、seed 单用户桩、reap 僵尸 job)
|
||||
$COMPOSE logs -f api
|
||||
```
|
||||
|
||||
若 api 启动即退出,最常见原因是 `CREDENTIAL_ENC_KEY` 非法(base compose 已注入 dev 默认;
|
||||
生产覆盖层要求经 env 提供合法 Fernet key,见 §2.3)——日志会显示 CredentialKeyError 相关堆栈。
|
||||
|
||||
部署完成后,**打开前端「设置」页录入 LLM provider 凭据**(§1.4),产品才能真正调用模型。
|
||||
|
||||
### 2.6 生产 env 清单
|
||||
|
||||
生产用 `docker-compose.prod.yml` 覆盖层,以下变量必须在**仓库根 `.env`**(compose 自动读取做 `${...}` 插值;
|
||||
`chmod 600`、勿入库)或宿主环境变量提供。带 `:?` 者缺失即 `config`/`up` **报错拒起**。
|
||||
|
||||
| 变量 | 必填 | 消费方 | 说明 |
|
||||
|---|:---:|---|---|
|
||||
| `POSTGRES_PASSWORD` | ✅ | pg + api(`DATABASE_URL*`) | pg 强随机口令,替换 base 弱口令 `writer/writer`。api 连接串复用它。 |
|
||||
| `CREDENTIAL_ENC_KEY` | ✅ | api | Fernet key(§2.3 新生成,**勿用 `.env.example` 占位值**)。api 启动即校验;解开全部已存 provider 凭据的唯一钥匙,须离线备份。 |
|
||||
| `CORS_ORIGINS` | ✅ | api | 浏览器跨源白名单,逗号分隔,**禁通配 `*`**(如 `https://writer.example.com`)。 |
|
||||
| `NEXT_PUBLIC_API_BASE` | ✅ | web(**构建期**内联 + 运行期) | 浏览器可达的公网 API 基址(经反代)。改它必须**重新 `docker compose ... build web`**(构建期内联进 bundle)。 |
|
||||
| `APP_ENV` | 覆盖层已固定 `prod` | api | 无需手设(prod 层写死)。 |
|
||||
| `LOG_JSON` | 覆盖层已固定 `true` | api | 无需手设(prod 层写死)。 |
|
||||
| `API_BASE_INTERNAL` | 覆盖层已固定 `http://api:8000` | web(RSC) | 无需手设(prod 层写死)。 |
|
||||
|
||||
> LLM provider key(DeepSeek/Kimi/OpenAI…)**永不进 env**——部署后在应用「设置」页录入、加密落库(§1.4)。
|
||||
|
||||
生成 `.env` 骨架(示例):
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
POSTGRES_PASSWORD=<强随机口令>
|
||||
CREDENTIAL_ENC_KEY=<uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 的输出>
|
||||
CORS_ORIGINS=https://writer.example.com
|
||||
NEXT_PUBLIC_API_BASE=https://writer.example.com
|
||||
EOF
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 反向代理 + HTTPS
|
||||
|
||||
生产应在应用前放一层反代做 TLS 终止、单一入口、并(配合 §6)做访问控制。
|
||||
拓扑:浏览器 → 反代(443) → `web:3000`(页面/RSC)与 `api:8000`(`/api` 或独立子域)。
|
||||
|
||||
> 注意 `NEXT_PUBLIC_API_BASE` 的构建期内联:浏览器直接打 API 的地址在**构建 web 镜像时**就定死了。
|
||||
> web `Dockerfile` 已暴露 `ARG NEXT_PUBLIC_API_BASE`、prod compose 的 `web.build.args` 会传入它——改这个基址后**必须重新 `docker compose ... build web`** 才生效(重启不改已构建 bundle)。
|
||||
> 若走「同源 `/api` 前缀反代」,构建时把 `NEXT_PUBLIC_API_BASE` 设为该前缀;若走「api 独立子域」(如 `api.example.com`),同理构建期注入。
|
||||
|
||||
### 3.1 Caddy(推荐,自动证书)
|
||||
|
||||
```caddy
|
||||
# Caddyfile —— 自动申请/续签 Let's Encrypt 证书
|
||||
writer.example.com {
|
||||
# 后端 API:同源 /api/* 反代到 api 容器(去掉 /api 前缀按你的前端约定调整)
|
||||
handle_path /api/* {
|
||||
reverse_proxy api:8000
|
||||
}
|
||||
# 其余流量到 Next 前端
|
||||
reverse_proxy web:3000
|
||||
|
||||
# SSE 长连接:关掉缓冲,避免审稿/写章流被缓存卡住
|
||||
reverse_proxy api:8000 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将 Caddy 也放进 compose(与 api/web 同网络,用服务名访问),暴露 80/443。
|
||||
|
||||
### 3.2 Nginx(手动 / certbot 证书)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name writer.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/writer.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/writer.example.com/privkey.pem;
|
||||
|
||||
# 前端
|
||||
location / {
|
||||
proxy_pass http://web:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 后端 API(含 SSE:写章/四审流)
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection ""; # SSE:保持长连接
|
||||
proxy_buffering off; # SSE:关缓冲,事件即时下发
|
||||
proxy_read_timeout 3600s; # 长任务/流式容忍
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 无论 Caddy/Nginx,**SSE 必须关代理缓冲**(写章、四审都是事件流),否则前端进度条会卡住。
|
||||
|
||||
---
|
||||
|
||||
## 4. 方案 B:PaaS / 托管平台
|
||||
|
||||
容器化已就绪(两个 Dockerfile + standalone 前端),可托管到 PaaS。共性:**用托管 Postgres**,
|
||||
在平台面板设 env(尤其 `CREDENTIAL_ENC_KEY`、`CORS_ORIGINS`、`DATABASE_URL*`),
|
||||
部署后照 §2.4 跑一次 `alembic upgrade head`。provider key 仍在应用设置页录入(不进平台 env)。
|
||||
|
||||
### 4.1 Fly.io
|
||||
|
||||
- 两个 app:`fly launch` 分别为 api(`apps/api/Dockerfile`,构建上下文=仓库根)与 web(`apps/web/Dockerfile`,上下文=`apps/web`)。
|
||||
- Postgres 用 `fly postgres create`(或外部托管 PG),把 `DATABASE_URL`/`DATABASE_URL_SYNC` 设成其连接串(注意驱动前缀 `+asyncpg`/`+psycopg`,Fly 给的是原生 `postgres://`,需自行加前缀)。
|
||||
- `fly secrets set CREDENTIAL_ENC_KEY=... CORS_ORIGINS=... POSTGRES_PASSWORD=...`。
|
||||
- 迁移:镜像已含 `alembic.ini`(§1.5 已修复 1),可用 Fly `[deploy] release_command = "uv run alembic upgrade head"` 在每次发布前自动跑迁移,或 `fly ssh console` 手动跑。
|
||||
- web 的 `NEXT_PUBLIC_API_BASE` 在**构建期**注入(Fly build args → Dockerfile `ARG NEXT_PUBLIC_API_BASE`,已暴露)。
|
||||
- api 内部端口 8000、web 内部端口 3000,在 `fly.toml` 的 `internal_port` 对应。
|
||||
|
||||
### 4.2 Render
|
||||
|
||||
- 两个 Web Service(Docker 运行时),分别指向两个 Dockerfile;web 的 Docker build context 设 `apps/web`,api 设仓库根。
|
||||
- Postgres 用 Render Managed Postgres,把内部连接串填进 api 的 `DATABASE_URL*`(加 `+asyncpg`/`+psycopg` 前缀)。
|
||||
- 在 api service 的 Environment 填 `CREDENTIAL_ENC_KEY`/`CORS_ORIGINS`/`APP_ENV=prod`/`LOG_JSON=true`。
|
||||
- 迁移:用 Render 的 **Pre-Deploy Command** 跑 `uv run alembic upgrade head`(镜像已含 `alembic.ini`,§1.5 已修复 1)。
|
||||
- web 与 api 的互通用 Render 的内部地址;`API_BASE_INTERNAL` 指向 api 内部 URL,`NEXT_PUBLIC_API_BASE`(构建期 build arg)指向 api 公网 URL。
|
||||
|
||||
> 两个平台的共同注意点:`NEXT_PUBLIC_API_BASE` 仍是**构建期内联**(改它须重构 web 镜像);`alembic.ini` 已随镜像,迁移可在容器/release 命令里跑。
|
||||
> HEALTHCHECK 已内建于镜像,平台存活探测可直接复用或按平台惯例另配 `/health`(api)/`/`(web)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 迁移与升级流程
|
||||
|
||||
### 5.1 常规升级
|
||||
|
||||
```bash
|
||||
export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
|
||||
git pull # 拉新代码(含新迁移 / schema.d.ts)
|
||||
$COMPOSE build # 重建 api/web 镜像
|
||||
# 先升迁移到 head(含新增业务表 + 可能的 checkpointer 变更),再滚动重启(见 §2.4)
|
||||
$COMPOSE run --rm api uv run alembic upgrade head # 在 api 容器内跑迁移(镜像已含 alembic.ini)
|
||||
$COMPOSE up -d # 用新镜像重启 api/web(pg 不动)
|
||||
|
||||
# 冒烟
|
||||
curl -f http://127.0.0.1:8000/health && curl -fI http://127.0.0.1:3000/
|
||||
```
|
||||
|
||||
顺序要点:**先迁移、后换应用镜像**——新代码通常假设 schema 已到 head。若后端 schema 有变,
|
||||
记得升级前已在开发侧 `pnpm gen:api` 重生成并提交了 `apps/web/lib/api/schema.d.ts`(§1.2),
|
||||
否则 web 构建/类型会漂移。
|
||||
|
||||
### 5.2 回滚
|
||||
|
||||
```bash
|
||||
# 应用回滚:切回上一个 tag/commit 重建重启
|
||||
git checkout <上一个可用 tag>
|
||||
$COMPOSE build && $COMPOSE up -d
|
||||
|
||||
# 数据库回滚(谨慎):按需降级一版
|
||||
$COMPOSE run --rm api uv run alembic downgrade -1
|
||||
```
|
||||
|
||||
> ⚠️ DB 降级可能丢列/丢表数据,且 `d3e4f5a6b7c8` 的 `downgrade()` 会 `DROP` 四张 checkpoint 表
|
||||
> (在跑的写章/链任务会丢控制流状态)。**回滚前先备份**(§7.3)。业务表回滚同理不可逆,优先「向前修复」。
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全红线(单用户原型的硬约束)
|
||||
|
||||
⚠️ **本产品没有认证、没有多租户、没有登录**(`users`/`owner_id` 是桩,见 CLAUDE.md「Prototype scope」)。
|
||||
任何拿到 URL 的人都能读写全部小说数据、录入/触发用花你钱的 LLM 凭据。因此:
|
||||
|
||||
1. **绝不裸暴露到公网。** 至少满足其一:
|
||||
- 只在 **VPN / 内网 / SSH 隧道**内访问;
|
||||
- 反代前加 **IP 白名单** 或 **HTTP Basic Auth**(Caddy `basic_auth` / Nginx `auth_basic`);
|
||||
- 或**先补一层认证**再上公网(当前无,属产品级改造)。
|
||||
2. **Postgres 不对公网开放。** base compose 把 `pg` 发布到 `5432:5432`(全网卡)。生产**必须**改成
|
||||
仅回环 `127.0.0.1:5432:5432`(§2.2)或删掉端口映射只走 compose 内网。**改掉 base 里的弱口令 `writer/writer`**。
|
||||
3. **CORS 显式白名单,禁通配。** `CORS_ORIGINS` 必须是精确来源;后端已在启动断言不含 `*`
|
||||
(`allow_credentials=True` 下 `*` 会被浏览器拒且语义危险)。别为图省事放宽。
|
||||
4. **`CREDENTIAL_ENC_KEY` 的密钥管理与轮换:**
|
||||
- 用 §2.3 生成的**新** Fernet key,绝不用 `.env.example` 占位值;
|
||||
- 存进密钥库 / 平台 secrets,`.env` `chmod 600` 且**不入库**;
|
||||
- **备份它**(丢了 = 所有 provider 凭据不可解密);
|
||||
- 轮换需「旧钥解密全部凭据 → 新钥重加密」(当前无自动脚本,轮换前规划停机 + 备份)。
|
||||
5. **provider key 只走应用设置页**(加密落库),永不写进 env/compose/日志(§1.4)。
|
||||
6. **日志脱敏已内建**:structlog 结构化日志按 CLAUDE.md「Logging」纪律**不记全 prompt/正文**(记长度/哈希)、
|
||||
**不记 API key**。保持这一约束,别在自建反代/中间件里回退成明文全量日志。
|
||||
7. **HTTPS 强制**(§3),别让凭据/正文走明文。
|
||||
|
||||
---
|
||||
|
||||
## 7. 可观测性与运维
|
||||
|
||||
### 7.1 健康端点
|
||||
|
||||
- `GET /` → `{"service":"ww-api","status":"ok"}`
|
||||
- `GET /health` → `{"status":"ok"}`
|
||||
|
||||
两者均无鉴权、返回 200,可作反代/编排的存活探测目标。
|
||||
api/web 的 Dockerfile **已内置** `HEALTHCHECK`(§1.5 已修复 5):api 用 stdlib `urllib` 打 `/health`、web 用 `node http` 打 `/`——`docker compose ps` 直接显示 `healthy`,反代/平台亦可复用。
|
||||
|
||||
### 7.2 结构化日志
|
||||
|
||||
- 后端 structlog;**生产设 `LOG_JSON=true`** 输出 JSON,便于日志系统采集/检索。
|
||||
- 每个请求带 `request_id`(贯穿 assemble→write→四审→accept 一条链),也进错误响应信封——
|
||||
用户报错时可用它端到端 grep。
|
||||
- 采集:`docker compose logs`,或把容器 stdout 接到你的日志栈(Loki/ELK/云日志)。
|
||||
|
||||
### 7.3 备份 Postgres(含凭据库)
|
||||
|
||||
数据全在 `pgdata` 卷 + Postgres 里,**包含加密后的 provider 凭据**。定期逻辑备份:
|
||||
|
||||
```bash
|
||||
export COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
|
||||
# 逻辑备份(推荐)
|
||||
$COMPOSE exec pg pg_dump -U writer -d writer -Fc > backup_$(date +%F).dump
|
||||
|
||||
# 恢复
|
||||
$COMPOSE exec -T pg pg_restore -U writer -d writer --clean < backup_YYYY-MM-DD.dump
|
||||
```
|
||||
|
||||
> **备份数据库还不够**:`CREDENTIAL_ENC_KEY` 要**单独异地备份**——DB 里的凭据是密文,
|
||||
> 没有这把钥匙备份也解不出(§2.3 / §6)。两者分开保管、都要备。
|
||||
|
||||
### 7.4 成本账本(LLM 调用)
|
||||
|
||||
每次 LLM 调用都按 `ARCHITECTURE §4.8` 的字段集记结构化日志(provider/model/tier/token 数/耗时/`request_id` 等),
|
||||
喂成本账本。运维时用这些日志:
|
||||
- 核对每章/每次生成的 token 与花费;
|
||||
- 排查慢/失败调用(网关的路由/回退/熔断也在此可见);
|
||||
- 用 `request_id` 关联「一次写章」的整条链。
|
||||
|
||||
采集方式同 §7.2(JSON 日志接入日志栈后按字段聚合)。
|
||||
|
||||
---
|
||||
|
||||
## 附:核心事实速查
|
||||
|
||||
- **服务/端口**:`pg`(5432, 唯一健康检查 `pg_isready -U writer`) · `api`(8000) · `web`(3000)。
|
||||
- **镜像**:均本地构建;api 上下文=仓库根,web 上下文=`apps/web`(standalone)。
|
||||
- **必填 env**:`CREDENTIAL_ENC_KEY`(启动即校验,缺失/非法则 api 起不来)。
|
||||
- **迁移**:`alembic upgrade head` 同时建业务表 + LangGraph checkpointer 表;checkpointer 只在迁移建表,不在运行时。
|
||||
- **provider key**:不进 env,部署后在设置页录入、加密落库。
|
||||
- **部署面缺口已全部修复**(§1.5):api 镜像含 `alembic.ini`;base compose `api` 注入 `CREDENTIAL_ENC_KEY`/`APP_ENV`/`LOG_JSON`/`CORS_ORIGINS`、`web` 设 `API_BASE_INTERNAL` + `NEXT_PUBLIC_API_BASE` build arg;api/web 内置 HEALTHCHECK;生产加固走 `docker-compose.prod.yml` 覆盖层(强口令/回环 pg/prod env/`restart`)。
|
||||
- **部署命令**:`docker compose build`(dev)或 `docker compose -f docker-compose.yml -f docker-compose.prod.yml build`(prod)→ `... run --rm api uv run alembic upgrade head` → `... up -d`。
|
||||
- **红线**:单用户无认证 → 不裸暴露公网;pg 不对外;CORS 禁 `*`;`CREDENTIAL_ENC_KEY` 备份+轮换。
|
||||
108
PROGRESS.md
108
PROGRESS.md
@@ -8,7 +8,89 @@
|
||||
|
||||
---
|
||||
|
||||
## 当前阶段:T6 · 创作工具箱(通用生成器框架)— P1 框架地基
|
||||
## ✅ 已交付:AI 对话(聊天记录)持久化(AC-1..AC-4 全绿)· develop 上新功能
|
||||
|
||||
> **背景**:[2026-07-09] 用户要求把作者↔AI 每轮往复(refine/rewrite/clarify/continue/工具箱生成器)**落库为聊天记录**,每章一个「AI 对话」抽屉展示。多 agent(4 学科起草→4 视角交叉评审→合稿)产出实施计划 `docs/design/ai-chat-history-plan.md`。**决策全 A**:clarify buffer-until-concluded / v1 只记 5 类交互 / append-only 不删。
|
||||
> **契约与不变量纪律**:@db 建表(契约 C2 扩)→@backend 端点(C3 扩)→@frontend gen:api+接线→@qa E2E,逐级等上游契约「稳定」。守 #1/#3/#8:聊天记录是**旁路留痕**(同 `usage_ledger` 物种),前端**显式 append**、生成端点**保持只读不变**、**绝不**喂 `assemble`/prompt(计划 §8 有围栏 + code-review 守卫)。
|
||||
|
||||
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| AC-1 `ai_messages` 表 + 迁移 | ✅ @db 2026-07-09 | @db | — | 计划 §2 canonical DDL;迁移 `ceabd3c2c00d`(down=`f6a7b8c9d0e1`);C2 扩已「稳定」,AC-2 可动工 |
|
||||
| AC-2 repo + append/list 端点 + schema + 契约 | ✅ @backend 2026-07-09 | @backend | AC-1 | `c675a74`;`SqlAiMessageRepo`(批 append seq/thread_id + newest-first union/分页) + `POST/GET /projects/{id}/ai-messages` + schemas;C3 扩已「稳定」→ AC-3 可动工(须 `pnpm gen:api`) |
|
||||
| AC-3 gen:api + AiConversationDrawer + useAiConversation + 接线 5 处交互 | ✅ @frontend 2026-07-09 | @frontend | AC-2 | `dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`;gen:api + 纯视图模型 + hook + 抽屉 + 五类交互留痕(clarify buffer-until-concluded);vitest 706 passed / cov 95% / build OK |
|
||||
| AC-4 E2E + #1/#6 围栏守卫 | ✅ @qa 2026-07-09 | @qa | AC-3 | 计划 §6/§8;`tests/test_ai_chat_history_e2e.py`(6 用例,真 pg 零 LLM)+ `tests/test_ai_messages_not_in_generation_path.py`(AST 围栏,能变红);pytest **1002 passed**(+10)。**AI 对话(聊天记录)AC-1..AC-4 全交付** |
|
||||
|
||||
---
|
||||
|
||||
## 已完成阶段:QA-CR · 全项目 Code Review 整改批(全 ✅)
|
||||
|
||||
> **背景**:[2026-06-26] 对全仓做了一次**七维度 Code Review**(后端 Python 规范 / LangGraph 最佳实践 / 后端 FastAPI 架构 / 前端编程规范 / 前端架构 / 应用安全 / 依赖时效),6 个专科 reviewer 并行 + 依赖自查。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**——再次证伪「测试过=没问题」。架构本身成熟、不变量纪律强(provider 中立 / HITL accept 单写 / checkpoint 仅控制流 / SecretStr 全覆盖);**确认无虞**:SSRF(`base_url` 仅来自硬编码 `_PROVIDER_BASE_URLS`,无用户 URL 入网)/ SQL 注入(全 ORM 参数化)/ XSS(无 `dangerouslySetInnerHTML`/`eval`)/ 错误信封不泄栈密钥 / Kimi 用 RFC 8628 device flow 无 CSRF 面。
|
||||
> **整改 DoD**:CRITICAL + HIGH 全部 ✅ 且各自补回归测试;MEDIUM 择批清理;门禁全绿(后端 ruff/format/mypy/pytest + alembic 无漂移;前端 lint/tsc/vitest/build)。**守 §Definition of Done**:每项 TDD 先红后绿、完成即小步 commit。
|
||||
> **落地顺序**:① 本周 CR-C1/C2 + 两资源泄漏(H1/H2)+ Fernet key(H8);② 合并前 计费两项(H3)+ request_id(H4)+ max_length(H9)+ 前端三 HIGH(H10/H11/H12);③ MEDIUM/LOW 技术债批。
|
||||
> **依赖纪律**:CR-C1(升 Next)须 `pnpm install` 重锁 + `pnpm build` 验证、**升级后轮换全部应用密钥**(与 CR-H8 Fernet key 联动)。CR-H4/H9 改 schema → 若动 OpenAPI 形参须 `pnpm gen:api` + `memory/contracts.md` 记一笔。无 DB 迁移预期。
|
||||
|
||||
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| CR-C1 🔴 升级 Next.js 修 RCE + React 补丁 | ✅ | @frontend | — | `f4c2c4e` next 15.1.3→**15.5.20**(registry backport 线,避 16.x 大迁移)、react/react-dom 19.0.0→**19.2.7**、eslint-config-next/@types 同步;`pnpm install` 重锁 + `pnpm build` 绿;`pnpm audit` next/react/react-dom 无 high/critical;新 `lib/security/dependencyFloors.ts` 版本下界守卫。**⚠️ 运维待办(用户执行)**:CVE 号无法对真库核验(升级后 `pnpm audit`/GitHub advisory 复核)+ 升级后轮换全部应用密钥 |
|
||||
| CR-C2 🔴 多章链设 `recursion_limit` | ✅ | @backend | — | `ed0a679` **前提证伪**:langgraph 1.2.5 默认上限=10007(非 25)、count≤50→~200 步,**无实活崩溃**;仍加显式**顶层** `recursion_limit=count*4+10`(防御硬化);测断言 config 键(行为「跑长链完成」测会 vacuous) |
|
||||
| CR-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | ✅ | @backend | — | `ca8fbc8` 轮询移出会话:`_poll_for_token`(无会话)+`_run_kimi_oauth_poll`,仅最终 persist 走 run_job 短会话;`job_runner` 未动(与 style_learn 共享) |
|
||||
| CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | ✅ | @backend | — | `5dd336b`+`26f10d0` 请求级依赖改 `_http_client` 生成器(`async with`→`aclose`);两处测 override 跟随改,堵住 E2E 误触真网 |
|
||||
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | ✅ | @llm | — | `cb8fddf` 改 `create_with_completion` 取 `raw.usage`,弃硬编码 0(修计费腐蚀) |
|
||||
| CR-H4 🟠 `request_id` 贯通网关日志 | ✅ | @llm | — | `51fe9f4`+`e08b01a` LlmRequest 加可选 `request_id` + **条件透传**(仅非 None,否则覆盖 contextvars 反退化 sync/SSE 追踪);`llm_call`+两处 `llm_provider_failed` 全锁测。契约 C1-扩 |
|
||||
| CR-H5 🟠 `thinking` 字段:实现或删除 | ✅ | @llm | — | `afeac51` 删死字段 `LlmRequest.thinking`(YAGNI);保 `Capabilities.thinking`(异名活字段);回写 ARCH/contracts |
|
||||
| CR-H6 🟠 流式路径加 per-provider 重试 | ✅ | @llm | — | `8a2b1b0` `_stream_with_retry` 对称 run:仅首 token 前重试,mid-stream 失败照抛(不变量 §4.5) |
|
||||
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | ✅ | @backend | — | `25d48b7` `Final[Mapping]=MappingProxyType(_TOOLBOX_BY_KEY)`(仿 SPECS) |
|
||||
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | ✅ | @devops | — | `baa41d3` 换不可用占位符(Fernet 拒绝→照抄即启动失败)+ 轮换提示。**⚠️ 真 key 仍在 git 历史,曾用该 key 的环境须轮换(用户)** |
|
||||
| CR-H9 🟠 API 文本输入加 `max_length` | ✅ | @backend | — | `e1a24a2`+`d6b53d0` 全请求文本字段加上限(api_key≤512/brief≤1e4/final_text≤2e5/segment≤2e4/directive≤2e4/instruction≤2e3…)+ **2 MiB body 中间件 413 兜底** + `PAYLOAD_TOO_LARGE`。契约 C3;`gen:api` 无漂移 |
|
||||
| CR-H10 🟠 前端 `errorCode` 去重 | ✅ | @frontend | — | `5665cc9` `lib/style/{useRefine,useStyleLearn}` 统一 import 自 `generation/cards` |
|
||||
| CR-H11 🟠 Drawer 关闭还原焦点 | ✅ | @frontend | — | `6e2b478` 行为本分支早已修;提取 `lib/a11y/useRestoreFocus` + renderHook 回归测试 + DRY(Drawer/ContextDrawer 共用) |
|
||||
| CR-H12 🟠 RefineView 取消在途 refine | ✅ | @frontend | — | `5c02792` useRefine 暴露 `abort`(setState-free)+ AbortController + aborted 守卫弃迟到响应;RefineView cleanup 调用 + 真 deps |
|
||||
| CR-M1 🟡 后端架构整改批 | ✅ 大部 | @backend | — | `c596a8d` outline volume 下推 DB WHERE(Protocol/Sql/fake 加 keyword `volume`);`bf3dabe` 角色/世界观入库 N+1→`create_many`(世界观 add_all;角色一次 IN 查+一次 flush 保 upsert 幂等/批内去重;单条 create 薄封装);`1392830` jobs 读端点裸 ORM→JobRepo;`0892ff6` projects/templates 列表加分页(`ww_api.pagination` limit∈[1,200]/offset≥0,越界 422,下推 LIMIT/OFFSET)。**item#4 provider_deps `_client`→已由 @llm 交付**(`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 改调,删私有 `_client` 反手 + `type:ignore`/`noqa:SLF001`;isinstance 收窄兜非 OpenAI 兼容)。**item#6 assemble 并行→SKIP**:8 查询共享同一注入 AsyncSession,`gather` 会腐蚀(ledger 警告属实);安全化须把 session 工厂穿透 assemble + 8 repo 各独立 session + 并发正确性测,对单用户几 ms 收益不值该回归风险 |
|
||||
| CR-M2 🟡 网关/adapters 整改批 | ✅ 大部 | @llm | — | `32451a9` pricing 未知 model 发 `pricing_unknown_model` warning(成本仍 0、照常记账);`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status(不泄 vendor 响应体)。**item `is_open()` CQS→SKIP**:冷却过读时半开清态是**故意的惰性转移**(唯一转半开点),拆 CQS 需两处调用方各先发命令、漏发则熔断永不复位(更差),风格分不值动熔断语义 |
|
||||
| CR-M3 🟡 LangGraph 链健壮性 | ✅ 大部 | @llm | — | `ec91c8f` runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`;SpyGraph 测同步补 aget_state 透传);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8,collect 仍一次落库;并发峰值测 + 五审落齐测)。**item `run_overdue_scan` 幂等→SKIP**:`scan_overdue` 本已幂等(单条 `UPDATE...RETURNING` + `status NOT IN [CLOSED,OVERDUE]` + commit-only-if-changed,原子无部分空洞),且下一章扫描 current+1 自愈漏标;再动会触碰验收事务边界,单用户零收益 |
|
||||
| CR-M4 🟡 前端 key/a11y/cleanup 批 | ✅ | @frontend | — | `47d5558` ARIA(NavDrawer `aria-controls` + Drawer `id` + ForeshadowCard 语义化 `<dl>/<dt>/<dd>`)+ `125908e` ReviewReport `flashMark` setTimeout 卸载清理(ref 存句柄)。**核实为无需改/已具备**:index-key(三处均 replace-all/append-only 静态展示、无天然稳定 id,index 安全;卡名可编辑作 key 更糟)、`stop`(经查**非**服务端 SSE 帧、是客户端 AbortController 动作,加则处理不存在事件)、AppShell nav label(`<nav aria-label>` 已有)、ProjectWizard 步骤焦点(`stepHeadingRef`+首帧守卫 useEffect 已实现) |
|
||||
| CR-M5 🟡 前端 TS 边界类型收紧 | ✅ | @frontend | — | `d80d41a` `ApiErrorEnvelope` 改由生成 schema(`components.schemas.ErrorEnvelope`)派生防漂移 + `9cf4915` `hasApplicableFix` 改对象参类型谓词、消除 `applyConflictFix` 两处 `as string` + `16f2eae` `parseJsonBody`(结构校验 HEAD 已具备,补非对象/非 JSON 守卫分支测试) |
|
||||
| CR-L1 🟢 LOW 清理批 | ✅ | @backend·@llm·@frontend | — | **@backend**:`49e3cb6` SSE draft/rewrite/review 补 OpenAPI `text/event-stream` 声明(共享 `_SSE_RESPONSE`);`print`→logging **无需改**(owned 目录唯一 `print(` 是 `export_openapi.py` CLI stdout,前端 gen:api 管道消费,改则断管道)。**@llm**:`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning。**@frontend**:`6414542` 去冗余 `as`/`.valueOf()` cast(`stream/sse.ts` idx.valueOf、`jobs/job.ts` `dict[error] as string`;保留 load-bearing 的 CSS-var 键/变量键强转) |
|
||||
| CR-D1 🟡 抬高后端依赖下界(可选升级评估) | ✅ | @devops | — | 8 个 pyproject 后端运行时下界抬到 lockfile 主次版本(`langgraph>=1.2`/`anthropic>=0.111`/`openai>=2.43`/`pydantic>=2.13`/`fastapi>=0.137`/`cryptography>=49` 等,每条 ≤ 实锁);`uv.lock` **纯元数据刷新**(仅 `requires-dist` specifier,零 pinned 版本变动);`uv lock --check` 绿、pytest **971**、ruff 干净。root dev 工具下界 + 前端 vitest/tailwind 升级评估未纳入(超范围) |
|
||||
|
||||
> **[2026-07-08] CRITICAL + HIGH 全部 ✅ 落地**(14 项 + 4 项低危收尾),多 agent 并行开发(@llm/@backend/@devops/@frontend 四簇串行整合,各 TDD 红→绿、逐项补回归测试)。**门禁全绿**:后端 ruff/format 干净 · mypy **229** · alembic **无漂移** · pytest **934**;前端 lint/tsc 干净 · vitest **669** · build OK · coverage **95.41%**。**14 项对抗式复核 0 problem**(skeptic 逐条实证 test 非 vacuous)。落地含 3 处前提证伪(C2 非实活崩溃/H4 sync-SSE 已由 contextvars 贯通/H11 行为本分支已修)。
|
||||
>
|
||||
> **[2026-07-08] MEDIUM/LOW(CR-M1..M5 / L1 / D1)亦全部清理 ✅**(四簇串行整合):明确取舍——高价值低风险项落地、风险>价值或前提已陈旧的子项**有据 SKIP/NOOP**(assemble 并行 gather / breaker is_open CQS / overdue-scan 已幂等 / index-key 安全 / `stop` 非服务端帧 / 已具备的 nav-label、wizard 焦点)。**门禁全绿**:后端 mypy **232** · alembic 无漂移 · pytest **971**;前端 vitest **681** · build · coverage **95.45%**。附带修复:一 agent 误提交的非源文件(`.coverage`/`AGENTS.md`/`example_skills`)已撤销还原为未跟踪(`1bda024`)。
|
||||
>
|
||||
> **维度小结**:后端 FastAPI 架构 0C/2H/7M·Python 规范 0C/4H/6M·LangGraph 1C/2H/5M·前端 React 0C/2H/9M·前端 TS 0C/1H/8L·安全 0C/2H/4M·依赖 1C。后端依赖全部当前大版本线;前端唯一关键缺口=Next RCE。
|
||||
>
|
||||
> **[2026-07-08] CR-M1 + CR-L1 后端片 ✅ 落地**(@backend,6 提交 `c596a8d`/`bf3dabe`/`1392830`/`0892ff6`/`49e3cb6`,各行为改动先写红测再实现)。CR-M1 4/6 完成(volume 下推 · N+1→create_many · jobs→JobRepo · 列表分页),item#4(去 `_client`)与 item#6(assemble 并行)SKIP(理由见 CR-M1 行);CR-L1 后端片完成(SSE OpenAPI;print 属误报)。**门禁全绿**:后端 ruff/format 干净 · mypy **230** · pytest **960 passed**(较基线 955 +5 新测:volume 下推 1 + 分页 2 + 批量入库 2)· 无模型改动故免 alembic。新增 5 测无回归,既有 upsert 幂等/冲突 gate + m5 真 pg E2E 全绿。
|
||||
>
|
||||
> **[请求 → @llm]** CR-M1 item#4:`apps/api/ww_api/services/provider_deps.py:91` 需去掉对 adapter 私有 `_client` 的探测(`adapter._client.models.list()`)。owning 类 `OpenAICompatAdapter`/`ProviderAdapter`(`packages/llm_gateway/ww_llm_gateway/adapters/`)在 @llm 域——请在 adapter 上加一个公开 `async probe_connection()`(内部最小请求验 Key,如 `models.list()`),@backend 改调该公开方法即可删私有属性访问 + `type: ignore`/`noqa: SLF001`。行为保持不变。
|
||||
|
||||
---
|
||||
|
||||
## 进行中:写作工作台重构(writing-first)· 分支 `feat/writing-first-workspace`
|
||||
|
||||
> **背景**:[2026-07-07] 用户对写作体验提 8 点反馈,产品主张升级为 **writing-first 工作台**——进来即写、所有工具/AI 动作从编辑器内按需调用并回填正文、元数据由内容渐进生成。多 agent 工作流(扎根代码→Web+GitHub 调研→三方设计→四镜头对抗评审→定稿)产出 DEV_PLAN(`scratchpad/writing-first-workspace-DEVPLAN.md`,**ephemeral 未入库**;灵感/取舍见 `memory/selectable-write-prompts.md`)。
|
||||
> **已拍板决策**:进来即写=**延迟落库**(首次击键才建占位项目 + 列表过滤空稿);可选提示词 MVP=**纯前端** directive→volatile(不改 `DraftStreamRequest` 契约、不动 `write_craft.md` 字节 → 金标准零回归);预设 MVP=先只上内置;续写=多候选卡点选插入。
|
||||
> **纪律**:全程 TDD 小步提交,回填全守 HITL(AI 绝不 auto-replace,点选/接受才落草稿);`/refine`、`/rewrite` 只读不写库(不变量 #3)。**门禁全绿**:前端 vitest **678** / lint / tsc / build / coverage **95.36%**;后端 ruff / mypy **229** / pytest **955**(含 WFW-9 clarify E2E)/ alembic 无漂移。**WFW-0~9 全部交付**(WFW-5/6/7 多 agent 并行 → `3dd7d28`;WFW-8 整章重写跨栈 → `6ed9c89`+`542c2ab`;**WFW-9 三段全交付** M1 `1652ad9`+`e30c933` / M2 `4a09f27`..`8e29b33` / M3 `ea9a9d9`+`a703a90`,契约 C-Rewrite+C-Clarify 见 `memory/contracts.md`)。**未 push、未开 PR**(等用户示意)。
|
||||
|
||||
| 任务 | 状态 | 负责 | 依赖 | 反馈# | 备注 |
|
||||
|---|---|---|---|---|---|
|
||||
| WFW-0 进来即写(延迟落库) | ✅ | @frontend | — | #5 | `8389572` `/write`(DraftComposer) + `lib/start/useDeferredDraft`(懒建占位项目+种首章草稿,双重幂等,空进空出不落库) + 落地页 CTA「直接开始写」主/「先立项」辅 |
|
||||
| WFW-1 润色 / 再沟通 | ✅ | @frontend | — | #1#2 | `2f0b841` `useRefine`(同步/refine,版本栈,再沟通=上一版产出+新意见再回炉) + `refineApply`(接受回填按内容重锚,原文变则提示重选) + RefinePanel 内联卡 + Editor 上报选区 |
|
||||
| WFW-2 续写(多候选) | ✅ | @frontend | WFW-0 | #2 | `9195cf3` `useContinue`(continue 生成器 with_prior_chapter 读前文,多候选累加) + ContinuePanel 候选卡点选插入章末 + flush 后读最新草稿 |
|
||||
| WFW-3 内联工具箱 + 金手指露出 | ✅ | @frontend | — | #4#8 | `04f4785` `useToolboxTools`(客户端拉描述符) + InlineToolbox(列非 legacy 生成器含金手指) + `GeneratorRunner` 加 `onInsertText`(文本产物插入正文) |
|
||||
| WFW-4 AI 写作三槽(文风/剧情/自定义) | ✅ | @frontend | — | #3 | `44b0b8a` `directive.ts` 加 `PLOT_PRESETS` + `composeDirective` 合并;DirectivePanel 拆文风/剧情两组 chips + 自定义。纯前端零契约,`write_craft.md` 字节不变 |
|
||||
| WFW-5 内容感知书名 | ✅ | @frontend | — | #6 | `3dd7d28` 纯前端零后端 MVP:`buildManuscriptExcerpt`(首尾摘录+硬上界) + GeneratorRunner「用当前正文」按钮把摘录填 brief,book-title 据正文反推。**后续更佳版**:后端富化 book-title.md + `with_manuscript` 策略(未做) |
|
||||
| WFW-6 侧栏 IA 收敛(ContextDrawer) | ✅ | @frontend | — | #7 | `3dd7d28` 加法式 ContextDrawer 右侧速查抽屉 + 底栏「速查」按钮;设定库/大纲**完整只读速查**,伏笔/规则/文风**摘要+跳全宽页**(后续可补真实数据)。`CONTEXT_DRAWER_ENABLED` 一键回滚、旧侧栏/全宽页路由全保留 |
|
||||
| WFW-7 首次 AI 写作前极轻 genre 采集 + 无大纲降级 | ✅ | @frontend | WFW-0 | — | `3dd7d28` 无题材首次写章前弹 GenrePicker(临时并入本次 directive 不落库) + chapters 空时「尚无大纲,将按设定自由生成」提示,杜绝无题材 slop |
|
||||
| WFW-8 整章级「再沟通/重写」(对话式迭代) | ✅ | @backend·@llm·@frontend | — | 新#1 | `6ed9c89`(后端)+`542c2ab`(前端)。后端 SSE `POST .../rewrite`(`rewrite.md` 教条 + `rewrite_node` + `RewriteStreamRequest`,复用 assemble 记忆注入,只读不写库、工具非写节点、守 #3/#7/#9);前端 `useChapterRewrite`(版本栈)+`ChapterRewritePanel`+底栏「整章重写」:给意见→流式重写整章→多轮迭代→接受某版才替换正文(HITL)。契约 C-Rewrite。用户反馈「整章不满意要跟 AI 交流」 |
|
||||
| WFW-9 M1 AI 反问澄清(refine 侧,路线A 两阶段) | ✅ | @backend·@llm·@frontend | — | 新#2 | `1652ad9`(后端)+`e30c933`(前端)。多agent并行(后端整片‖ChoiceChips)+主线集成。非流式预检 `POST .../refine/clarify`→`ClarifyDecision`(结构化,analyst,只读,失败回退放行)+clarify_refine spec/教条(SPECS#24);既有 refine 未改。前端 `useClarify`+`ChoiceChips`+`clarify.ts`(foldClarifications/门控)+RefinePanel 门控接线:意见<10字或点「帮我理清方向」→AI反问选项→折答案进instruction→走既有refine。契约 C-Clarify。**M1 refine 侧完成;M2/M3 见下两行** |
|
||||
| WFW-9 M2 AI 反问澄清(rewrite 侧,整章重写路径) | ✅ | @backend·@llm·@frontend | — | 新#2 | `4a09f27`+`6fd6375`(后端)+`9b32743`+`4b1088a`+`8e29b33`(前端),多 agent 串行整合。新非流式预检 `POST .../rewrite/clarify`→`ClarifyDecision`(复用 M1 schema/`run_clarify`)+新 `clarify_rewrite` spec/教条(章级,SPECS 24→25,prompt_hashes +1)+`RewriteClarifyRequest{feedback,prior_draft?}`(仅取有界 3000 字摘录喂 analyst,不整章入 LLM);只读不写库、失败放行。前端 `useRewriteClarify`+`toVM/PROCEED` 提取入共享 `clarify.ts`(DRY)+`ChapterRewritePanel` 门控(镜像 RefinePanel):意见<10字或「帮我理清方向」→反问→折进 feedback→走既有 rewrite SSE。契约 C-Clarify 扩;gen:api 纳入新端点 |
|
||||
| WFW-9 M3 澄清 UX 打磨 + E2E | ✅ | @frontend·@qa | — | — | `ea9a9d9`(a11y):refine/rewrite 两面板 AI 反问块加 `role=status` 向读屏动态播报(WCAG 4.1.3 Status Messages)。`a703a90`(E2E):`tests/test_wfw9_clarify_e2e.py` 7 用例真 pg+mock 网关零 token——refine/rewrite 含糊→反问 / 清晰→放行 / 失败开路(parsed=None→200 need_clarification=false,不 500) / 只读不写业务表但记 usage_ledger / 404。无产品 bug。**深度视觉/交互 QA(真起前端浏览)为人工验收事项** |
|
||||
| WFW-9 M2 AI 反问澄清(rewrite 侧,路线A 两阶段) | ✅ 后端 | @llm·@backend | WFW-8·WFW-9 M1 | 新#2 | `4a09f27`(llm)+`6fd6375`(后端)。镜像 M1:非流式预检 `POST .../rewrite/clarify`←`RewriteClarifyRequest{feedback(必给),prior_draft(可选)}`→`ClarifyDecision`(复用同一 schema,analyst,只读,失败回退放行)+`clarify_rewrite_spec`/教条 `clarify_rewrite.md`(segment→整章,SPECS#25);既有 `.../rewrite` SSE 未改。端点只喂 prior_draft 开头 3000 字摘录(不整章入 LLM)、日志脱敏。契约 C-Clarify(扩)。门禁绿:后端 ruff/format·mypy229·pytest948·alembic 无漂移。**前端仍欠 `pnpm gen:api`+M2 UI 接线(仿 RefinePanel 门控 ChapterRewritePanel) + M3 UX/E2E 待办** |
|
||||
| WFW-9 E2E(两阶段 AI 反问澄清,refine+rewrite) | ✅ | @qa | WFW-9 M1·M2 | 新#2 | `tests/test_wfw9_clarify_e2e.py` 7 用例真 pg + mock 网关(provider/adapter 层,同 M2/T6 范式)零 token:refine 含糊→反问1问 / refine 明确→放行、rewrite 含糊→反问 / rewrite 明确→放行、失败开放(网关产非 ClarifyDecision→仍200 need_clarification=false 不 500 且仍记账)、只读(seed chapter 字节一致+无 review/digest 行)但 usage_ledger 真落 analyst 一行、404 信封。响应绝不回灌原文摘录。无产品 bug。门禁绿:ruff/format·mypy 本文件净·pytest 955 passed |
|
||||
|
||||
---
|
||||
|
||||
## 已封板:T6 · 创作工具箱(通用生成器框架)✅
|
||||
|
||||
> **背景**:对标竞品「创作工具箱」——把每个「XX 生成器」实现为声明式 skill(`AgentSpec` 的设计本意),用一条**通用执行路径**驱动。用户已定:① 走**通用 skill 框架**(非逐个硬编码)② 封面/图像本期跳过 ③ 文本生成器全做(上架已有 3 + 新建 8)。完整设计见 `~/.claude/plans/creation-toolbox-generators.md`。
|
||||
> **P1 目标(DoD)**:`GeneratorTool` 描述符 + `TOOLBOX` 注册表 + 通用 `GET /skills/toolbox` & `POST /projects/{id}/skills/{tool_key}/generate` 端点 + **1 个最简生成器(脑洞)端到端打通**(mock 网关 E2E 零 token)。证实「加生成器 = 加一份声明」这条路。
|
||||
@@ -126,6 +208,26 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
|
||||
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
||||
|
||||
- [2026-07-10] @frontend — **✅ 写作台 UX 改造 P2-1..P2-3 全落地 → P0/P1/P2 整套 IA 改造完成**(`docs/design/ux-exploration.md` §4/§5,P2-4 按计划延后不做)。**P2-1 左栏分组**:`lib/nav/items.ts` 新增纯函数 `navSections(projectId?)`——把 10 个平铺项目项归为 **写作 / 资料库(大纲·设定库·伏笔·文风)/ 审阅(审稿·多章链)/ 更多(工具箱·规则·技能库)** 四组(全局组 作品库/模板库/设置 无标题恒在顶部),`NavItems.tsx` 复用现有「本作」小标题样式逐组渲染小标题(桌面侧栏/移动抽屉共用,链接/路由/激活键均不变,纯重排+加标题);工具箱在原分组清单缺列,收进「更多」以不丢入口(记 decisions)。**P2-2 目录归位(可折叠列)**:新增 `lib/workbench/useChapterListCollapse.ts`(`localStorage` 键 `ww.workbench_chapter_list_collapsed`,SSR 首帧恒展开避免 hydration 不一致、挂载后恢复、toggle 即时写回),`ChapterList` 收起时降为一条带「展开目录」按钮的窄轨(`2.75rem`)、展开时目录标题旁加「收起目录」按钮,`Workbench` 三栏栅格据折叠态在 `xl:grid-cols-[2.75rem_1fr_18rem]`↔`[12rem_1fr_18rem]` 间切换(章节列表/当前章高亮/切章导航全保留;窄屏抽屉不受影响、不带折叠按钮)。**P2-3 放置规则**:`memory/decisions.md` 落一条四象限唯一落点规则——导航→左栏、AI 动作→中央输入条+「或让 AI:」行、章节状态→底栏、参考/透明度→右栏「本章参考」,防三重复回潮。**P2-4 统一对话面板按计划延后**(§3/§4-P2-4/§6 明列为「先观察的大赌注、非开局」,本波不实现)。**TDD**:`navSections` +6 断言(分组标签/成员/全项恰好一次覆盖)、`useChapterListCollapse` +3(默认展开/toggle 持久化/挂载恢复)均 RED→GREEN。**门禁全绿**:lint/typecheck 干净 · vitest **716 passed**(710 基线 +6 新)· `test:coverage` 通过(`lib/**` 95.43% ≥ 80)· `build` OK。仅动 `apps/web/` + `memory/decisions.md` + `PROGRESS.md`。
|
||||
- [2026-07-10] @frontend — **✅ 写作台 UX 改造 P1-1..P1-5 全落地**(`docs/design/ux-exploration.md` §4,接 P0 新布局)。**P1-1 底栏瘦身 + act-on-chapter/导航分家**:把生成动词(续写/润色选段/整章重写/工具箱)从底栏搬进中央输入条正下方常驻的**「或让 AI:」次级动作行**(新 `AiVerbsRow`/`VerbButton`,读作「同一个 AI 的其它用法」,生成中禁用而非隐藏,触发沿用既有 `openContinue/openRefine/openRewrite/openToolbox`——仅搬家不改行为);删底栏纯导航「大纲」「伏笔」(左栏已有,连带下线 `BookOpen/Library` import);**唯一强调的「审稿」** 作为写→审→验收前进 CTA 保留(outline);`写本章`「发送键」上移到 `DirectivePanel` 表头**常驻可见**(折叠时也在,streaming 切「停」),底栏遂只剩 字数/生成中 · 保存 · 里程碑播报 · streaming 时的「停」(sticky 底栏够得着)+ 审稿 CTA + 速查/对话记录抽屉入口。**P1-2 统一「采用」动词族**:`ConversationThread.ACCEPT_LABEL`→采用(替换整章)/采用(加到章末)/采用(替换这段);`RefinePanel`「接受回填」+`ContinuePanel`「插入正文」+`ChapterRewritePanel`「接受最新版」同步;弃「回填」(含 Workbench 三处 toast 与两处面板描述)。**P1-3 右栏去术语(不降级,守 #6 透明度)**:`ChapterAssistant`「本章助手」→本章参考、「本章注入(透明)」→AI 写这章会参考、「四审」→写完后检查(白话描述,**删重复的「去审稿」快捷** + 去 `next/link` import)、空/加载/错误态与「已排除」及 pin/排除 aria 全部注入→参考白话;实体透明内容本身未动;移动抽屉 label 同改本章参考。**P1-4 四动词可见范围提示**(非 hover-only):写本章=整章·从头写(表头按钮内)、续写=接着往下写、整章重写=整章·基于现有重写、润色选段=选中段·打磨(「或让 AI:」行内 2xs 副标签)。**P1-5 题材去弹窗**:删中途弹出的 `genrePromptOpen`/`onPickGenre`,改为项目无题材时正文上方**常驻内联题材条**(复用 `GenrePicker`,`onPick=setChosenGenre` 不自动开写)→ 首次「写本章」顺滑流式不被打断;no-slop 守卫保留(未选题材点写本章给 toast 引导而非静默生成)。守 §3:五交互未并框、写本章仍流式进正文、右栏透明度未删。**纯表现层,无新增 `lib/**` 逻辑**(`useGenreGate`/`directive` 未动 → 无新 vitest)。**门禁全绿**:lint/typecheck 干净 · vitest **710 passed**(基线持平)· `test:coverage` 通过(`lib/**` 95.47% ≥ 80)· `build` OK。仅动 `apps/web/`。**决策**:速查/对话记录(两抽屉,flag 均 true)非导航亦非那 4 个生成动词,留在底栏作辅助入口(保全接线,符「底栏 essentially 只剩状态 + 审稿」)。**P2(左栏分组/目录归位/放置规则)待后续。**
|
||||
- [2026-07-10] @frontend — **✅ 写作台 UX 改造 P0-1..P0-5 全落地**(`docs/design/ux-exploration.md` §4,回答用户原话「我怎么和 AI 对话」)。**P0-1** 底栏「AI 对话」→「对话记录」(只读日志正名,让「对话」一词归还真输入框):`AiConversationDrawer` 标题/`aria-label`/关闭 aria/空态(→「这里留存你和 AI 的往复记录」)+ 底栏按钮文案/title 同改。**P0-2** 把折叠 `<details>`「本章写作指令[可选]」原地提升为正文正上方**常驻输入条**(`Workbench.tsx` 内联 `DirectivePanel`):标题「告诉 AI 这章想怎么写」、去「可选」徽标、空章默认展开(`defaultOpen={initialText 空}`,受控 `useState` 取代原生 details)、「AI 写作诉求」→「你的写作要求」、textarea 邀请式 placeholder,**发送键即「写本章」**(内联 primary 写本章 / streaming 时切「停」,复用既有 `onWrite`/`stop` + `composeDirective` + 文风/剧情 chips + 流式打字机,**未改成聊天气泡**)。**P0-3** 删顶部「AI 工具条」:`AppShell` 去 `AiToolbar` 渲染 + `--chrome` 恒 4rem(原 7rem,省 48px),`git rm` 死代码 `components/AiToolbar.tsx`+`AiToolbarMoreMenu.tsx`+`lib/nav/ai-tools.ts`+`ai-tools.test.ts`(全仓仅 AppShell 用,无其它 importer)。**P0-4** 底栏「润色选段」常驻可点(去 `disabled`/hover-only title),无选区点击弹 toast「先在正文里选中一段再润色」(`openRefine` 守卫 + `canRefine` 从 `Toolbar` props 下线,仅留 Workbench 内做面板渲染守卫)。**P0-5** 输入条旁常驻一行 HITL 微文案「AI 给草稿,你决定采不采用,原文永远你说了算」+ 可再发现的「?这是什么」弹层(`HitlHelp`,非一次性 coach,Esc/点击外部关闭)。守 §3:五种交互未并框、写本章未改聊天、右栏透明度未删、未用 Sparkles 作 AI 标记、润色未藏到有选区才现。**纯表现层改造,无新增 `lib/**` 逻辑**(`directive.ts` 未动 → 无新 vitest;删 `ai-tools.test.ts` 使基线 715→710)。**门禁全绿**:lint/typecheck 干净 · vitest **710 passed** · `test:coverage` 通过(`lib/**` 95.47% ≥ 80)· `build` OK。仅动 `apps/web/`。**P1(底栏瘦身/统一采用词/右栏去术语)+ P2(左栏分组/目录归位/放置规则)待后续。**
|
||||
- [2026-07-10] @frontend — **✅ AC-3 后续:润色/整章重写面板内联对话气泡流**(用户反馈:不想每次开「AI 对话」抽屉才看得到对话,要在对话时就地内联可见)。抽出复用组件 `components/workbench/ConversationThread.tsx`(把抽屉内联的 `Bubble`/`readOptions`/`ACCEPT_LABEL` 提取为共享气泡列表,抽屉与内联面板共用 → 外观一致 DRY);新纯助手 `lib/workbench/inlineConversation.ts`(`mergeInlineThreads` 把已落库留痕〔本章 + 指定 kinds〕与「进行中」实时气泡按 id 去重合并、复用 `groupByThread` 定序;`toPendingView` 合成未落库气泡〔`meta._pending/_streaming` UI 标记〕;`isPendingView/isStreamingView/readSegment`)+ **RED→GREEN 9 单测**。RefinePanel/ChapterRewritePanel 内联渲染 `role=log` 滚动气泡流(本章 refine/rewrite + 其 clarify 往复 **∪** 本轮进行中气泡:流式 token 逐字进气泡、缓冲的 clarify Q&A 亦实时可见),输入框置底像聊天;**pending→persisted 无重影**(进行中气泡结束即清、已落库经 `mergeById` 去重);**fail-soft**(历史加载失败仍显示本轮实时气泡,不阻塞回炉/重写);气泡「再接受/回填」复用既有 DRAFT-only HITL 回调(drift-safe,正文仍只在验收才变);clarify gating/abort/停/接受悉数保留。**抽屉保留原样**(底栏仍可开,跨章/项目级全量历史浏览)。**门禁全绿**:lint/typecheck 干净 · vitest **715 passed**(706 基线 + 9)· `test:coverage` 通过(`lib/**` 95.48% ≥ 80)· `build` OK。仅动 `apps/web/`。
|
||||
- [2026-07-09] @qa — **✅ AC-4 E2E + #1/#6 真源围栏守卫|AI 对话(聊天记录)AC-1..AC-4 全交付**(计划 §6/§8)。**E2E**(`tests/test_ai_chat_history_e2e.py`,真 pg、**零 LLM 无网关**——纯 CRUD 侧记录,镜像 `test_t6_toolbox_e2e.py` 的 `e2e_sm` 生命周期)覆盖 §6 全 6 用例:① 缓冲多轮线程一批落库(作者含糊→AI 反问〔选项进 meta〕→作者答→AI 定稿,同 `thread_id`/`seq` 0..3)→ GET newest-first(`seq`/`role`/`thread_id`/`meta{options,version_no,segment}` 往复保真、全文 content 未截断);② 作用域分区(章级 ∪ 项目级 `chapter_no IS NULL`——`?chapter_no=N` 两者都返回、缺省仅项目级、项目级现于任一章抽屉);③ 跨多批 newest-first + `limit/offset` 分页 + `kind` 过滤;④ **append 只写 `ai_messages`**(UsageLedger/Chapter/ChapterReview/WorldEntity/Job 逐表零变化、`usage_ledger` 恒 0 证无网关)、GET 只读不建行;⑤ 未知项目 POST/GET 皆 404、非法 role/kind·空 messages·超上限 content/meta 五类 422 且失败零落库;⑥ clarify-abandon 代理(服务端无部分写路径,只持久化被显式 POST 的已定线程)。**围栏**(`tests/test_ai_messages_not_in_generation_path.py`,AST 静态守卫,**能变红**):memory/orchestrator 两目录 23 文件无一 import/引用 `AiMessage*`/`ai_message_repo`(Name/Attribute/Import 全扫)+ `MemoryRepos` 捆绑与 `assemble` 形参都不含它 + 植入违规自证探测器非空——把 ai_message repo 接进 `assemble()` 会直接失败(已用真 assemble.py 源注入验证变红)。守 #1/#6:聊天记录是 append-only 旁路侧记录(同 `usage_ledger` 物种),绝不喂 prompt/记忆注入。**门禁全绿**:ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移** · pytest **1002 passed**(992 基线 + 10:E2E 6 + 围栏 4)。gotcha 已登记(ai_messages 真源围栏 = code-review BLOCKER)。仅动 `tests/` + `memory/gotchas.md` + `PROGRESS.md`,**未碰任何产品代码**。
|
||||
- [2026-07-09] @frontend — **✅ AC-3 gen:api + AI 对话 hook/抽屉 + 五类交互留痕**(计划 §4,`dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`)。**gen:api**(`dce882c`):`POST/GET /projects/{id}/ai-messages` + `AiMessage*` 4 schema 入 `schema.d.ts`(openapi.json 为 gitignore 构建产物),`lib/api/types.ts` re-export 码生成名。**核心**(`ddf7fa0`):`lib/workbench/aiConversation.ts` 纯视图模型(`kindLabel/kindBadge`、`bubbleSide`、`groupByThread` 按 `(created_at,seq)` 组内+线程定序〔**绝不单靠 created_at**——同批共享 created_at 靠 repo 的 seq 消歧〕、`partitionByScope`〔本章 ∪ 项目级〕、`acceptActionFor`〔rewrite→替换整章 / continue·generator→插入正文 / refine→回填选段 / clarify→无〕)+ 11 单测;`lib/workbench/useAiConversation.ts` Workbench 级单实例(懒加载去重 + `mergeById` 防并发覆盖 + 乐观插入/正位对账/失败回滚,**fail-soft 绝不 throw 进生成/HITL**〔守 #3 意图〕;`silent` 直投供无抽屉的工具箱页)+ 9 renderHook 测试;`components/workbench/AiConversationDrawer.tsx` 克隆 ContextDrawer 的 a11y(`role=dialog/log`、`aria-live` additions、Esc、focus trap、还原焦点、prefers-reduced-motion),本章对话 + 项目级生成两段气泡、clarify 只读选项芯片、ai 气泡挂 DRAFT-only 再接受。**hook 返回产物**(`59f2f60`):四生成 hook 加返回值(`RefineVersion|null`/`string|null`/`RewriteVersion|null`/`GeneratorArtifact|null`,向后兼容,补成功用例断言)。**五处接线**(`33a9b76`):RefinePanel/ChapterRewritePanel 结束后 append `[author(意见/答复), ai(产出)]`,**clarify buffer-until-concluded**——澄清 Q&A(原含糊意见→AI 反问〔选项进 meta〕→作者答复)缓冲,随最终产出批一起落库同 `thread_id`,**纯放弃不记**;refine ai 行 meta 存 `version_no`+原选段(每版重锚);ContinuePanel ai-only(续写无作者输入)meta `candidate_index`;GeneratorRunner append `[author(字段摘要,meta{tool_key,input_fields}), ai(渲染预览,meta{tool_key,output_kind})]`(空产物不记,`lib/toolbox/aiSummary.ts` 纯助手+5 单测),InlineToolbox 挂本章号、ToolboxPage silent 项目级(`chapter_no=null`);Workbench 挂 hook 单实例 + 抽屉 + 底栏「AI 对话」入口(`AI_CONVERSATION_ENABLED` 灰度)+ 三个再接受回调(成功关抽屉+toast、refine 漂移不盲替换)。**留痕永不改「是否/何时写正文」**——正文仍只在验收事务落库(守 #3)。**门禁全绿**:lint/typecheck 干净 · vitest **706 passed**(681 基线 + 25:aiConversation 11 + useAiConversation 9 + aiSummary 5)· `test:coverage` 通过(`lib/**` 95.44% ≥ 80,新纯模块/hook 全测)· `build` OK。→ 剩 **AC-4 @qa** E2E(计划 §6)+ P4 #1/#6 围栏。
|
||||
- [2026-07-09] @backend — **✅ AC-2 repo + append/list 端点 + schema + 契约**(计划 §3,`c675a74`)。repo `packages/core/ww_core/domain/ai_message_repo.py`(`AiMessageRepo`/`SqlAiMessageRepo`/`AiMessageView`/`AiTurnRow`,经 `ww_core.domain` 导出):`append`(一批多条 bubble → 批内 `seq`=enumerate 0 基位置、同 `thread_id`、meta 新 dict 不原地 mutate、`add_all`+一次 flush+refresh,**只 flush 不 commit**) + `list_for_chapter`(`created_at DESC, seq DESC` newest-first;`chapter_no` 非 None → 本章 ∪ 项目级 `IS NULL`,None → 仅项目级;可选 `kind`;`limit/offset`)。schemas `apps/api/ww_api/schemas/ai_messages.py`:批级 append 信封 `AiMessageAppendRequest{thread_id,chapter_no(ge=1)|null,kind,tool_key,messages}` + `AiMessageInput{role,content(min1,max200_000),meta(序列化≤50_000)}` + `AiMessageView` + `AiMessageListResponse{messages}`;`Literal` 为 kind/role 枚举权威(边界 422)。端点 `apps/api/ww_api/routers/ai_messages.py`(prefix `/projects`,tag `ai-messages`,已注册 main):**`POST /projects/{id}/ai-messages`**(201,项目 404,校验 422,append 只 flush + 端点 `commit()`) + **`GET /projects/{id}/ai-messages?chapter_no=&kind=`+`PageDep`**(200,newest-first,只读)。**这是显式记录写入**——五个生成端点逐字节不变、只读(守 #3);`ai_messages` 旁路侧记录,`assemble()`/agent 永不读(守 #1/#6)。注入缝 `get_ai_message_repo`。**TDD**:repo 真 pg 单测 9 passed(seq 0..n-1 / 同 thread_id / `created_at DESC,seq DESC` / union 含 NULL / 分页 / flush-only 跨 session 不可见);端点集成 12 passed(round-trip / 本章∪项目级 / kind 过滤 / 404×2 / 422×5〔非法 role/kind、空 messages、空 content、超上限 content、超上限 meta〕 / commit 边界 / 响应 key 集合不泄漏)。**门禁全绿**:ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移**(本任务不加模型)· `export_openapi` 成功(新端点+4 schema 已入表面)· pytest **992 passed**(971 基线 + 21)· 新模块 cov router 100%/schemas 100%/repo 97%(余 Protocol `...` 桩),全局 89%。契约 **C3 扩(AC-2)已登记「稳定」** → **@frontend AC-3 须 `cd apps/web && pnpm gen:api`** 后消费码生成名。
|
||||
- [2026-07-09] @db — **✅ AC-1 `ai_messages` 表 + 迁移**(计划 §2 canonical DDL)。`packages/db/ww_db/models.py` 加 `AiMessage(UuidPk, CreatedAt, Base)`(补 `Uuid` 导入):`project_id`(FK projects CASCADE, NOT NULL, indexed)/`chapter_no`(Integer nullable=项目级)/`thread_id`(Uuid NOT NULL)/`seq`(Integer NOT NULL, repo 批内 0 基定序)/`kind`(Text 无 CHECK)/`tool_key`(Text nullable)/`role`(Text + `ck_ai_messages_role` CHECK author|ai)/`content`(Text NOT NULL 全文)/`meta`(JSONB NOT NULL server_default `'{}'`)/`id`+`created_at`(mixin);append-only 无 updated_at。复合索引 `ix_ai_messages_project_chapter=(project_id,chapter_no,created_at DESC)` 手写 `sa.text` DESC + FK 单列索引。迁移 `ceabd3c2c00d`(down=`f6a7b8c9d0e1`,**autogenerate 仅捕获 ai_messages 无杂散漂移**,DESC 表达式归一为 `sa.text`)。**T0.2 MVP schema 后第一张新业务表**——对话历史是 18 表都无的能力、正交于 review/job/手稿,属真新增(非重复建表);是**旁路侧记录非手稿真源**,`assemble()`/agent 永不读(守 #1/#3/#6,围栏留 AC-4/P4 收口)。**门禁全绿**:ruff/format 干净 · mypy **233 文件** · `alembic upgrade head` + **`alembic check` 无漂移** + **downgrade→upgrade 往复干净** · pytest **971 passed**(基线不变,无代码用新表)。契约 **C2 扩(AC-1)已登记「稳定」** → AC-2 @backend 可动工。
|
||||
- [2026-07-08] @devops — **✅ QA-CR CR-D1 抬高后端依赖下界至 lockfile 主次版本(保可复现)**。8 个 pyproject(`apps/api` + `packages/{core,agents,config,db,llm_gateway,shared,skills}`)的后端运行时依赖下界从过旧值抬到实锁 MAJOR.MINOR:`langgraph>=1.2`/`langgraph-checkpoint-postgres>=3.1`/`openai>=2.43`/`anthropic>=0.111`/`google-genai>=2.9`/`instructor>=1.15`/`tenacity>=9.1`/`pydantic>=2.13`/`pydantic-settings>=2.14`/`structlog>=26.1`/`asyncpg>=0.31`/`alembic>=1.18`/`fastapi>=0.137`/`uvicorn>=0.49`/`cryptography>=49`(每条 ≤ lockfile 实锁,同小版本线的 sqlalchemy/psycopg 留原样)。`uv.lock` 变动为**纯元数据刷新**——仅各 workspace 成员 `[package.metadata].requires-dist` 的 specifier 字符串随下界更新,**零 pinned `version=` 变动、零 re-resolve、零第三方版本 bump**。root dev 工具下界(pytest/ruff/mypy/httpx)+ 前端 vitest 3.x/tailwind 4.x 升级评估**明确不纳入**(非运行时可复现关键,超范围)。**门禁**:`uv lock --check` 绿(元数据刷新后一致)· `uv run ruff check .` 干净 · pytest **971 passed**(与基线一致,无回归)。无源码/测试改动。
|
||||
- [2026-07-08] @llm — **✅ QA-CR MEDIUM/LOW 网关+链整改批(CR-M2/M3/L1@llm/M1.4,TDD 逐项提交)**。**完成 6**:`32451a9` pricing 未知 model 发 `pricing_unknown_model` warning(成本仍 0);`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status(不泄 vendor 响应体);`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 弃私有 `_client` 反手(CR-M1.4);`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning(CR-L1);`ec91c8f` 链 runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8,collect 一次落库)。**SKIP 2**:CR-M2 `is_open()` CQS(惰性半开转移是唯一转半开点、拆分漏发命令会致熔断永不复位,风险>收益);CR-M3 `run_overdue_scan` 幂等(`scan_overdue` 本已是原子 `UPDATE...RETURNING`+`NOT IN[CLOSED,OVERDUE]`+commit-only-if-changed,且下一章 current+1 扫描自愈漏标,再动会触碰验收事务边界)。**门禁全绿**:后端 ruff/format 干净 · mypy **232 文件** · pytest **971 passed**(+11 新测:pricing 2 / 错误脱敏 4 / probe 2 / 续写缺前文告警 2 / 并行审 1)· 无 DDL/无迁移。守不变量 #1/#3/#4(熔断/验收事务/add-only 记账 sink 未动)。
|
||||
- [2026-07-08] @llm/@backend/@devops/@frontend — **✅ QA-CR CRITICAL + HIGH 整改批全部落地**(14 项 + 4 低危收尾)。多 agent 工作流:先 14 个只读 agent 逐项**扎根真实代码**产精确修复+测试规格(纠出 3 处台账前提漂移),再按 owner 四簇**串行整合**入单工作树(避免并发写树 + repo 级 `ruff format .` 竞态),各 TDD 红→绿、逐项补回归测试、逐 fix 小步提交,末尾 14 个 skeptic **对抗式复核**(实证每条 test 非 vacuous,0 problem)。**关键落地/纠偏**:C1 next 15.1.3→**15.5.20**+react **19.2.7**(`pnpm audit` 清、`dependencyFloors.ts` 下界守卫,**CVE 号无法对真库核验+密钥轮换为用户运维事项**);C2 **前提证伪**(langgraph 默认上限 10007 非 25,无实活崩溃)仍加显式顶层 `recursion_limit`;H4 request_id **条件透传**(否则覆盖 contextvars 反退化);H2 修请求级 httpx 泄漏时**揪出 E2E 会误触真网**(override 跟随修);H9 全文本字段上限+2 MiB body 中间件 413 兜底(契约 C3,`gen:api` 无漂移);H8 `.env.example` 换不可用占位符(**真 key 仍在 git 历史,曾用环境须轮换**)。**门禁全绿**:后端 mypy 229/pytest **934**/alembic 无漂移;前端 vitest **669**/build/coverage 95.41%。契约 C1-扩(LlmRequest.request_id)+ C3(H9 caps)。**MEDIUM/LOW 择批后清理。** 提交 `cb8fddf`→`dd6d2fc`(19 提交)。
|
||||
|
||||
- [2026-07-08] @backend — **✅ QA-CR 后端整改 5 项(TDD,逐 fix 提交)**。**CR-C2**:`chain_runner.run_chain_job` 给 `graph.ainvoke` 传显式 top-level `recursion_limit=count*NODES_PER_CHAPTER+HEADROOM`(防御硬化,命名常量)。**CR-H1**:Kimi OAuth 设备轮询移出 DB 会话——新增 `_poll_for_token`(纯 HTTP)/`_make_persist_work`/`_run_kimi_oauth_poll`,仅最终持久化经 `run_job` 开短会话(防连接池饥饿;删 `_make_poll_work`)。**CR-H2**:请求级 httpx 客户端改**生成器依赖** `_http_client`(`async with ... yield`,请求结束 aclose 修泄漏);`_default_http_client` 保留供后台裸调;E2E + 端点测试 override 键跟随改 `_http_client`。**CR-H7**:冻结 `TOOLBOX` 为只读 `MappingProxyType`(`_TOOLBOX_BY_KEY` 私有底 dict + `Final[Mapping]`,仿 SPECS)。**CR-H9**:用户请求字符串字段补 `max_length`(providers/generation/projects/style/foreshadow/rules/templates,命名常量无魔数,样本列表长度+每条双上界)+ 新错误码 `ErrorCode.PAYLOAD_TOO_LARGE`(413) + 应用级 `body_size_limit_middleware`(2 MiB,按 Content-Length 拦截自建 413 信封),`main.py` 注册。**门禁全绿**:ruff/format 干净 · mypy **229 文件**(0 错)· pytest **928**(+23:recursion_limit 1 / poll-no-session 1 / http-gen 1 / toolbox-readonly 1 / input-size 19[17 参数化 + samples 1 + body413 1])· **无 DDL/无迁移**。记 contracts C3(CR-H9:新增 `maxLength` + `PAYLOAD_TOO_LARGE`)。→ **@frontend 须 `pnpm gen:api`**(请求字段现有 `maxLength`;本 cluster 未代跑)。守不变量 #3/#8/#9。
|
||||
|
||||
- [2026-07-06] @backend/@llm/@frontend — **✅ 灵感⑦ 群像按定位配比·缩减版 + §0 归属纠偏(develop 直提)**。角色群像生成支持「按定位配比」下发。**契约变更** `CharacterGenerateRequest` 加 `role_mix:dict[str,int]|None`——`model_validator(mode="after")` 定义与既有 `count` 的关系:在场时校验(非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12)并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count);缺省退回单一 `role`+`count`。**§0 归属纠偏**:`build_character_gen_context`+`run_character_gen`(`ww_core/orchestrator/generation_node.py`)确为 **@llm 域**——加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7 assemble 纯函数;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix`。`character-gen.md` 加「按配比严格分配 role、各定位之和=总数」纪律 → **regen `prompt_hashes.json`**(仅 character-gen 哈希变,**不进 SPECS→不 bump 计数**)。**前端** `cards.ts` 加 `sanitizeRoleMix`(去空/去重/clamp≥1/按 12 截断)+`roleMixTotal`+`buildCharacterGenerateRequest` 第 4 参 roleMix;`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**TDD**:schema 校验 7 例(总和≤12/空 dict/非正值拒绝)+ context 注入 2 例 + run 穿参 1 例 + 前端 cards 构造/clamp 5 例 + hook renderHook 1 例。**门禁全绿**:后端 ruff/format 干净 · mypy **222** · pytest **858**(+11)· 无迁移(未动模型,未跑 alembic);前端 gen:api/lint/typecheck 干净 · vitest **543**(+6)· coverage 95%(cards.ts 100%)· build OK。记 contracts C3。守不变量 #2/#6/#7/#9。**无 DB 迁移**。
|
||||
|
||||
- [2026-07-06] @llm/@db/@backend/@frontend — **✅ 灵感③ 人物塑造 advisory 审(develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**,D2);只做人物塑造、不做氛围(氛围噪声大折进 style/pace)。**新 SPEC** `characterization_spec`(`tier="analyst"` 不写 model,`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`;**计数三处 22→23**(`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring)+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`(**JSONB nullable**,迁移 `f6a7b8c9d0e1`,nullable 免 backfill)。**契约变更** `ReviewHistoryItem`+`ReviewView` 加 `characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`(normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`(advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**(+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**(+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse)· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧(motive/appearance 已入 CharacterCard 契约,作客观锚点)。
|
||||
|
||||
- [2026-07-06] @backend/@llm/@frontend — **✅ 灵感⑤ AI 立项方案生成+预填(develop 直提)**。向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。**新 SPEC** `project_plan_spec`(`tier="analyst"` 不写 model,`reads=()`/`writes=()` 纯预览,`prompts/project-plan.md`)+ schema `ProjectPlanResult`(书名候选 list + 时空背景/叙事结构/故事核心/结局设计/基调,**全字段默认值守解析韧性**)+ 注册 `SCHEMA_CATALOG["project-plan"]`;**计数三处 21→22**(`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring)+ **regen `prompt_hashes.json`**(仅加一条)。**新端点** `POST /skills/project-plan/generate`(**不带 project 前缀**绕开 toolbox 404)← `ProjectPlanGenerateRequest`(全字段 `max_length` CR-H9,`brief` 用 `str|None` 令 codegen 可选)→ `ProjectPlanView`;**种子门控**缺 genre/logline→422,无凭据→503。复用 `build_brief_context`+`run_generator`(`run_generator`/`_build_request` 的 `project_id` 放宽 `uuid.UUID|None`——向导无 project,ledger.project_id 本 nullable);新缝 `get_project_plan_gateway`(analyst)。**零迁移**。**前端** `wizard.ts` 加 `mapPlanResultToWizardForm`(书名/结构/基调→对应字段;时空背景/结局设计→折进 premise **不静默丢**;结局取向/视角预设枚举不臆造)+ `applyPlanPatch`(**仅回填空字段、保护已编辑**)+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hook(renderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。**门禁全绿**:后端 ruff/format 干净 · mypy **218** · pytest **814**(+新端点 6 + agents spec 8 测)· alembic 无漂移(零 ORM 变更);前端 gen:api/lint/typecheck 干净 · vitest **526**(+28)· coverage 95%(wizard/generation 100%)· build OK。记 contracts C3 新端点。守不变量 #2/#3/#9。依赖 ④(已交付)。
|
||||
- [2026-06-26] @orchestrator — **全项目 Code Review(七维度)完成 → `QA-CR` 整改批立项**。6 个专科 reviewer 并行(后端 FastAPI 架构 / Python 规范 / LangGraph 最佳实践 / 前端 React 架构 / 前端 TS 类型安全 / 应用安全)+ 依赖时效自查;212 py + 168 tsx 全覆盖。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**:**C1** Next.js 15.1.3 RCE(CVE-2025-66478「React2Shell」,App Router+RSC 受影响,升 15.1.9/15.5.7/16.x + 轮换密钥);**C2** 多章链缺 `recursion_limit`(默认 25 supersteps、每章 4 节点→超 ~6 章即崩,批量产卷不可用)。HIGH 含 Kimi OAuth 占 DB 会话 / httpx 泄漏 / Anthropic 计费记 0 / request_id 未贯通 / `.env.example` 提交可用 Fernet key / 文本输入无 `max_length` / 前端 errorCode 重复 / Drawer 焦点 / RefineView 未取消。**确认无虞**:SSRF(base_url 仅硬编码)/SQL 注入(全 ORM)/XSS(无 dangerouslySetInnerHTML)/错误信封不泄密/Kimi device-flow 无 CSRF。已登记 `QA-CR` 任务表(CR-C1/C2 + CR-H1..H12 + CR-M1..M5 + CR-L1 + CR-D1)按 owner 分派 @frontend/@backend/@llm/@devops,落地顺序见当前阶段 background。**本批为评审立项,尚无代码改动。**
|
||||
- [2026-06-23] @orchestrator — **🎉 多章工作流链(Chain Workflow)交付 + 多 agent 交叉评审 + CRITICAL 修复 + 合并 develop(merge `513bf71`)**。对标星月「一键多章」+ 差异化「每章过四审、遇冲突才停人」。设计契约 `docs/design/chain-workflow.md`(commit `5ee9799`)。**多 agent 逐学科建造**(契约先行、目录单写者):**C1 @llm**(`7f3eaab`) LangGraph **cyclic 链图** `orchestrator/chain/{state,nodes,graph}.py`(`build_chain_graph`:write→review→decide→accept 循环 + **interrupt-on-conflict** + resume;删死代码 `build_write_graph`,`write_node` 本体留用;7 单测 mock 网关+fake session+MemorySaver)·**C2 @backend**(`29349dc`) 服务/3 端点/schema/checkpointer 接线(见下条)·**C3 @db**(`548b7ab`) langgraph 检查点 4 表迁移 `d3e4f5a6b7c8`(`autocommit_block` 避开 `CREATE INDEX CONCURRENTLY` 与 alembic 事务死锁;`env.py` `include_object` 豁免这 4 表防漂移误报)·**C4 @qa**(`061792d`) E2E `tests/test_chain_workflow_e2e.py` 真 pg + mock 网关零 token(无冲突两章全自动 + 冲突→interrupt→awaiting→resume→accept)。**3 路对抗交叉评审**(python/fastapi/不变量)在**绿门禁下仍揪出 1 CRITICAL + 7 HIGH**——证伪"测试过=没问题":CRITICAL=链把**绑请求 session 的 gateway** 传进 BackgroundTask,后台跑时 session 已关→write/review 的 `usage_ledger.record()` 写进死 session **静默丢失**(链计费全丢,违 #1;E2E 清了 ledger 却没断言其存在故漏掉)。**修复**(`d1ea83b`):网关改 `chain_gateway_builder` 按节点 session 重建(仿 `digest_gateway_builder`/`style.py`);日志脱敏(`_classify_job_error`+`exc_type`,去 `str(exc)`,§5);resume 原子 claim `JobRepo.claim_awaiting_to_running`(防并发双跑同 thread_id,非 awaiting→409)+ 所有权校验(`JobView.project_id`,跨项目→404);`ChainResumeAccepted`(去 start/count=0 哨兵);删死导入 `extract_conflicts`;**补回归断言**(链 `usage_ledger` 必有行 + `chapter_reviews` 每章一行)。**出口(DoD)✅**:后端门禁绿 ruff/format 干净 · mypy **194 Success** · alembic **无漂移**(业务表零迁移,复用 `jobs.status=awaiting_input` + `result.awaiting_chapter`;唯一新 DDL=langgraph 检查点表)· pytest **608 passed**(+6 回归测)。首次真正启用 LangGraph checkpointer/interrupt。守不变量 #1/#3/#4/#5/#9。**剩余下一步(未做)**:① 前端链 UI(`pnpm gen:api` 纳入 run/resume + 发起页/进度轮询/awaiting 裁决续跑面板,复用 `ConflictAdjudication`);② §12 文档回写 `ARCHITECTURE.md §5.2`(补 cyclic 链图 + 更正现状:写章直连流式/单章 accept 为事务/链图首启 checkpointer);③ 未 push 远端。可选:续写/扩写节点入链;UGC 链市场需多租户(deferred)。
|
||||
- [2026-06-23] @backend — **✅ Chain Workflow C2(多章链 服务+端点+schema+checkpointer 接线,分支 `feat/chain-workflow`)**。承 C1(@llm)链图(`build_chain_graph` §3.3),落地服务/端点壳:① **3 端点**`routers/chain.py`(`POST .../chains/{key}/run`→202 `ChainRunAccepted`;`POST .../chains/runs/{job_id}/resume`→202;`GET /jobs/{id}` 复用)。校验:count 1..50→422、未知 chain_key→404、项目不存在→404、resume job 不存在→404、resume 非 awaiting→**409 新码 `CONFLICT`**、无凭据→503。② **schema**`schemas/chain.py`(`ChainRunRequest/ChainRunAccepted/ChainResumeRequest`,`ConflictDecision` 复用)。③ **服务**`services/chain_runner.py`(`run_chain_job` 仿 `run_job` 壳自建独立 session 驱动链图:set_running→ainvoke→据 `__interrupt__` 置 `awaiting_input` 或 `done`/`failed`;`build_accept_op` 在 apps/api 装配验收事务闭包注入图节点,守 #3/#4;token 不入 result/日志)+ `services/chain_deps.py`(`get_checkpointer_factory`:运行时 `AsyncPostgresSaver` 上下文 / 测试 MemorySaver)。④ **零迁移**(§7):复用 `jobs`,新增 `status="awaiting_input"` + `JobRepo.set_awaiting`,awaiting 章经 `result.awaiting_chapter`;新错误码 `ErrorCode.CONFLICT`(409)。⑤ **新网关缝**`project_deps.build_chain_gateway`/`get_chain_gateway`(按请求 tier writer/analyst/light 分派——单档网关恒返该档会错路由 review/digest,见 gotcha)+ `get_digest_gateway_builder`。**单测**`apps/api/tests/test_chain.py` 12 用例(mock 网关 + MemorySaver + fake session/accept_op,无 DB/无网络/无真 LLM):run→202、resume→202、未知 key 404、count 越界(>50/0) 422、resume 非 awaiting 409、项目/ job 不存在 404、run_chain_job 无冲突→done+written 满、冲突→awaiting→resume→done、错误脱敏、accept_op 冲突缺判→CONFLICT_UNRESOLVED。**全仓门禁绿**:ruff/format 干净、mypy **193 Success**、alembic **无漂移**(无 ORM 变更)、pytest **600 passed**。守不变量 #1/#3/#4/#5/#9。记 contracts C-Chain 稳定 + 2 decision + 3 gotcha。**唯一新增 DDL(langgraph 检查点表)= C3 迁移;前端链 UI = 契约稳定后 @frontend follow-up(本期非目标)。**
|
||||
- [2026-06-22] @orchestrator — **🎉 T6 创作工具箱(通用生成器框架)全工具箱交付 + 独立复核(分支 `feat/t6-creation-toolbox`)**。多 agent 并行波次:Wave 0(@llm T6.5 ‖ @backend 描述符类型 ‖ P2 收尾)→ Wave A(@backend T6.2+T6.3 TOOLBOX+端点合龙,契约稳定点)→ Wave B(@frontend T6.6 ‖ @qa T6.4)→ Wave C 收口,目录单写者零写冲突、契约先行、零返工。**@llm T6.5**:`ww_agents` +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/glossary/opening/fine-outline,只声明 tier #2)+ `build_outline_chapter_context`(with_outline_chapter 策略)。**@frontend T6.6**:`gen:api` 纳入 3 端点;`app/projects/[id]/toolbox/page.tsx`(RSC) + `components/toolbox/{ToolboxPage,ToolCard,GeneratorRunner}` + `lib/toolbox/*`(声明驱动纯函数,按 `input_fields` 渲染表单、按 `output_kind` 渲染预览、可入库者复用 `ConflictAdjudication` 走 409→ack)+ LeftNav「工具箱」+ ⌘K `nav-toolbox`/`action-gen-*`;legacy 3 跳现页不回归。**@qa T6.4**:`tests/test_t6_toolbox_e2e.py` 5 用例真 pg + mock 网关零 token,**未发现端点 bug**。**P2 收尾**:限流→`decisions.md` 记延后(单用户原型);noopener/Committable 经查本分支早已修;`provider_deps.py` 残留 `type:ignore` 为 SDK 私有属性探活、load-bearing 保留。**最终独立复跑全仓门禁绿**:后端 ruff/format 干净、mypy **195 Success**、alembic **无漂移**(descriptor 在代码、复用既有表,零建表迁移)、pytest **583 passed**;前端 gen:api/lint/tsc/vitest **279**/build OK。守不变量 #2(只声明 tier)/#3(预览不写库、入库经验收 gate)/#9(system_prompt 进缓存前块)。**"加生成器=加一份声明" 经实证(8 个新生成器复用同一通用执行器 + 同一前端组件)。** **实景 browse 复验(工具箱卡片栅格→脑洞预览→词条入库)待做。** 后续可选 P5:封面图像轨道 / 自定义 skill 运行期 schema / 收敛已有 3 竖井。
|
||||
@@ -201,3 +303,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
- [2026-06-20] @frontend — **UX R3 完成**(验收前清单 HITL gate,UX §7.4,纯前端无后端改,续 `feat/ux-r1-r2-review-ergonomics`)。新纯函数 `lib/review/accept-preview.ts`(`buildAcceptPreview`:据现有 client state 列「本次验收将更新」——晋升版次 + 提炼章节摘要 + 写回裁决(有冲突时) + 验收后伏笔到期扫描(可能置 OVERDUE) + 伏笔建议待登记提醒(有建议时))。**只列后端 accept 事务确实做的事**(核对 `projects.py` accept 端点:promote+digest+decisions+overdue 扫描;**不**声称改人物 latest_state/自动落库伏笔建议);带「以上为预期,最终以验收回执为准」免责。`AcceptPanel` 加 `foreshadowCount` prop + 未阻断时渲染清单,`ReviewReport` 传 `foreshadow.length`。TDD:4 测。前端门禁绿:lint/tsc/**vitest 205**(+4)/build。下一步(按 plan):Tier4(T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。
|
||||
- [2026-06-22] @backend — **✅ T6.2+T6.3 创作工具箱通用端点稳定**(通用生成器框架后端落地,分支 `feat/t6-creation-toolbox`)。新 `ww_skills.TOOLBOX`(11 条:legacy 3 + 新 8) + `get_tool`(`toolbox_registry.py`,descriptor 在代码无迁移)。3 新端点(`routers/toolbox.py`,已注册):`GET /skills/toolbox`(描述符列表,legacy 携 `legacy_route`:`codex?gen=world|character` / `/outline`)、`POST .../skills/{tool_key}/generate`(按 `spec.tier` 经新注入缝 `get_tier_gateway_builder` 建网关 → `run_generator` 结构化预览,**不写库**仅落 ledger;未知/legacy→404、项目不存在→404、无凭据→503)、`POST .../skills/{tool_key}/ingest`(仅 `ingest!=None` 工具;world_entities 走 **continuity 预检 409 gate**(`acknowledge_conflicts` 放行) + `partition_writes` 白名单 + schema→JSONB 写库,outline 细纲按 idx 拼 beats upsert;不可入库→422)。PURE context 派发 `services/toolbox_context.build_toolbox_context`(4 策略,缺章/大纲→空节拍不报错)。response/request schema `schemas/toolbox.py`(concrete 子模型,非裸 dict)。守不变量 #2/#3/#9。TDD:注册表 7 + context 8 + 端点 11 = **+26 测**。**全仓门禁绿**:ruff/format 干净、mypy **183 Success**(隔离 cache)、alembic **无漂移**(无新迁移)、pytest **578 passed**。OpenAPI 新增 3 端点 → **@frontend 须 `cd apps/web && pnpm gen:api`**(T6 前端前置)。记 contracts C3 扩(T6.2/T6.3)。
|
||||
- [2026-06-20] @frontend — **Tier4 完成**(T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop)。T4-a:新 `lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件,AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3,余 4rem),6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b(全链):后端 `assemble/_build_volatile` 加 `directive` 形参(「本章指令」段领衔 volatile,**绝不入 stable_core**,守缓存前缀不变量 #9),`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDD:ai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩(T4-b)。
|
||||
- [2026-06-28] @backend/@frontend — **UI Phase G 后端依赖补齐**:`ProjectResponse` 增加 `updated_at` / `pending_review_count`,`chapters` 加 `updated_at` 迁移 `8c1d2e3f4a5b`;`SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩,UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。
|
||||
- [2026-06-29] @frontend/@qa — **UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review;新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build;后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。
|
||||
- [2026-07-06] @frontend — **✅ 灵感⑥ 关系图谱(缩减版)+ D4 完成**(纯前端,无后端契约/迁移/gen:api)。新增 `lib/characters/graphLayout.ts`(纯逻辑:`roleStyle` role→分组配色关键词映射、`buildGraphNodes`/`buildGraphEdges`(kind→标签,`DEFAULT_RELATION_LABEL` 命名常量)、`countDanglingRelations`、`layoutGraph`(@dagrejs/dagre 一次性确定性初始坐标、中心→左上、不可变)、`buildRelationshipGraph` 端到端)——**仅用现有 `CharacterCardView.relations={name,kind,note}`**:边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造);悬空边(引用未入库角色)跳过并计数。新增 `components/characters/RelationshipGraph.tsx`(`'use client'`,React Flow @xyflow/react;CodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab,显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06)。TDD:graphLayout **+22 测**(role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿:lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%(graphLayout.ts 97.6%)** · **build OK**(codex 路由 7.58 kB,React Flow 懒加载独立 chunk)。注:`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500(stale chunk),非代码缺陷、dev watcher 下次重编自愈,未重启服务。
|
||||
- [2026-07-08] @frontend — **CR-M4/M5/L1 前端评审收尾(MEDIUM/LOW 打磨,仅 apps/web,无后端契约/gen:api)**。DONE:①a11y——`NavDrawer` 汉堡按钮补 `aria-controls`(新增 `Drawer` 可选 `id` prop 指向面板,默认 `nav-drawer`)、`ForeshadowCard` 的 `<dl>` 裸 `<div>` 行改真 `<dt>/<dd>` 结构(可视文案不变,importance 用 sr-only 标签);②`ReviewReport.flashMark` 的 `setTimeout` 句柄入 ref + 卸载 useEffect 清理(防定时器泄漏,提 `FLASH_DURATION_MS` 常量);③`useAccept` 手写 `ApiErrorEnvelope` 改由生成 schema 派生 `components["schemas"]["ErrorEnvelope"]`(端点专属 details 就地收窄,行为不变);④`applyFix.hasApplicableFix` 重写为对象入参**类型谓词** `patch is ApplicablePatch`,消除 `applyConflictFix` 里两处 `as string`(含收窄单测);⑤`server.parseJsonBody` 已具边界校验,补两条守卫分支测试(非对象体/非 JSON 体);⑥去冗余 cast:`stream/sse.ts` `idx.valueOf()`→`idx`、`jobs/job.ts` `dict["error"] as string`(typeof 守卫后 TS 自动收窄)。SKIP/NOOP(前提已过时/本就安全):`ProjectWizard` 换步聚焦标题已实现;`AppShell`/`LeftNav` 两处 `<nav>` 均已带 `aria-label`;`KNOWN_EVENTS` 加 `stop`——`stop` 是客户端 abort 动作非服务端 SSE 帧(对照 contracts.md 草稿 token/done/error、续审 section/conflict/foreshadow/pace/style/characterization/done/error 均无 stop),加入会为不存在的事件建处理;`CharacterGenerator`/`GeneratorRunner`/`ForeshadowCard.progress` 的 index-key——三处均为整表替换/追加式/静态展示列表(无逐项重排或删除,`CharacterCardItem`/`PreviewRow` 为无内部状态受控组件,选择/编辑均按 index 且随新预览整体重置),index 作 key 安全且无天然稳定 id,加合成 UUID 需重做 index 选择/编辑态属过度改造。门禁全绿:lint 干净 · typecheck 干净 · **vitest 681 passed**(+3)· **coverage lib 95.45%**(exit 0)· **build OK**。
|
||||
|
||||
160
README.md
Normal file
160
README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 网文创作工作流(Writer Work Flow)
|
||||
|
||||
> AI 辅助的**中文网文写作工作流**,以 Web 应用形态交付。核心理念:**把写长篇当软件工程做**——先立架构(世界观 / 角色 / 大纲),对着规格逐章生成,每章写完即测(多维审查),全程维护单一真相源(数据库)。
|
||||
|
||||
作者不再让 AI「一口气瞎写十万字」,而是像写代码一样:需求(立项)→ 架构(设定库)→ 建模(角色卡)→ 接口(大纲)→ 实现(逐章生成)→ 测试(写完即审)→ 集成(验收更新全局状态)→ 回归(全书一致性)。AI 是「灵感增幅器 + 质检员」,作者始终掌控创意。
|
||||
|
||||
---
|
||||
|
||||
## 功能总览
|
||||
|
||||
- **引导式立项** — 一句灵感走向连载:选题材 / 基调 / 结局走向 / 叙事视角,AI 起草世界观、主角、金手指、总纲方案,一键建库。
|
||||
- **设定库(Codex)** — 结构化的单一真相源:
|
||||
- 角色卡(外貌 / 动机 / 性格 / 背景 / 口癖 / 性格弧光 / 人物关系图)
|
||||
- 世界观(力量体系 / 势力 / 地理 / 词条)
|
||||
- **大纲 / 细纲** — AI 排分卷分章骨架,逐层细化到场景清单,并提示伏笔回收窗口。
|
||||
- **写章(SSE 流式)** — 注入设定 + 文风指纹,按 genre-aware 的网文写作教条(黄金三章、章末钩子、爽点密度)流式产出本章草稿。
|
||||
- **五审(写完即审)** — 一致性 / 伏笔 / 文风 / 节奏 四审 + 人物塑造(advisory 建议性)。审查器**只读**,不静默改稿。
|
||||
- **验收事务(HITL)** — 人在环中:作者裁决冲突后,唯一写入路径提交;未解决冲突阻断验收。章节摘要从**最终已验收文本**抽取,绝不污染真相源。
|
||||
- **创作工具箱(15 个生成器)** — 声明式 skill 框架,「加生成器 = 加一份声明」:脑洞 / 书名 / 简介 / 取名 / 金手指 / 词条 / 黄金开篇 / 细纲 / 续写 / 扩写 / 降 AI / 拆书,外加世界观 / 角色 / 大纲。
|
||||
- **模板库** — 复用与沉淀常用创作模板。
|
||||
- **多 Provider LLM 网关** — 路由 / 回退 / 熔断的自建薄网关;Agent 只声明能力 tier,网关按配置映射到 provider+model;支持 Kimi Code 订阅 OAuth(device flow)。
|
||||
- **沉浸式写作体验** — 纸感暖色 + 夜读双主题(`data-theme` 切换)、写作台专注模式(隐藏侧栏加宽正文)、`⌘K` 命令面板(跨页跳转 / 执行命令)、应用内帮助页 [`/help`](http://localhost:4000/help)(功能介绍 + 新手上手)。UI 对标编辑部式暖奶油设计语言,全程尊重 `prefers-reduced-motion` 与 WCAG AA。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈(已锁定)
|
||||
|
||||
| 层 | 选型 |
|
||||
|---|---|
|
||||
| 前端 | **Next.js + TypeScript**(仅 UI;经 OpenAPI 生成的 TS 客户端调后端,无手写共享类型) |
|
||||
| 后端 | **Python + FastAPI**(async,SSE) |
|
||||
| 编排 | **LangGraph**(写→审→验收 图,Postgres checkpointer 可恢复) |
|
||||
| LLM 访问 | 自建**薄网关**,覆盖 `anthropic` + `openai`(baseURL 兼容 DeepSeek/Kimi/Qwen/GLM)+ `google-genai` |
|
||||
| 结构化输出 | **Pydantic + instructor** |
|
||||
| ORM / 迁移 | **SQLAlchemy 2.0(async)+ Alembic** |
|
||||
| 存储 | **Postgres**(原型不启用 pgvector) |
|
||||
| 长任务 | FastAPI BackgroundTasks + `jobs` 表(原型不引入独立队列) |
|
||||
|
||||
工具链:**uv**(Python workspace,8 个成员)+ **pnpm**(前端)。Python 3.12+,Node 22。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
前置:Python 3.12+ · Node 22 · [uv](https://docs.astral.sh/uv/) · pnpm · Docker
|
||||
|
||||
> **本地端口(宿主已 +1000,防与其他项目冲突)**:前端 **http://localhost:4000** · 后端 **http://localhost:9000**(API 文档 `/docs`)· Postgres **localhost:6432**。容器内部端口不变(3000 / 8000 / 5432),只改宿主发布端口;要改回默认各减 1000 即可。接线:前端 `apps/web/.env.local` 的 `NEXT_PUBLIC_API_BASE=http://localhost:9000` 指向后端;后端 `.env` 的 `CORS_ORIGINS` 放行 `http://localhost:4000`、`DATABASE_URL` 指向 `localhost:6432`。
|
||||
|
||||
```bash
|
||||
# 1. 克隆后,安装后端依赖(仓库根,uv workspace)
|
||||
uv sync
|
||||
|
||||
# 2. 安装前端依赖
|
||||
cd apps/web && pnpm install && cd ../..
|
||||
|
||||
# 3. 配置环境变量(CREDENTIAL_ENC_KEY 为必填的凭据加密 Fernet key,启动即校验)
|
||||
cp .env.example .env
|
||||
# 生产请重新生成:
|
||||
# uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
|
||||
# 4. 起 Postgres 并跑迁移
|
||||
docker compose up -d pg
|
||||
uv run alembic upgrade head
|
||||
|
||||
# 5. 起后端 API(http://localhost:9000,文档 /docs)— 宿主端口 +1000
|
||||
uv run uvicorn ww_api.main:app --port 9000 --reload
|
||||
|
||||
# 6. 起前端(另开终端,http://localhost:4000)— pnpm dev 已内置 -p 4000
|
||||
cd apps/web && pnpm dev
|
||||
```
|
||||
|
||||
或一键起全套(pg + api + web,宿主端口均已 +1000):
|
||||
|
||||
```bash
|
||||
docker compose up # web → :4000 · api → :9000 · pg → :6432
|
||||
```
|
||||
|
||||
> **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。
|
||||
|
||||
---
|
||||
|
||||
## 工具链 & 门禁
|
||||
|
||||
改动声明「完成」前须全绿(TDD 先红后绿,覆盖率 ≥ 80%)。
|
||||
|
||||
**后端**(仓库根):
|
||||
|
||||
```bash
|
||||
uv run ruff check . # lint
|
||||
uv run ruff format . # format
|
||||
uv run mypy packages apps # 类型
|
||||
uv run pytest -q # 单元 + 集成(需 pg:docker compose up -d pg)
|
||||
uv run alembic check # 改过模型后校验无迁移漂移
|
||||
uv run pytest -q --cov --cov-report=term-missing --cov-fail-under=80 # 覆盖率门禁
|
||||
```
|
||||
|
||||
**前端**(`apps/web`):
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test # vitest(范围 lib/**:纯逻辑 + hooks)
|
||||
pnpm build
|
||||
pnpm test:coverage # 覆盖率门禁(阈值 80%)
|
||||
```
|
||||
|
||||
**改过后端 schema** 后重生成 TS 客户端:
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm gen:api
|
||||
```
|
||||
|
||||
CI 见 `.github/workflows/ci.yml`(backend job 带 pg service 跑 ruff/mypy/alembic/pytest;frontend job 跑 gen:api/lint/typecheck/test)。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
apps/
|
||||
api/ FastAPI 应用:路由 / service / 请求响应 schema / SSE 端点
|
||||
web/ Next.js 前端:Server Components 读视图 + Client 编辑器/流式交互
|
||||
packages/
|
||||
shared/ 跨栈 Pydantic 契约 schema(前端经 OpenAPI 消费)
|
||||
config/ 应用配置(Settings,环境变量边界)
|
||||
db/ SQLAlchemy 2.0 模型 / Alembic 迁移 / Repository
|
||||
llm_gateway/ 多 provider 网关:路由 / 回退 / 熔断 / adapter / 计费台账
|
||||
agents/ 声明式 AgentSpec + 外置 prompt(Agent 只声明能力 tier)
|
||||
core/ domain 领域逻辑 · memory 记忆真相源(确定性选择注入)· orchestrator LangGraph 图
|
||||
skills/ 工具箱注册表 + skill 注册(声明驱动的生成器)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文档地图
|
||||
|
||||
四份 spec 分层,每层细化上一层;实现暴露的规格缺口须回写对应 spec。
|
||||
|
||||
| 文档 | 回答什么 | 何时读 |
|
||||
|---|---|---|
|
||||
| [`PRODUCT_SPEC.md`](./PRODUCT_SPEC.md) | 是什么 & 为什么 —— 问题、功能(含 P0/P1/P2 优先级)、数据模型、端点、Agent | 理解范围或某功能 |
|
||||
| [`UX_SPEC.md`](./UX_SPEC.md) | 长什么样 —— 信息架构、用户流、页面线框、组件、视觉 token(纸感 / warm-cream 主题) | 做任何 UI |
|
||||
| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 怎么实现 —— 完整 DDL、网关内部、编排引擎、API 契约、横切关注点 | 实现后端 / 数据 / 网关 |
|
||||
| [`DEV_PLAN.md`](./DEV_PLAN.md) | 按什么顺序 —— Phase 0–5,每个任务标注学科 skill | 挑下一个任务 |
|
||||
| [`PROGRESS.md`](./PROGRESS.md) | 交付到哪了 —— 权威状态台账 + 变更日志 | 开工前必读 |
|
||||
|
||||
冲突裁决:更具体 / 更靠后的文档为准(`ARCHITECTURE` > `UX_SPEC`/`DEV_PLAN` > `PRODUCT_SPEC`)。协作与开发规范见 [`CLAUDE.md`](./CLAUDE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 测试与原型边界
|
||||
|
||||
- **测试**:TDD 强制(先写失败测试);三层——单元(一个节点/函数/Repository,mock IO)→ 集成(FastAPI 端点 + DB + mock 网关)→ E2E(写→审→验收,mock 网关)。**所有测试一律 mock LLM,绝不命中真实 LLM API**。覆盖率门禁 ≥ 80%。
|
||||
|
||||
原型作用域,以下**明确不做**(勿误用于生产):
|
||||
|
||||
- **单用户,无 auth / 多租户** —— `users` / `owner_id` 为占位桩。
|
||||
- **无向量检索 / pgvector** —— 记忆注入是确定性按需选择(显式点名 + 主要角色 + 最近 N 章),非向量搜索(向量检索列 P2)。
|
||||
- **无独立任务队列** —— 长任务走 FastAPI BackgroundTasks + `jobs` 表。
|
||||
- 不做模型微调、内容分发 / 发布平台对接、移动原生 App。
|
||||
@@ -4,6 +4,14 @@ WORKDIR /app
|
||||
COPY pyproject.toml uv.lock* ./
|
||||
COPY packages ./packages
|
||||
COPY apps/api ./apps/api
|
||||
# alembic.ini 在仓库根,随 build context 拷入镜像,供容器内 `alembic upgrade head`(迁移目录
|
||||
# packages/db/migrations 已随 `COPY packages` 进镜像;缺 alembic.ini 迁移会找不到配置而失败)。
|
||||
COPY alembic.ini ./
|
||||
RUN uv sync --no-dev --frozen || uv sync --no-dev
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# 存活探测:镜像无 curl,用 stdlib urllib 打 /health(非 200/连接失败会抛异常,退出非零)。
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
|
||||
# --no-sync:用构建期已烤好的 --no-dev venv 直接起,禁止启动时重新同步(否则 `uv run` 会在
|
||||
# 运行时按默认组重装依赖、连 ruff/mypy 等 dev 工具都现场下载,慢且需运行时联网、每次启动重来)。
|
||||
CMD ["uv", "run", "--no-sync", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -3,10 +3,10 @@ name = "ww-api"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.111",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"structlog>=24.1",
|
||||
"cryptography>=43",
|
||||
"fastapi>=0.137",
|
||||
"uvicorn[standard]>=0.49",
|
||||
"structlog>=26.1",
|
||||
"cryptography>=49",
|
||||
"ww-shared",
|
||||
"ww-config",
|
||||
"ww-db",
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -41,6 +42,7 @@ class FakeProjectRepo:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {}
|
||||
self.now = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
|
||||
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
|
||||
pid = uuid.uuid4()
|
||||
@@ -53,12 +55,21 @@ class FakeProjectRepo:
|
||||
theme=data.theme,
|
||||
selling_points=list(data.selling_points),
|
||||
structure=data.structure,
|
||||
tone=data.tone,
|
||||
ending_type=data.ending_type,
|
||||
narrative_pov=data.narrative_pov,
|
||||
updated_at=self.now,
|
||||
)
|
||||
self.rows[pid] = (owner_id, view)
|
||||
return view
|
||||
|
||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
|
||||
return [v for (o, v) in self.rows.values() if o == owner_id]
|
||||
async def list_for_owner(
|
||||
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
|
||||
) -> list[ProjectView]:
|
||||
views = [v for (o, v) in self.rows.values() if o == owner_id]
|
||||
if limit is None:
|
||||
return views
|
||||
return views[offset : offset + limit]
|
||||
|
||||
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
|
||||
entry = self.rows.get(project_id)
|
||||
@@ -145,6 +156,7 @@ class FakeReviewRepo:
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> ReviewView:
|
||||
view = ReviewView(
|
||||
@@ -156,6 +168,7 @@ class FakeReviewRepo:
|
||||
foreshadow_sug=[dict(s) for s in (foreshadow_sug or [])],
|
||||
style=style,
|
||||
pace=pace,
|
||||
characterization=characterization,
|
||||
health_score=health_score,
|
||||
)
|
||||
# 新→旧:插到列表头部。
|
||||
@@ -350,8 +363,14 @@ class FakeOutlineReadRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self.rows.get((project_id, chapter_no))
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
views = [v for (p, _), v in self.rows.items() if p == project_id]
|
||||
async def list_for_project(
|
||||
self, project_id: uuid.UUID, *, volume: int | None = None
|
||||
) -> list[OutlineView]:
|
||||
views = [
|
||||
v
|
||||
for (p, _), v in self.rows.items()
|
||||
if p == project_id and (volume is None or v.volume == volume)
|
||||
]
|
||||
return sorted(views, key=lambda v: v.chapter_no)
|
||||
|
||||
|
||||
|
||||
325
apps/api/tests/test_ai_messages.py
Normal file
325
apps/api/tests/test_ai_messages.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""AC-2 POST/GET /projects/{id}/ai-messages 端点(内存替身,无 DB/无网络)。
|
||||
|
||||
docs/design/ai-chat-history-plan.md §3.3 / §6。覆盖:
|
||||
- POST 201 + 回显持久化视图(seq/id/created_at)+ 端点 commit 边界;
|
||||
- append→list 回环;项目级(chapter_no null) vs 本章 union;
|
||||
- 校验 → 422(非法 role、超上限 content、空 messages、超大 meta);
|
||||
- 未知项目 → 404;响应 key 集合不泄漏意外字段。
|
||||
|
||||
owner_id 全程 stub(单用户原型)。AI 一律不触达(纯 CRUD)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeProjectRepo, FakeSession
|
||||
from ww_core.domain.ai_message_repo import AiMessageView, AiTurnRow
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
|
||||
# 期望的响应 bubble 字段集合(防泄漏意外字段)。
|
||||
_EXPECTED_KEYS = {
|
||||
"id",
|
||||
"project_id",
|
||||
"chapter_no",
|
||||
"thread_id",
|
||||
"seq",
|
||||
"kind",
|
||||
"tool_key",
|
||||
"role",
|
||||
"content",
|
||||
"meta",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
|
||||
class _FakeAiMessageRepo:
|
||||
"""镜像 `SqlAiMessageRepo` 语义的内存版(批内 seq、newest-first union)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[AiMessageView] = []
|
||||
self._clock = 0
|
||||
|
||||
async def append(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
thread_id: uuid.UUID,
|
||||
chapter_no: int | None,
|
||||
kind: str,
|
||||
tool_key: str | None,
|
||||
rows: list[AiTurnRow],
|
||||
) -> list[AiMessageView]:
|
||||
self._clock += 1
|
||||
created = datetime(2026, 7, 9, tzinfo=UTC) + timedelta(seconds=self._clock)
|
||||
out: list[AiMessageView] = []
|
||||
for i, r in enumerate(rows):
|
||||
view = AiMessageView(
|
||||
id=uuid.uuid4(),
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
thread_id=thread_id,
|
||||
seq=i,
|
||||
kind=kind,
|
||||
tool_key=tool_key,
|
||||
role=r.role,
|
||||
content=r.content,
|
||||
meta=dict(r.meta),
|
||||
created_at=created,
|
||||
)
|
||||
self.rows.append(view)
|
||||
out.append(view)
|
||||
return out
|
||||
|
||||
async def list_for_chapter(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int | None,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[AiMessageView]:
|
||||
matched = [r for r in self.rows if r.project_id == project_id]
|
||||
if chapter_no is not None:
|
||||
matched = [r for r in matched if r.chapter_no in (chapter_no, None)]
|
||||
else:
|
||||
matched = [r for r in matched if r.chapter_no is None]
|
||||
if kind is not None:
|
||||
matched = [r for r in matched if r.kind == kind]
|
||||
matched.sort(key=lambda r: (r.created_at, r.seq), reverse=True)
|
||||
return matched[offset : offset + limit]
|
||||
|
||||
|
||||
def _make_client() -> tuple[httpx.AsyncClient, _FakeAiMessageRepo, FakeProjectRepo, FakeSession]:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
|
||||
from ww_db import get_session
|
||||
|
||||
repo = _FakeAiMessageRepo()
|
||||
project_repo = FakeProjectRepo()
|
||||
session = FakeSession()
|
||||
|
||||
# 一个已存在的项目(owner = stub),供 404 对照——直接种入 fake 的行表(避开事件循环)。
|
||||
project_id = uuid.uuid4()
|
||||
project_repo.rows[project_id] = (STUB_OWNER_ID, ProjectView(id=project_id, title="AC-2"))
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_ai_message_repo] = lambda: repo
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
client.project_id = project_id # type: ignore[attr-defined]
|
||||
return client, repo, project_repo, session
|
||||
|
||||
|
||||
def _refine_batch(chapter_no: int | None = 5) -> dict[str, object]:
|
||||
return {
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"chapter_no": chapter_no,
|
||||
"kind": "refine",
|
||||
"messages": [
|
||||
{"role": "author", "content": "原选段"},
|
||||
{"role": "ai", "content": "重写段", "meta": {"version_no": 1, "segment": "原选段"}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_returns_201_persisted_views_and_commits() -> None:
|
||||
client, repo, _pr, session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
msgs = body["messages"]
|
||||
assert [m["seq"] for m in msgs] == [0, 1]
|
||||
assert [m["role"] for m in msgs] == ["author", "ai"]
|
||||
assert msgs[1]["meta"] == {"version_no": 1, "segment": "原选段"}
|
||||
assert all(set(m.keys()) == _EXPECTED_KEYS for m in msgs)
|
||||
assert all(m["id"] and m["created_at"] for m in msgs)
|
||||
assert session.commits == 1
|
||||
assert len(repo.rows) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_then_list_roundtrip() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||||
resp = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
|
||||
assert resp.status_code == 200
|
||||
msgs = resp.json()["messages"]
|
||||
# newest-first:批内 seq DESC → ai(seq1) 先于 author(seq0)。
|
||||
assert [m["content"] for m in msgs] == ["重写段", "原选段"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_chapter_union_includes_project_level() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||||
# 项目级工具箱生成(chapter_no 省略 → NULL)。
|
||||
await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "generator",
|
||||
"tool_key": "expand",
|
||||
"messages": [{"role": "ai", "content": "扩写预览"}],
|
||||
},
|
||||
)
|
||||
chapter_view = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
|
||||
toolbox_view = await client.get(f"/projects/{pid}/ai-messages")
|
||||
chapter_contents = {m["content"] for m in chapter_view.json()["messages"]}
|
||||
toolbox_contents = {m["content"] for m in toolbox_view.json()["messages"]}
|
||||
assert chapter_contents == {"原选段", "重写段", "扩写预览"} # 本章 ∪ 项目级
|
||||
assert toolbox_contents == {"扩写预览"} # 仅项目级
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_kind_filter() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(2))
|
||||
await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"chapter_no": 2,
|
||||
"kind": "rewrite",
|
||||
"messages": [{"role": "ai", "content": "整章新版"}],
|
||||
},
|
||||
)
|
||||
resp = await client.get(
|
||||
f"/projects/{pid}/ai-messages", params={"chapter_no": 2, "kind": "rewrite"}
|
||||
)
|
||||
msgs = resp.json()["messages"]
|
||||
assert [m["kind"] for m in msgs] == ["rewrite"]
|
||||
assert [m["content"] for m in msgs] == ["整章新版"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_unknown_project_returns_404() -> None:
|
||||
client, repo, _pr, session = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{uuid.uuid4()}/ai-messages", json=_refine_batch(5))
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert session.commits == 0
|
||||
assert repo.rows == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_unknown_project_returns_404() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{uuid.uuid4()}/ai-messages", params={"chapter_no": 1})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_invalid_role_returns_422() -> None:
|
||||
client, _repo, _pr, session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"chapter_no": 5,
|
||||
"kind": "refine",
|
||||
"messages": [{"role": "system", "content": "x"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_invalid_kind_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "translate", # 非五类枚举 → 422
|
||||
"messages": [{"role": "ai", "content": "x"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_empty_messages_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={"thread_id": str(uuid.uuid4()), "kind": "refine", "messages": []},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_empty_content_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "refine",
|
||||
"messages": [{"role": "ai", "content": ""}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_content_over_cap_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "rewrite",
|
||||
"messages": [{"role": "ai", "content": "x" * 200_001}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_oversize_meta_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "refine",
|
||||
"messages": [{"role": "ai", "content": "x", "meta": {"blob": "y" * 60_000}}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -115,6 +115,7 @@ class _FakeReviewRepo:
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
characterization: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> Any:
|
||||
rec = _FakeReviewRecord(chapter_no, list(conflicts))
|
||||
@@ -571,6 +572,66 @@ async def test_run_chain_job_no_conflict_completes_done(_patched_runner: None) -
|
||||
assert [a["chapter_no"] for a in fakes["accepted"]] == [1, 2]
|
||||
|
||||
|
||||
async def test_run_chain_job_sets_recursion_limit_scaled_to_count(
|
||||
_patched_runner: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""CR-C2:run_chain_job 给 graph.ainvoke 传显式 top-level recursion_limit(防御硬化)。
|
||||
|
||||
langgraph 默认上界已足够(无实时崩溃),但每链上界应显式 = count*NODES_PER_CHAPTER +
|
||||
HEADROOM。断言 config 键值 + 仍正常跑完(行为不变)。用 PROXY 包裹真 graph 记录 config
|
||||
(不 setattr 到可能冻结的 CompiledStateGraph 上)。
|
||||
"""
|
||||
from ww_core.orchestrator.chain import build_chain_graph as real_build
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _SpyGraph:
|
||||
def __init__(self, inner: Any) -> None:
|
||||
self._inner = inner
|
||||
|
||||
async def ainvoke(self, inp: Any, *, config: Any = None, **kw: Any) -> Any:
|
||||
captured["config"] = config
|
||||
return await self._inner.ainvoke(inp, config=config, **kw)
|
||||
|
||||
async def aget_state(self, config: Any, **kw: Any) -> Any:
|
||||
# runner 现经公开 aget_state 判 interrupt(CR-M3)——代理透传给真 graph。
|
||||
return await self._inner.aget_state(config, **kw)
|
||||
|
||||
def _spy_build(*args: Any, **kwargs: Any) -> Any:
|
||||
return _SpyGraph(real_build(*args, **kwargs))
|
||||
|
||||
monkeypatch.setattr("ww_api.services.chain_runner.build_chain_graph", _spy_build)
|
||||
|
||||
fakes = _chain_runner_fakes()
|
||||
gateway = _FakeChainGateway(conflicts=[])
|
||||
job_id = uuid.uuid4()
|
||||
saver = MemorySaver()
|
||||
|
||||
await run_chain_job(
|
||||
_session_factory,
|
||||
job_id,
|
||||
project_id=uuid.uuid4(),
|
||||
user_id=USER,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=1,
|
||||
count=7,
|
||||
chain_gateway_builder=_async_returning(gateway),
|
||||
checkpointer_ctx=lambda: _memsaver_ctx(saver),
|
||||
accept_op=fakes["accept_op"],
|
||||
chapter_repo_factory=fakes["chapter_repo_factory"],
|
||||
review_repo_factory=fakes["review_repo_factory"],
|
||||
)
|
||||
|
||||
# (1) 显式上界 = count*NODES_PER_CHAPTER + HEADROOM = 7*4+10 = 38。
|
||||
assert captured["config"]["recursion_limit"] == 7 * 4 + 10 == 38
|
||||
# (2) recursion_limit 是 top-level 键(不在 configurable 下——langgraph 只认 top-level)。
|
||||
assert "recursion_limit" not in captured["config"]["configurable"]
|
||||
# (3) 仍正常跑完(行为不变)。
|
||||
rec = _RecordingJobRepo.state[job_id]
|
||||
assert rec["status"] == STATUS_DONE
|
||||
assert rec["result"]["written"] == list(range(1, 8))
|
||||
|
||||
|
||||
async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
|
||||
_patched_runner: None,
|
||||
) -> None:
|
||||
|
||||
41
apps/api/tests/test_chain_deps.py
Normal file
41
apps/api/tests/test_chain_deps.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""chain_deps._libpq_conn_string 单测——剥离 SQLAlchemy 驱动后缀(回归)。
|
||||
|
||||
bug:runtime checkpointer 工厂曾把 `postgresql+psycopg://…`(settings 的 sync URL)
|
||||
原样喂给 `AsyncPostgresSaver.from_conn_string`,psycopg 不识别 `+psycopg` 方言段 →
|
||||
`ProgrammingError: missing "=" …` → 多章链 job 直接 failed。E2E 用 MemorySaver 覆盖,
|
||||
从不走真 conn string,故此路径无回归保护。本用例锁住「剥离后缀」语义。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_api.services import chain_deps
|
||||
|
||||
|
||||
class _StubSettings:
|
||||
def __init__(self, url: str) -> None:
|
||||
self.database_url_sync = url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
(
|
||||
"postgresql+psycopg://writer:writer@localhost:5432/writer",
|
||||
"postgresql://writer:writer@localhost:5432/writer",
|
||||
),
|
||||
("postgresql+asyncpg://u:p@h:5432/db", "postgresql://u:p@h:5432/db"),
|
||||
("postgresql://u:p@h:5432/db", "postgresql://u:p@h:5432/db"),
|
||||
],
|
||||
)
|
||||
def test_libpq_conn_string_strips_driver_suffix(
|
||||
monkeypatch: pytest.MonkeyPatch, raw: str, expected: str
|
||||
) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setattr(chain_deps, "get_settings", lambda: _StubSettings(raw))
|
||||
# Act
|
||||
result = chain_deps._libpq_conn_string()
|
||||
# Assert
|
||||
assert result == expected
|
||||
assert "+psycopg" not in result
|
||||
assert "+asyncpg" not in result
|
||||
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]
|
||||
@@ -28,9 +28,9 @@ from ww_agents import (
|
||||
WorldEntityCard,
|
||||
WorldGenResult,
|
||||
)
|
||||
from ww_core.domain.character_repo import CharacterWriteView
|
||||
from ww_core.domain.character_repo import CharacterWriteFields, CharacterWriteView
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.repositories import RuleView
|
||||
from ww_core.domain.rule_repo import RuleListItemView
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_skills import SkillRegistry
|
||||
@@ -75,32 +75,80 @@ class _FakeCharacterWriteRepo:
|
||||
name: str,
|
||||
role: str,
|
||||
traits: list[str],
|
||||
appearance: str,
|
||||
motive: str,
|
||||
backstory: str,
|
||||
arc: str,
|
||||
speech_tics: list[str],
|
||||
tags: list[Any],
|
||||
relations: list[dict[str, Any]],
|
||||
) -> CharacterWriteView:
|
||||
self.rows.append(
|
||||
# 幂等:按 (project_id, name) upsert——同名更新既有行而非插重复(镜像 Sql 实现)。
|
||||
existing = next(
|
||||
(r for r in self.rows if r["project_id"] == project_id and r["name"] == name),
|
||||
None,
|
||||
)
|
||||
if existing is not None:
|
||||
existing.update(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"name": name,
|
||||
"role": role,
|
||||
"traits": list(traits),
|
||||
"appearance": appearance,
|
||||
"motive": motive,
|
||||
"arc": arc,
|
||||
"speech_tics": list(speech_tics),
|
||||
"tags": list(tags),
|
||||
"relations": [dict(r) for r in relations],
|
||||
}
|
||||
)
|
||||
return CharacterWriteView(id=uuid.uuid4(), name=name, role=role)
|
||||
return CharacterWriteView(id=existing["id"], name=name, role=role)
|
||||
row_id = uuid.uuid4()
|
||||
self.rows.append(
|
||||
{
|
||||
"id": row_id,
|
||||
"project_id": project_id,
|
||||
"name": name,
|
||||
"role": role,
|
||||
"traits": list(traits),
|
||||
"appearance": appearance,
|
||||
"motive": motive,
|
||||
"arc": arc,
|
||||
"speech_tics": list(speech_tics),
|
||||
"tags": list(tags),
|
||||
"relations": [dict(r) for r in relations],
|
||||
}
|
||||
)
|
||||
return CharacterWriteView(id=row_id, name=name, role=role)
|
||||
|
||||
async def create_many(
|
||||
self, project_id: uuid.UUID, cards: list[CharacterWriteFields]
|
||||
) -> list[CharacterWriteView]:
|
||||
# 镜像 Sql:逐卡 upsert(含批内重名去重),保序返回逐卡 View。
|
||||
return [
|
||||
await self.create(
|
||||
project_id,
|
||||
name=c.name,
|
||||
role=c.role,
|
||||
traits=list(c.traits),
|
||||
appearance=c.appearance,
|
||||
motive=c.motive,
|
||||
backstory=c.backstory,
|
||||
arc=c.arc,
|
||||
speech_tics=list(c.speech_tics),
|
||||
tags=list(c.tags),
|
||||
relations=[dict(r) for r in c.relations],
|
||||
)
|
||||
for c in cards
|
||||
]
|
||||
|
||||
|
||||
class _FakeRulesReadRepo:
|
||||
def __init__(self, rules: list[RuleView] | None = None) -> None:
|
||||
"""实现规则 repo 的读侧 `list_for_project`(带 id,供 list_rules 端点)。"""
|
||||
|
||||
def __init__(self, rules: list[RuleListItemView] | None = None) -> None:
|
||||
self._rules = rules or []
|
||||
|
||||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
|
||||
return list(self._rules)
|
||||
|
||||
|
||||
@@ -170,7 +218,7 @@ def _make_app(
|
||||
get_memory_repos,
|
||||
get_precheck_gateway,
|
||||
get_project_repo,
|
||||
get_rules_read_repo,
|
||||
get_rule_write_repo,
|
||||
get_skill_registry,
|
||||
get_worldbuilder_gateway,
|
||||
)
|
||||
@@ -190,7 +238,7 @@ def _make_app(
|
||||
(lambda: memory) if memory is not None else _empty_memory_repos
|
||||
)
|
||||
app.dependency_overrides[get_character_write_repo] = lambda: char_repo
|
||||
app.dependency_overrides[get_rules_read_repo] = lambda: rules_repo
|
||||
app.dependency_overrides[get_rule_write_repo] = lambda: rules_repo
|
||||
app.dependency_overrides[get_skill_registry] = lambda: registry
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
gw = _raise_no_creds if no_creds else (lambda: gateway)
|
||||
@@ -210,6 +258,16 @@ def _client(app: Any) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_rules_unknown_project_returns_404() -> None:
|
||||
# QA MEDIUM 回归:GET 规则列表对不存在 project 返 404(原返误导性空 200,掩盖坏 id)。
|
||||
app, _ = _make_app(project_repo=FakeProjectRepo(), gateway=object())
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{uuid.uuid4()}/rules")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
# ---- 世界观生成 ----
|
||||
|
||||
|
||||
@@ -315,6 +373,39 @@ async def test_ingest_characters_no_conflict_writes_and_201() -> None:
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_multiple_characters_all_land_in_one_batch() -> None:
|
||||
# 批量入库:多张卡一次 create_many 全部落库,created 保序,单次 commit。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
def _card(name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"role": "配角",
|
||||
"traits": [],
|
||||
"backstory": "x",
|
||||
"arc": "y",
|
||||
"speech_tics": [],
|
||||
"tags": [],
|
||||
"relations": [],
|
||||
}
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters",
|
||||
json={"cards": [_card("甲"), _card("乙"), _card("丙")]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["created"] == ["甲", "乙", "丙"]
|
||||
assert [r["name"] for r in char_repo.rows] == ["甲", "乙", "丙"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_characters_with_conflict_blocks_409() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
@@ -360,6 +451,174 @@ async def test_ingest_characters_acknowledged_conflict_writes() -> None:
|
||||
assert len(char_repo.rows) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_same_name_twice_updates_not_duplicates() -> None:
|
||||
# Arrange:同一项目、同名角色入库两次,第二次字段不同。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
def _payload(role: str, trait: str) -> dict[str, Any]:
|
||||
return {
|
||||
"cards": [
|
||||
{
|
||||
"name": "叶寒",
|
||||
"role": role,
|
||||
"traits": [trait],
|
||||
"backstory": "孤儿",
|
||||
"arc": "成长",
|
||||
"speech_tics": [],
|
||||
"tags": [],
|
||||
"relations": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async with _client(app) as client:
|
||||
# Act:先入主角/腹黑,再以同名入对手/隐忍。
|
||||
first = await client.post(f"/projects/{pid}/characters", json=_payload("主角", "腹黑"))
|
||||
second = await client.post(f"/projects/{pid}/characters", json=_payload("对手", "隐忍"))
|
||||
|
||||
# Assert:仍是一行(按 name upsert),字段被更新而非新增重复。
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 201
|
||||
assert len(char_repo.rows) == 1
|
||||
assert char_repo.rows[0]["role"] == "对手"
|
||||
assert char_repo.rows[0]["traits"] == ["隐忍"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_relations_persist_on_write() -> None:
|
||||
# Arrange:入库带关系网的角色卡。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
relations = [{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}]
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters",
|
||||
json={
|
||||
"cards": [
|
||||
{
|
||||
"name": "叶寒",
|
||||
"role": "主角",
|
||||
"traits": ["腹黑"],
|
||||
"backstory": "孤儿",
|
||||
"arc": "成长",
|
||||
"speech_tics": [],
|
||||
"tags": [],
|
||||
"relations": relations,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# Assert:relations 持久化(写侧未丢)。
|
||||
assert resp.status_code == 201
|
||||
assert char_repo.rows[0]["relations"] == relations
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_persists_appearance_and_motive() -> None:
|
||||
# ⑧ 穿线:入库端点须把 appearance/motive 透传给写侧 repo(否则新列永远 NULL)。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters",
|
||||
json={
|
||||
"cards": [
|
||||
{
|
||||
"name": "苏璃",
|
||||
"role": "CP",
|
||||
"traits": ["清冷毒舌"],
|
||||
"appearance": "银发赤瞳,玄色劲装",
|
||||
"motive": "替亡兄查清族灭真相",
|
||||
"backstory": "玄霜阁遗孤",
|
||||
"arc": "由敌转盟",
|
||||
"speech_tics": [],
|
||||
"tags": [],
|
||||
"relations": [],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert char_repo.rows[0]["appearance"] == "银发赤瞳,玄色劲装"
|
||||
assert char_repo.rows[0]["motive"] == "替亡兄查清族灭真相"
|
||||
|
||||
|
||||
def test_card_to_view_roundtrips_new_fields() -> None:
|
||||
# ⑧ 穿线:schema↔view 双向形变须带上 appearance/motive(否则前后端静默丢字段)。
|
||||
from ww_api.routers.generation import _card_to_view, _view_to_card
|
||||
|
||||
card = CharacterCard(
|
||||
name="苏璃",
|
||||
role="CP",
|
||||
traits=["清冷毒舌"],
|
||||
appearance="银发赤瞳,玄色劲装",
|
||||
motive="替亡兄查清族灭真相",
|
||||
backstory="玄霜阁遗孤",
|
||||
arc="由敌转盟",
|
||||
)
|
||||
view = _card_to_view(card)
|
||||
assert view.appearance == "银发赤瞳,玄色劲装"
|
||||
assert view.motive == "替亡兄查清族灭真相"
|
||||
back = _view_to_card(view)
|
||||
assert back.appearance == card.appearance
|
||||
assert back.motive == card.motive
|
||||
|
||||
|
||||
def test_relations_from_jsonb_parses_and_skips_dirty() -> None:
|
||||
# Arrange:混入脏条目(非 dict / 缺 name / 缺 kind)。
|
||||
from ww_api.routers.generation import _relations_from_jsonb
|
||||
|
||||
raw = [
|
||||
{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"},
|
||||
{"name": "无类型"}, # 缺 kind → 跳过
|
||||
{"kind": "无名"}, # 缺 name → 跳过
|
||||
"not-a-dict", # 非 dict → 跳过
|
||||
{"name": "墨白", "kind": "师徒"}, # note 可缺
|
||||
]
|
||||
# Act
|
||||
out = _relations_from_jsonb(raw)
|
||||
# Assert:只保留有效两条,note 缺为 None。
|
||||
assert [(r.name, r.kind, r.note) for r in out] == [
|
||||
("苏离", "宿敌", "灭门之仇"),
|
||||
("墨白", "师徒", None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_characters_includes_relations() -> None:
|
||||
# Arrange:读侧 memory view 带 relations(JSONB list)。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, _ = _make_app(
|
||||
project_repo=repo,
|
||||
gateway=_SchemaRoutingGateway({}),
|
||||
memory=_codex_memory_with_relations(),
|
||||
)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{pid}/characters")
|
||||
|
||||
# Assert:读端点不再丢 relations(修 #7)。
|
||||
assert resp.status_code == 200
|
||||
card = resp.json()["characters"][0]
|
||||
assert card["relations"] == [{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}]
|
||||
|
||||
|
||||
# ---- 读端点 ----
|
||||
|
||||
|
||||
@@ -367,10 +626,11 @@ async def test_ingest_characters_acknowledged_conflict_writes() -> None:
|
||||
async def test_list_rules_returns_rules() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
rid1, rid2 = uuid.uuid4(), uuid.uuid4()
|
||||
rules_repo = _FakeRulesReadRepo(
|
||||
[
|
||||
RuleView(level="project", content="主角不复活"),
|
||||
RuleView(level="global", content="无脏话"),
|
||||
RuleListItemView(id=rid1, level="project", content="主角不复活"),
|
||||
RuleListItemView(id=rid2, level="global", content="无脏话"),
|
||||
]
|
||||
)
|
||||
gateway = _SchemaRoutingGateway({})
|
||||
@@ -382,6 +642,8 @@ async def test_list_rules_returns_rules() -> None:
|
||||
assert resp.status_code == 200
|
||||
rules = resp.json()["rules"]
|
||||
assert [r["content"] for r in rules] == ["主角不复活", "无脏话"]
|
||||
# 列表带稳定 id(前端删除 handle)。
|
||||
assert [r["id"] for r in rules] == [str(rid1), str(rid2)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -426,6 +688,8 @@ def _codex_memory() -> Any:
|
||||
name="叶寒",
|
||||
role="主角",
|
||||
traits={"items": ["腹黑", "护短"]},
|
||||
appearance="剑眉星目,一袭青衫",
|
||||
motive="为母复仇、登临帝位",
|
||||
backstory="孤儿出身",
|
||||
arc={"text": "从孤儿到帝王"},
|
||||
speech_tics={"items": ["哼"]},
|
||||
@@ -456,6 +720,53 @@ def _codex_memory() -> Any:
|
||||
)
|
||||
|
||||
|
||||
def _codex_memory_with_relations() -> Any:
|
||||
"""MemoryRepos:角色行携 relations(JSONB list),验读端点还原关系网(修 #7)。"""
|
||||
from test_projects import (
|
||||
_EmptyDigestRepo,
|
||||
_EmptyForeshadowRepo,
|
||||
_EmptyOutlineRepo,
|
||||
_EmptyRulesRepo,
|
||||
_EmptyStyleRepo,
|
||||
_StubProjectSpecRepo,
|
||||
)
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
MemoryRepos,
|
||||
WorldEntityView,
|
||||
)
|
||||
|
||||
class _CharRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||||
return [
|
||||
CharacterView(
|
||||
name="叶寒",
|
||||
role="主角",
|
||||
traits={"items": ["腹黑"]},
|
||||
backstory="孤儿出身",
|
||||
arc={"text": "成长"},
|
||||
speech_tics={"items": []},
|
||||
tags=[],
|
||||
relations=[{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}],
|
||||
)
|
||||
]
|
||||
|
||||
class _WorldRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
|
||||
return []
|
||||
|
||||
return MemoryRepos(
|
||||
outline=_EmptyOutlineRepo(),
|
||||
character=_CharRepo(),
|
||||
world_entity=_WorldRepo(),
|
||||
digest=_EmptyDigestRepo(),
|
||||
foreshadow=_EmptyForeshadowRepo(),
|
||||
style=_EmptyStyleRepo(),
|
||||
rules=_EmptyRulesRepo(),
|
||||
project=_StubProjectSpecRepo(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
@@ -475,6 +786,9 @@ async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
|
||||
assert card["speech_tics"] == ["哼"]
|
||||
assert card["arc"] == "从孤儿到帝王"
|
||||
assert card["tags"] == ["天才"]
|
||||
# ⑧ 穿线:读端点暴露 appearance/motive(Text 列直落,不形变)。
|
||||
assert card["appearance"] == "剑眉星目,一袭青衫"
|
||||
assert card["motive"] == "为母复仇、登临帝位"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
51
apps/api/tests/test_generation_schema.py
Normal file
51
apps/api/tests/test_generation_schema.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""CharacterGenerateRequest.role_mix 校验单测(⑦ 群像按定位配比)。
|
||||
|
||||
纯 pydantic 校验,无 DB/网关。契约:role_mix 在场时 count 取各值之和,
|
||||
每项数量须 ≥ 1、总和须 ≤ MAX_GENERATE_COUNT。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_api.schemas.generation import MAX_GENERATE_COUNT, CharacterGenerateRequest
|
||||
|
||||
|
||||
def test_role_mix_absent_keeps_explicit_count() -> None:
|
||||
req = CharacterGenerateRequest(brief="剑修", count=3)
|
||||
assert req.role_mix is None
|
||||
assert req.count == 3
|
||||
|
||||
|
||||
def test_role_mix_sets_count_to_sum() -> None:
|
||||
req = CharacterGenerateRequest(brief="群像", role_mix={"主角": 1, "对手": 2, "配角": 3})
|
||||
assert req.count == 6
|
||||
|
||||
|
||||
def test_role_mix_overrides_provided_count() -> None:
|
||||
# role_mix 在场时 count 取各值之和,忽略客户端显式传入的 count
|
||||
req = CharacterGenerateRequest(brief="群像", count=1, role_mix={"主角": 2, "对手": 2})
|
||||
assert req.count == 4
|
||||
|
||||
|
||||
def test_role_mix_total_at_max_allowed() -> None:
|
||||
req = CharacterGenerateRequest(brief="群像", role_mix={"主角": MAX_GENERATE_COUNT})
|
||||
assert req.count == MAX_GENERATE_COUNT
|
||||
|
||||
|
||||
def test_role_mix_sum_over_max_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
# 6 + 7 = 13 > 12
|
||||
CharacterGenerateRequest(brief="群像", role_mix={"主角": 6, "对手": 7})
|
||||
|
||||
|
||||
def test_role_mix_empty_dict_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
CharacterGenerateRequest(brief="群像", role_mix={})
|
||||
|
||||
|
||||
def test_role_mix_nonpositive_value_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
CharacterGenerateRequest(brief="群像", role_mix={"主角": 0})
|
||||
with pytest.raises(ValidationError):
|
||||
CharacterGenerateRequest(brief="群像", role_mix={"主角": -1})
|
||||
@@ -38,8 +38,11 @@ class _OutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self._rows.get(chapter_no)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
return [self._rows[k] for k in sorted(self._rows)]
|
||||
async def list_for_project(
|
||||
self, project_id: uuid.UUID, *, volume: int | None = None
|
||||
) -> list[OutlineView]:
|
||||
rows = [self._rows[k] for k in sorted(self._rows)]
|
||||
return [v for v in rows if volume is None or v.volume == volume]
|
||||
|
||||
|
||||
class _CharRepo:
|
||||
|
||||
112
apps/api/tests/test_input_size_limits.py
Normal file
112
apps/api/tests/test_input_size_limits.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""CR-H9:用户请求文本字段 max_length 上界 + 应用级请求体大小中间件(防 DoS/成本)。
|
||||
|
||||
- 单元:各请求 schema 的字符串字段在 cap 处可构造、cap+1 触发 ValidationError(含样本列表长度
|
||||
与列表项两处上界——最大 DoS 面)。
|
||||
- 集成(无 pg):超大请求体经中间件直接 413(不入路由/DB)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
# create_app() 于 import ww_api.main 时构建,需 CREDENTIAL_ENC_KEY——先于任何 main 导入置好。
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
|
||||
from ww_api.schemas.foreshadow import ForeshadowRegisterRequest
|
||||
from ww_api.schemas.generation import CharacterGenerateRequest, WorldGenerateRequest
|
||||
from ww_api.schemas.projects import (
|
||||
AcceptRequest,
|
||||
DraftSaveRequest,
|
||||
DraftStreamRequest,
|
||||
ProjectCreateRequest,
|
||||
ReviewRequest,
|
||||
)
|
||||
from ww_api.schemas.providers import (
|
||||
ProviderCredentialInput,
|
||||
TierRoutingInput,
|
||||
)
|
||||
from ww_api.schemas.providers import (
|
||||
TestConnectionRequest as _TestConnectionRequest, # 别名避免 pytest 误采集 Test* 类
|
||||
)
|
||||
from ww_api.schemas.rules import RuleCreateRequest
|
||||
from ww_api.schemas.style import RefineRequest, StyleLearnRequest
|
||||
from ww_api.schemas.templates import TemplateCreateRequest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StrCase:
|
||||
"""一个字符串字段的上界用例:cap 处可构造、cap+1 触发 ValidationError。"""
|
||||
|
||||
schema: type[BaseModel]
|
||||
field_name: str
|
||||
cap: int
|
||||
case_id: str
|
||||
base: dict[str, Any] = field(default_factory=dict) # 该 schema 其余必填字段
|
||||
|
||||
|
||||
_STR_CASES: list[_StrCase] = [
|
||||
_StrCase(ProviderCredentialInput, "api_key", 512, "api_key<=512", {"provider": "p"}),
|
||||
_StrCase(ProviderCredentialInput, "provider", 64, "provider<=64", {"api_key": "k"}),
|
||||
_StrCase(TierRoutingInput, "model", 128, "model<=128", {"tier": "writer", "provider": "p"}),
|
||||
_StrCase(
|
||||
TierRoutingInput, "provider", 64, "routing-provider<=64", {"tier": "writer", "model": "m"}
|
||||
),
|
||||
_StrCase(_TestConnectionRequest, "provider", 64, "test-provider<=64"),
|
||||
_StrCase(WorldGenerateRequest, "brief", 10_000, "world-brief<=10000"),
|
||||
_StrCase(CharacterGenerateRequest, "brief", 10_000, "char-brief<=10000"),
|
||||
_StrCase(ProjectCreateRequest, "title", 200, "title<=200"),
|
||||
_StrCase(AcceptRequest, "final_text", 200_000, "final_text<=200000"),
|
||||
_StrCase(DraftSaveRequest, "text", 200_000, "draft-text<=200000"),
|
||||
_StrCase(ReviewRequest, "draft", 200_000, "review-draft<=200000"),
|
||||
_StrCase(RefineRequest, "segment", 20_000, "segment<=20000"),
|
||||
_StrCase(RefineRequest, "instruction", 2_000, "refine-instruction<=2000", {"segment": "s"}),
|
||||
_StrCase(DraftStreamRequest, "directive", 20_000, "directive<=20000"),
|
||||
_StrCase(ForeshadowRegisterRequest, "code", 100, "code<=100", {"title": "t"}),
|
||||
_StrCase(ForeshadowRegisterRequest, "title", 500, "foreshadow-title<=500", {"code": "c"}),
|
||||
_StrCase(RuleCreateRequest, "content", 10_000, "rule-content<=10000", {"level": "project"}),
|
||||
_StrCase(TemplateCreateRequest, "title", 200, "template-title<=200", {"body": "b"}),
|
||||
_StrCase(TemplateCreateRequest, "body", 20_000, "template-body<=20000", {"title": "t"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _STR_CASES, ids=[c.case_id for c in _STR_CASES])
|
||||
def test_request_str_fields_reject_over_max_length(case: _StrCase) -> None:
|
||||
# Arrange / Act — 长度 == cap:构造成功。
|
||||
case.schema(**{**case.base, case.field_name: "x" * case.cap})
|
||||
# Assert — 长度 == cap+1:超界即 ValidationError。
|
||||
with pytest.raises(ValidationError):
|
||||
case.schema(**{**case.base, case.field_name: "x" * (case.cap + 1)})
|
||||
|
||||
|
||||
def test_style_learn_samples_item_and_list_caps() -> None:
|
||||
# 单条样本 == 20_000 字符 OK;20_001 → ValidationError(最大 DoS 面:每条正文都设上界)。
|
||||
StyleLearnRequest(samples=["x" * 20_000])
|
||||
with pytest.raises(ValidationError):
|
||||
StyleLearnRequest(samples=["x" * 20_001])
|
||||
# 列表 == 20 条 OK;21 条 → ValidationError(同时约束列表长度)。
|
||||
StyleLearnRequest(samples=["s"] * 20)
|
||||
with pytest.raises(ValidationError):
|
||||
StyleLearnRequest(samples=["s"] * 21)
|
||||
|
||||
|
||||
def test_oversized_request_body_rejected_413() -> None:
|
||||
"""超大请求体经 body-size 中间件直接 413(不入路由/DB)。
|
||||
|
||||
不以 context manager 进入 TestClient → 不跑 lifespan/seed → 不需 Postgres。中间件在
|
||||
路由前拦截,故请求根本不触达任何 DB。
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from ww_api.main import create_app
|
||||
from ww_api.middleware import MAX_BODY_BYTES
|
||||
|
||||
client = TestClient(create_app())
|
||||
resp = client.post("/projects", content=b"x" * (MAX_BODY_BYTES + 1))
|
||||
|
||||
assert resp.status_code == 413
|
||||
assert resp.json()["error"]["code"] == "PAYLOAD_TOO_LARGE"
|
||||
@@ -15,6 +15,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -51,12 +53,65 @@ class _ScriptedHttp:
|
||||
return None
|
||||
|
||||
|
||||
class _TrackingSessionFactory:
|
||||
"""记录同一时刻在持会话数(open_count)+ 累计开启次数(total_opened)的 session 工厂替身。
|
||||
|
||||
CR-H1 断言用:轮询期间 open_count 应恒 0(无会话被长时间占用),持久化时 total_opened≥1。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.open_count = 0
|
||||
self.total_opened = 0
|
||||
|
||||
def __call__(self) -> Any:
|
||||
from fakes_projects import FakeSession
|
||||
|
||||
@asynccontextmanager
|
||||
async def _cm() -> AsyncIterator[Any]:
|
||||
self.open_count += 1
|
||||
self.total_opened += 1
|
||||
try:
|
||||
yield FakeSession()
|
||||
finally:
|
||||
self.open_count -= 1
|
||||
|
||||
return _cm()
|
||||
|
||||
|
||||
class _ObservingHttp:
|
||||
"""观测型 scripted http:每次 POST 到 TOKEN_URL(轮询)时记录当下在持会话数。aclose 无操作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
responses: list[_FakeResponse],
|
||||
*,
|
||||
factory: _TrackingSessionFactory,
|
||||
token_url: str,
|
||||
) -> None:
|
||||
self._responses = responses
|
||||
self._factory = factory
|
||||
self._token_url = token_url
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
self.open_during_poll: list[int] = []
|
||||
|
||||
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
|
||||
idx = len(self.calls)
|
||||
self.calls.append((url, data))
|
||||
if url == self._token_url: # 轮询 POST(区别于 device_authorization POST)。
|
||||
self.open_during_poll.append(self._factory.open_count)
|
||||
return self._responses[idx]
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _make_client(
|
||||
*,
|
||||
store: FakeCredentialStore,
|
||||
http: _ScriptedHttp,
|
||||
http: Any,
|
||||
enc_key: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
session_factory: Any = None,
|
||||
) -> TestClient:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
|
||||
from ww_config import get_settings
|
||||
@@ -88,12 +143,15 @@ def _make_client(
|
||||
job_repo = FakeJobRepo()
|
||||
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
|
||||
|
||||
sf = session_factory if session_factory is not None else FakeSessionFactory()
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_credential_store] = lambda: store
|
||||
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
||||
app.dependency_overrides[get_session] = lambda: FakeSession()
|
||||
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
|
||||
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http
|
||||
app.dependency_overrides[get_session_factory] = lambda: sf
|
||||
# 请求级 http 现为生成器依赖 `_http_client`(CR-H2)——override 它注 fake(否则真生成器联网)。
|
||||
app.dependency_overrides[routers.kimi_oauth._http_client] = lambda: http
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -163,6 +221,90 @@ def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPat
|
||||
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
|
||||
|
||||
|
||||
def test_poll_loop_does_not_hold_db_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""CR-H1:设备授权轮询期间**不得**占用 DB 会话(否则连接池被长时间空占→饥饿)。
|
||||
|
||||
轮询循环只做 HTTP(无 DB);仅最终持久化才开一个**短**会话。断言:轮询 POST 期间
|
||||
session_factory 未开会话(open_count==0),而持久化确开过短会话;凭据已落库、响应不泄 token。
|
||||
"""
|
||||
from ww_api.services.kimi_oauth import TOKEN_URL
|
||||
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
factory = _TrackingSessionFactory()
|
||||
# device auth → poll #1 pending → poll #2 success(≥2 次轮询 POST)。
|
||||
http = _ObservingHttp(
|
||||
[
|
||||
_device_auth_response(),
|
||||
_FakeResponse(400, {"error": "authorization_pending"}),
|
||||
_token_response(),
|
||||
],
|
||||
factory=factory,
|
||||
token_url=TOKEN_URL,
|
||||
)
|
||||
client = _make_client(
|
||||
store=store,
|
||||
http=http,
|
||||
enc_key=enc_key,
|
||||
monkeypatch=monkeypatch,
|
||||
session_factory=factory,
|
||||
)
|
||||
|
||||
resp = client.post("/settings/providers/kimi-code/oauth/start")
|
||||
|
||||
assert resp.status_code == 202
|
||||
# 核心断言:轮询 POST 期间无 session_factory 会话在持。
|
||||
assert http.open_during_poll # 确有轮询 POST 发生
|
||||
assert all(c == 0 for c in http.open_during_poll)
|
||||
# 持久化时确开过一个短会话。
|
||||
assert factory.total_opened >= 1
|
||||
# 凭据已落库;响应绝不含 token。
|
||||
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
|
||||
assert "secret-acc" not in resp.text
|
||||
assert "secret-ref" not in resp.text
|
||||
|
||||
|
||||
async def test_http_client_dependency_is_generator_and_closes_client(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""CR-H2:请求级 `_http_client` 是**生成器依赖**,请求结束时 aclose 客户端(不泄漏连接)。
|
||||
|
||||
驱动生成器一轮(enter→yield→exit),断言:是 async 生成器;yield 时未关闭;耗尽时恰好
|
||||
关闭一次。RED:修复前 `_http_client` 不存在(AttributeError)。
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from ww_api.routers import kimi_oauth
|
||||
|
||||
closes = {"count": 0}
|
||||
|
||||
class _SpyClient:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> _SpyClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: Any) -> None:
|
||||
closes["count"] += 1
|
||||
|
||||
async def aclose(self) -> None:
|
||||
closes["count"] += 1
|
||||
|
||||
monkeypatch.setattr("ww_api.routers.kimi_oauth.httpx.AsyncClient", _SpyClient)
|
||||
|
||||
# 是 async 生成器依赖(有 teardown),非普通函数(普通函数依赖无 teardown → 泄漏)。
|
||||
assert inspect.isasyncgenfunction(kimi_oauth._http_client)
|
||||
|
||||
gen = kimi_oauth._http_client()
|
||||
client = await gen.__anext__()
|
||||
assert client is not None
|
||||
assert closes["count"] == 0 # yield 时尚未关闭
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await gen.__anext__()
|
||||
assert closes["count"] == 1 # 请求结束恰好关闭一次
|
||||
|
||||
|
||||
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
|
||||
@@ -239,6 +239,50 @@ async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_bea
|
||||
assert body["chapters"][1]["beats"] == ["冲突升级"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outline_with_volume_filters_to_that_volume() -> None:
|
||||
# ?volume=N 只返回该卷章节;无该参数返回全部(向后兼容)。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
read_repo = FakeOutlineReadRepo()
|
||||
read_repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·开篇"])
|
||||
read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·冲突"])
|
||||
read_repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·新篇"])
|
||||
client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo)
|
||||
|
||||
async with client:
|
||||
all_resp = await client.get(f"/projects/{pid}/outline")
|
||||
vol2_resp = await client.get(f"/projects/{pid}/outline?volume=2")
|
||||
|
||||
# 不带 volume → 全部三章。
|
||||
assert all_resp.status_code == 200
|
||||
assert [c["no"] for c in all_resp.json()["chapters"]] == [1, 2, 3]
|
||||
# ?volume=2 → 仅卷二的第 3 章。
|
||||
assert vol2_resp.status_code == 200
|
||||
vol2_chapters = vol2_resp.json()["chapters"]
|
||||
assert [c["no"] for c in vol2_chapters] == [3]
|
||||
assert all(c["volume"] == 2 for c in vol2_chapters)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outline_repo_volume_filter_pushed_into_query() -> None:
|
||||
# 卷过滤下推到 repo(DB WHERE),而非端点 Python 侧过滤:直接调 repo 断言只回该卷。
|
||||
repo = FakeOutlineReadRepo()
|
||||
pid = uuid.uuid4()
|
||||
repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·1"])
|
||||
repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·2"])
|
||||
repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·3"])
|
||||
|
||||
vol1 = await repo.list_for_project(pid, volume=1)
|
||||
vol2 = await repo.list_for_project(pid, volume=2)
|
||||
all_rows = await repo.list_for_project(pid)
|
||||
|
||||
assert [v.chapter_no for v in vol1] == [1, 2]
|
||||
assert all(v.volume == 1 for v in vol1)
|
||||
assert [v.chapter_no for v in vol2] == [3]
|
||||
assert [v.chapter_no for v in all_rows] == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
|
||||
197
apps/api/tests/test_project_plan_endpoint.py
Normal file
197
apps/api/tests/test_project_plan_endpoint.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""AI 立项方案生成端点测试(灵感⑤;内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖 `POST /skills/project-plan/generate`(不带 project 前缀):
|
||||
- 无既有 project 也能生成(向导阶段 project 未建)→ 200 结构化预览 + 落 ledger。
|
||||
- 种子门控:缺 genre / logline → 422,不触达网关、不 commit。
|
||||
- 无凭据 → 503。
|
||||
- 向导草稿序列化进网关 context(种子 + 已填字段透传)。
|
||||
LLM 一律 mock,绝不联网。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeSession
|
||||
from ww_agents import ProjectPlanResult
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
|
||||
class _CaptureGateway:
|
||||
"""记录最近一次 req.input(验证向导草稿进了 context),返固定 ProjectPlanResult。"""
|
||||
|
||||
def __init__(self, parsed: ProjectPlanResult) -> None:
|
||||
self._parsed = parsed
|
||||
self.last_input: str | None = None
|
||||
self.calls = 0
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
self.calls += 1
|
||||
self.last_input = req.input if isinstance(req.input, str) else str(req.input)
|
||||
return LlmResponse(
|
||||
text=self._parsed.model_dump_json(),
|
||||
parsed=self._parsed,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
model="fake",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
),
|
||||
served_by=ServedBy(provider="fake", model="fake"),
|
||||
)
|
||||
|
||||
|
||||
def _plan() -> ProjectPlanResult:
|
||||
return ProjectPlanResult(
|
||||
title_candidates=["逐光而行", "剑试九霄"],
|
||||
setting="上古仙门世界",
|
||||
narrative_structure="黄金三章立钩 → 升级",
|
||||
story_core="废柴逆袭封神的代价",
|
||||
ending_design="归隐收束(正剧)",
|
||||
tone="热血",
|
||||
)
|
||||
|
||||
|
||||
def _make_app(
|
||||
*,
|
||||
gateway: Any,
|
||||
session: FakeSession | None = None,
|
||||
no_creds: bool = False,
|
||||
) -> tuple[Any, FakeSession]:
|
||||
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_project_plan_gateway
|
||||
from ww_db import get_session
|
||||
|
||||
session = session or FakeSession()
|
||||
|
||||
async def _ok() -> Any:
|
||||
return gateway
|
||||
|
||||
async def _no_creds() -> Any:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_plan_gateway] = _no_creds if no_creds else _ok
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
return app, session
|
||||
|
||||
|
||||
def _client(app: Any) -> httpx.AsyncClient:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_without_existing_project() -> None:
|
||||
# 向导阶段 project 未建:无 project 前缀端点直接生成,返结构化方案 + 落 ledger。
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, session = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
"/skills/project-plan/generate",
|
||||
json={"genre": "仙侠", "logline": "废柴少年觉醒禁忌血脉,逆势封神。"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["title_candidates"] == ["逐光而行", "剑试九霄"]
|
||||
assert body["story_core"] == "废柴逆袭封神的代价"
|
||||
assert body["ending_design"].endswith("(正剧)")
|
||||
assert body["tone"] == "热血"
|
||||
assert session.commits == 1 # 预览不写业务表,但落 ledger
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_serializes_wizard_draft_into_context() -> None:
|
||||
# 种子 + 已填字段序列化进网关 context(有则贴合)。
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, _ = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
"/skills/project-plan/generate",
|
||||
json={
|
||||
"genre": "都市",
|
||||
"logline": "外卖骑手觉醒读心术。",
|
||||
"title": "听风者",
|
||||
"tone": "轻松",
|
||||
"selling_points": ["逆袭", "系统流"],
|
||||
"brief": "偏悬疑向",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert gateway.last_input is not None
|
||||
assert "题材:都市" in gateway.last_input
|
||||
assert "一句话故事:外卖骑手觉醒读心术。" in gateway.last_input
|
||||
assert "暂定书名:听风者" in gateway.last_input
|
||||
assert "核心卖点:逆袭、系统流" in gateway.last_input
|
||||
assert "偏悬疑向" in gateway.last_input # brief 进 context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_seed_missing_logline_422() -> None:
|
||||
# 种子门控:缺 logline → 422,不触达网关、不 commit。
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, session = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post("/skills/project-plan/generate", json={"genre": "仙侠"})
|
||||
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["error"]["code"] == ErrorCode.VALIDATION
|
||||
assert gateway.calls == 0
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_seed_missing_genre_422() -> None:
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, session = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post("/skills/project-plan/generate", json={"logline": "一句话故事。"})
|
||||
|
||||
assert resp.status_code == 422
|
||||
assert gateway.calls == 0
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_seed_blank_strings_422() -> None:
|
||||
# 纯空白种子也拒(strip 后为空)。
|
||||
gateway = _CaptureGateway(_plan())
|
||||
app, session = _make_app(gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
"/skills/project-plan/generate", json={"genre": " ", "logline": " "}
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
assert gateway.calls == 0
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_no_credentials_503() -> None:
|
||||
app, session = _make_app(gateway=object(), no_creds=True)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
"/skills/project-plan/generate",
|
||||
json={"genre": "仙侠", "logline": "一句话故事。"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
@@ -10,7 +10,16 @@ import uuid
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
|
||||
from fakes_projects import (
|
||||
FakeChapterRepo,
|
||||
FakeProjectRepo,
|
||||
FakeReviewGateway,
|
||||
FakeSession,
|
||||
FakeWriterGateway,
|
||||
)
|
||||
from fastapi import FastAPI
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
DigestView,
|
||||
@@ -29,7 +38,9 @@ class _EmptyOutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return None
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
async def list_for_project(
|
||||
self, project_id: uuid.UUID, *, volume: int | None = None
|
||||
) -> list[OutlineView]:
|
||||
return []
|
||||
|
||||
|
||||
@@ -122,6 +133,16 @@ def _make_client(
|
||||
return client, project_repo, chapter_repo, gateway
|
||||
|
||||
|
||||
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
|
||||
"""Seed 一个属于 STUB_OWNER_ID 的项目,返回其 pid。
|
||||
|
||||
stream_draft 现先校验项目存在(QA C1),流式用例须用已存在项目,否则 404。
|
||||
"""
|
||||
pid = uuid.uuid4()
|
||||
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
|
||||
return pid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_returns_201() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
@@ -138,6 +159,54 @@ async def test_create_project_returns_201() -> None:
|
||||
assert uuid.UUID(body["id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_persists_tone_ending_pov() -> None:
|
||||
# ④ 立项新增基调/结局/视角:请求 → repo 落库 → 响应回显(snake_case 契约)。
|
||||
client, repo, _, _ = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
"/projects",
|
||||
json={
|
||||
"title": "基调作品",
|
||||
"tone": "热血",
|
||||
"ending_type": "HE",
|
||||
"narrative_pov": "第一人称",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["tone"] == "热血"
|
||||
assert body["ending_type"] == "HE"
|
||||
assert body["narrative_pov"] == "第一人称"
|
||||
(_owner, view) = next(iter(repo.rows.values()))
|
||||
assert view.tone == "热血"
|
||||
assert view.ending_type == "HE"
|
||||
assert view.narrative_pov == "第一人称"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_exposes_tone_ending_pov() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
async with client:
|
||||
created = (
|
||||
await client.post(
|
||||
"/projects",
|
||||
json={
|
||||
"title": "书",
|
||||
"tone": "沉重",
|
||||
"ending_type": "BE",
|
||||
"narrative_pov": "多视角",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
resp = await client.get(f"/projects/{created['id']}")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["tone"] == "沉重"
|
||||
assert body["ending_type"] == "BE"
|
||||
assert body["narrative_pov"] == "多视角"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects() -> None:
|
||||
client, repo, _, _ = _make_client()
|
||||
@@ -146,8 +215,38 @@ async def test_list_projects() -> None:
|
||||
await client.post("/projects", json={"title": "乙"})
|
||||
resp = await client.get("/projects")
|
||||
assert resp.status_code == 200
|
||||
titles = {p["title"] for p in resp.json()["projects"]}
|
||||
projects = resp.json()["projects"]
|
||||
titles = {p["title"] for p in projects}
|
||||
assert titles == {"甲", "乙"}
|
||||
assert all("updated_at" in p for p in projects)
|
||||
assert all(p["pending_review_count"] == 0 for p in projects)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_paginates_with_limit_and_offset() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
async with client:
|
||||
for t in ("甲", "乙", "丙"):
|
||||
await client.post("/projects", json={"title": t})
|
||||
page1 = await client.get("/projects?limit=2")
|
||||
page2 = await client.get("/projects?limit=2&offset=2")
|
||||
assert page1.status_code == 200
|
||||
assert [p["title"] for p in page1.json()["projects"]] == ["甲", "乙"]
|
||||
assert page2.status_code == 200
|
||||
assert [p["title"] for p in page2.json()["projects"]] == ["丙"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_rejects_out_of_range_pagination() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
async with client:
|
||||
too_small = await client.get("/projects?limit=0")
|
||||
too_large = await client.get("/projects?limit=201")
|
||||
neg_offset = await client.get("/projects?offset=-1")
|
||||
# 边界越界 → FastAPI 422(limit∈[1,200]、offset≥0)。
|
||||
assert too_small.status_code == 422
|
||||
assert too_large.status_code == 422
|
||||
assert neg_offset.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -157,7 +256,10 @@ async def test_get_project_detail() -> None:
|
||||
created = (await client.post("/projects", json={"title": "详情"})).json()
|
||||
resp = await client.get(f"/projects/{created['id']}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "详情"
|
||||
body = resp.json()
|
||||
assert body["title"] == "详情"
|
||||
assert "updated_at" in body
|
||||
assert body["pending_review_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -180,8 +282,8 @@ async def test_create_project_rejects_blank_title() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_stream_yields_sse_tokens_and_done() -> None:
|
||||
gateway = FakeWriterGateway(chunks=["阿福", "走进门。"])
|
||||
client, _, _, _ = _make_client(gateway=gateway)
|
||||
pid = uuid.uuid4()
|
||||
client, project_repo, _, _ = _make_client(gateway=gateway)
|
||||
pid = _seed_project(project_repo)
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
|
||||
assert resp.status_code == 200
|
||||
@@ -200,8 +302,8 @@ async def test_draft_stream_yields_sse_tokens_and_done() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_stream_threads_directive_into_volatile() -> None:
|
||||
gateway = FakeWriterGateway(chunks=["正文"])
|
||||
client, _, _, _ = _make_client(gateway=gateway)
|
||||
pid = uuid.uuid4()
|
||||
client, project_repo, _, _ = _make_client(gateway=gateway)
|
||||
pid = _seed_project(project_repo)
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"}
|
||||
@@ -215,8 +317,8 @@ async def test_draft_stream_threads_directive_into_volatile() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_stream_backward_compatible_without_body() -> None:
|
||||
gateway = FakeWriterGateway(chunks=["正文"])
|
||||
client, _, _, _ = _make_client(gateway=gateway)
|
||||
pid = uuid.uuid4()
|
||||
client, project_repo, _, _ = _make_client(gateway=gateway)
|
||||
pid = _seed_project(project_repo)
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
|
||||
assert resp.status_code == 200
|
||||
@@ -229,8 +331,8 @@ async def test_draft_stream_maps_error_to_sse_error_event() -> None:
|
||||
from ww_shared import AppError
|
||||
|
||||
gateway = FakeWriterGateway(chunks=["半段"], error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
|
||||
client, _, _, _ = _make_client(gateway=gateway)
|
||||
pid = uuid.uuid4()
|
||||
client, project_repo, _, _ = _make_client(gateway=gateway)
|
||||
pid = _seed_project(project_repo)
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
|
||||
assert resp.status_code == 200
|
||||
@@ -240,6 +342,18 @@ async def test_draft_stream_maps_error_to_sse_error_event() -> None:
|
||||
assert ErrorCode.LLM_UNAVAILABLE in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_stream_unknown_project_returns_404_without_calling_gateway() -> None:
|
||||
# QA C1 回归:对不存在的 project 流式写章必须 404,且绝不触网关(不烧 LLM 调用)。
|
||||
gateway = FakeWriterGateway(chunks=["不该被生成"])
|
||||
client, _project_repo, _, _ = _make_client(gateway=gateway)
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{uuid.uuid4()}/chapters/1/draft")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
assert len(gateway.requests) == 0 # 未触达网关
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_draft_is_idempotent() -> None:
|
||||
chapter_repo = FakeChapterRepo()
|
||||
@@ -283,3 +397,191 @@ async def test_get_draft_404_when_no_draft() -> None:
|
||||
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
# ---- POST /rewrite/clarify(整章重写预检澄清,WFW-9 M2)----
|
||||
|
||||
# 摘录上界外的哨兵:喂超长 prior_draft,断言超界正文不进网关 input(不喂全章、不泄漏)。
|
||||
_LEAK_SENTINEL = "泄漏哨兵段落禁止外泄"
|
||||
|
||||
|
||||
def _need_clarification_decision() -> object:
|
||||
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=True,
|
||||
questions=[
|
||||
ClarifyQuestion(
|
||||
question="你说本章节奏太慢,是想往哪个方向重写整章?",
|
||||
options=[
|
||||
ClarifyOption(label="砍支线压缩篇幅", value="删掉次要支线,压缩铺陈直入主线"),
|
||||
ClarifyOption(label="提前引爆冲突", value="把本章高潮冲突提前到前半段"),
|
||||
],
|
||||
allow_free_text=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _clear_rewrite_decision() -> object:
|
||||
from ww_agents import ClarifyDecision
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=False,
|
||||
verification="我会把本章高潮提前、删掉开头回忆插叙,直接进冲突,对吗?",
|
||||
)
|
||||
|
||||
|
||||
def _clarify_app(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
session: FakeSession | None = None,
|
||||
clarify_gateway: object | None = None,
|
||||
) -> FastAPI:
|
||||
"""构造只挂 rewrite/clarify 端点依赖的 app(project_repo + session + clarify 网关)。"""
|
||||
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_clarify_gateway, get_project_repo
|
||||
from ww_db import get_session
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_session] = lambda: session or FakeSession()
|
||||
if clarify_gateway is not None:
|
||||
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
|
||||
return app
|
||||
|
||||
|
||||
def _clarify_client(app: FastAPI) -> httpx.AsyncClient:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_vague_feedback_asks_one_question_and_commits() -> None:
|
||||
# (a) 含糊章级意见 → need_clarification True + 恰 1 问;analyst 档 + 末尾 commit 落账。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_need_clarification_decision())
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "节奏太慢", "prior_draft": "本章开头……"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is True
|
||||
assert len(body["questions"]) == 1
|
||||
assert body["questions"][0]["options"][0]["label"] == "砍支线压缩篇幅"
|
||||
assert body["questions"][0]["allow_free_text"] is True
|
||||
# 不变量 #2:analyst 档;意见折进网关 input;末尾一次 commit(网关 usage_ledger 落库)。
|
||||
assert gateway.requests[0].tier == "analyst"
|
||||
assert "节奏太慢" in str(gateway.requests[0].input)
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_clear_feedback_proceeds_read_only() -> None:
|
||||
# (b) 明确意见 → need_clarification False + verification;只读、无回滚。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "把本章高潮提前到前半段,删掉开头回忆插叙。"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is False
|
||||
assert body["questions"] == []
|
||||
assert body["verification"] is not None
|
||||
# 只读不写业务表:仅一次 commit 落网关 usage,无回滚(不变量 #3)。
|
||||
assert session.commits == 1
|
||||
assert session.rollbacks == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_unknown_project_404() -> None:
|
||||
# (c) 项目不存在 → 404(触网关前 fail-fast)。
|
||||
app = _clarify_app(
|
||||
project_repo=FakeProjectRepo(),
|
||||
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
|
||||
)
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/chapters/1/rewrite/clarify",
|
||||
json={"feedback": "节奏太慢"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_fails_open_on_wrong_parsed() -> None:
|
||||
# (d) 网关返回非 ClarifyDecision → 端点仍 200 + need_clarification False(韧性回退,非 500)。
|
||||
from ww_agents import WorldGenResult
|
||||
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=WorldGenResult(entities=[]))
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "重写一遍"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["need_clarification"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_empty_feedback_422() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
app = _clarify_app(
|
||||
project_repo=project_repo,
|
||||
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
|
||||
)
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/rewrite/clarify", json={"feedback": ""}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_bounds_excerpt_and_never_leaks_manuscript() -> None:
|
||||
# 有界摘录:超长 prior_draft 的越界正文不进网关 input,响应也绝不回灌正文(脱敏)。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
# 哨兵放在远超摘录上界处(10k 填充 + 哨兵),确保被截断在摘录外。
|
||||
prior_draft = ("正" * 10_000) + _LEAK_SENTINEL
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "重写本章", "prior_draft": prior_draft},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# 越界正文既不进 LLM 输入,也不出现在响应体(正文只喂有界摘录、绝不回灌)。
|
||||
assert _LEAK_SENTINEL not in str(gateway.requests[0].input)
|
||||
assert _LEAK_SENTINEL not in resp.text
|
||||
|
||||
@@ -209,6 +209,70 @@ async def test_accept_promotes_appends_digest_records_decisions() -> None:
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_characterization_does_not_block_accept() -> None:
|
||||
"""灵感③ advisory:人物塑造问题(零 continuity 冲突)不阻断验收(D2)。
|
||||
|
||||
留痕带 characterization issues 但 conflicts=[] → 验收 gate 只读 continuity conflicts,
|
||||
直通晋升;characterization 从不进 conflicts、从不产 decisions。
|
||||
"""
|
||||
from ww_api.services.digest_extraction import ChapterDigestFacts
|
||||
|
||||
review_repo = FakeReviewRepo()
|
||||
pid = uuid.uuid4()
|
||||
# 零 continuity 冲突,但有 advisory 人物塑造问题。
|
||||
await review_repo.record(
|
||||
pid,
|
||||
1,
|
||||
conflicts=[],
|
||||
characterization={
|
||||
"issues": [
|
||||
{
|
||||
"character": "沈砚",
|
||||
"aspect": "动机一致性",
|
||||
"where": "第3段",
|
||||
"quote": "他忽然转身就走。",
|
||||
"diagnosis": "与既定护妹动机断裂",
|
||||
"suggestion": "退缩扣回护妹动机",
|
||||
"confidence": 0.82,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s"))
|
||||
client, chapter_repo, _, digest_repo, session = _make_client(
|
||||
review_repo=review_repo, digest_gateway=digest_gw
|
||||
)
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/accept",
|
||||
# 无需对人物塑造做任何裁决(advisory 无 conflict 通道)。
|
||||
json={"final_text": "作者终稿正文。", "decisions": []},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["accepted_version"] == 1
|
||||
assert session.commits == 1
|
||||
# characterization 留痕不产 decisions(advisory);晋升照常。
|
||||
accepted = await chapter_repo.latest_accepted(pid, 1)
|
||||
assert accepted is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_characterization_surfaces_in_review_history() -> None:
|
||||
"""列 characterization 经 GET .../reviews 透传到历史项(前端面板 seed 用)。"""
|
||||
review_repo = FakeReviewRepo()
|
||||
pid = uuid.uuid4()
|
||||
await review_repo.record(
|
||||
pid, 1, conflicts=[], characterization={"issues": [{"character": "沈砚"}]}
|
||||
)
|
||||
client, _, _, _, _ = _make_client(review_repo=review_repo)
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/chapters/1/reviews")
|
||||
assert resp.status_code == 200
|
||||
row = resp.json()["reviews"][0]
|
||||
assert row["characterization"]["issues"][0]["character"] == "沈砚"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_with_no_review_succeeds() -> None:
|
||||
"""无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。"""
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
覆盖:
|
||||
- 201 + 回显 level/content + 端点 commit;
|
||||
- 非法 level → 422(Pydantic Literal 校验,FastAPI 422);
|
||||
- 空 content → 422。
|
||||
- 空 content → 422;
|
||||
- 项目不存在 → 404(QA H2 回归:此前 FK 违例逃逸成 500)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,8 +14,10 @@ import uuid
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeSession
|
||||
from ww_core.domain.rule_repo import RuleWriteView
|
||||
from fakes_projects import FakeProjectRepo, FakeSession
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
from ww_core.domain.rule_repo import RuleListItemView, RuleWriteView
|
||||
|
||||
|
||||
class _FakeRuleWriteRepo:
|
||||
@@ -22,34 +25,56 @@ class _FakeRuleWriteRepo:
|
||||
self.rows: list[RuleWriteView] = []
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
view = RuleWriteView(project_id=project_id, level=level, content=content)
|
||||
view = RuleWriteView(id=uuid.uuid4(), project_id=project_id, level=level, content=content)
|
||||
self.rows.append(view)
|
||||
return view
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
|
||||
return [
|
||||
RuleListItemView(id=r.id, level=r.level, content=r.content)
|
||||
for r in self.rows
|
||||
if r.project_id in (project_id, None)
|
||||
]
|
||||
|
||||
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]:
|
||||
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool:
|
||||
before = len(self.rows)
|
||||
self.rows = [r for r in self.rows if not (r.id == rule_id and r.project_id == project_id)]
|
||||
return len(self.rows) < before
|
||||
|
||||
|
||||
def _make_client() -> tuple[
|
||||
httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession, FakeProjectRepo, uuid.UUID
|
||||
]:
|
||||
"""构建测试 client,并 seed 一个属于 STUB_OWNER_ID 的项目;返回其 pid。
|
||||
|
||||
create_rule 现在先校验项目存在(QA H2),故必须 override get_project_repo 并 seed,
|
||||
否则正常用例会 404。返回的 pid 是已存在项目;未 seed 的随机 pid 即"不存在"。
|
||||
"""
|
||||
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_rule_write_repo
|
||||
from ww_api.services.project_deps import get_project_repo, get_rule_write_repo
|
||||
from ww_db import get_session
|
||||
|
||||
repo = _FakeRuleWriteRepo()
|
||||
session = FakeSession()
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = uuid.uuid4()
|
||||
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="作品"))
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_rule_write_repo] = lambda: repo
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
return client, repo, session
|
||||
return client, repo, session, project_repo, pid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_returns_201_and_commits() -> None:
|
||||
client, repo, session = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
client, repo, session, _project_repo, pid = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/rules",
|
||||
@@ -59,6 +84,8 @@ async def test_create_rule_returns_201_and_commits() -> None:
|
||||
body = resp.json()
|
||||
assert body["level"] == "project"
|
||||
assert body["content"] == "主角不许中途复活"
|
||||
# 创建回显带 id(前端删除 handle)。
|
||||
assert body["id"] == str(repo.rows[0].id)
|
||||
assert session.commits == 1
|
||||
assert len(repo.rows) == 1
|
||||
assert repo.rows[0].project_id == pid
|
||||
@@ -66,8 +93,7 @@ async def test_create_rule_returns_201_and_commits() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_invalid_level_returns_422() -> None:
|
||||
client, _repo, _session = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
client, _repo, _session, _project_repo, pid = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/rules",
|
||||
@@ -78,11 +104,71 @@ async def test_create_rule_invalid_level_returns_422() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_empty_content_returns_422() -> None:
|
||||
client, _repo, _session = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
client, _repo, _session, _project_repo, pid = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/rules",
|
||||
json={"level": "global", "content": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_whitespace_content_returns_422() -> None:
|
||||
# QA MEDIUM 回归:纯空白 content(strip 后为空)应 422,不可入库成垃圾行。
|
||||
client, repo, _session, _project_repo, pid = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/rules",
|
||||
json={"level": "project", "content": " "},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert len(repo.rows) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_unknown_project_returns_404() -> None:
|
||||
# QA H2 回归:给不存在的 project 加规则应 404(不是 500 的 FK 违例逃逸)。
|
||||
client, repo, _session, _project_repo, _pid = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/rules",
|
||||
json={"level": "project", "content": "x"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert len(repo.rows) == 0 # 未触达写库
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rule_returns_204_and_commits() -> None:
|
||||
# 作者显式删一条本作品规则(不变量 #3):204 + 端点提交 + 行被移除。
|
||||
client, repo, session, _project_repo, pid = _make_client()
|
||||
seeded = await repo.create(pid, level="project", content="待删规则")
|
||||
async with client:
|
||||
resp = await client.delete(f"/projects/{pid}/rules/{seeded.id}")
|
||||
assert resp.status_code == 204
|
||||
assert session.commits == 1
|
||||
assert len(repo.rows) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rule_unknown_rule_returns_404() -> None:
|
||||
# 未知 rule_id → 404;不提交(删不到行)。
|
||||
client, repo, session, _project_repo, pid = _make_client()
|
||||
async with client:
|
||||
resp = await client.delete(f"/projects/{pid}/rules/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rule_unknown_project_returns_404() -> None:
|
||||
# 项目不存在 → 404(先于 rule 查校验),不触达删除。
|
||||
client, repo, session, _project_repo, _pid = _make_client()
|
||||
async with client:
|
||||
resp = await client.delete(f"/projects/{uuid.uuid4()}/rules/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert session.commits == 0
|
||||
|
||||
@@ -91,3 +91,16 @@ def test_put_validation_rejects_blank_provider(client: TestClient) -> None:
|
||||
json={"credentials": [{"provider": "", "api_key": "sk-x"}]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_put_validation_rejects_unknown_tier(client: TestClient) -> None:
|
||||
# QA MEDIUM 回归:tier 限定 writer/analyst/light;未知档位 → 422(原接受任意字符串)。
|
||||
resp = client.put(
|
||||
"/settings/providers",
|
||||
json={
|
||||
"tier_routing": [
|
||||
{"tier": "bogus", "provider": "deepseek", "model": "x", "fallback": []}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@@ -59,10 +59,12 @@ def _app_with_overrides(
|
||||
session_factory: FakeSessionFactory | None = None,
|
||||
extract_gateway: object | None = None,
|
||||
refine_gateway: object | None = None,
|
||||
clarify_gateway: object | None = None,
|
||||
) -> FastAPI:
|
||||
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_clarify_gateway,
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
get_refine_gateway,
|
||||
@@ -85,6 +87,8 @@ def _app_with_overrides(
|
||||
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
|
||||
if refine_gateway is not None:
|
||||
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
|
||||
if clarify_gateway is not None:
|
||||
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
|
||||
return app
|
||||
|
||||
|
||||
@@ -394,3 +398,134 @@ async def test_refine_without_credentials_503() -> None:
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
# ---- POST /refine/clarify(润色预检澄清)----
|
||||
|
||||
|
||||
def _need_clarification() -> object:
|
||||
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=True,
|
||||
questions=[
|
||||
ClarifyQuestion(
|
||||
question="你想让这段更有张力,是指哪种方向?",
|
||||
options=[
|
||||
ClarifyOption(label="加快节奏", value="拆短句、压缩铺陈"),
|
||||
ClarifyOption(label="加重冲突", value="强化人物对立"),
|
||||
],
|
||||
allow_free_text=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _clear_decision() -> object:
|
||||
from ww_agents import ClarifyDecision
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=False,
|
||||
verification="我会把被字句改成主动句,其余不动,对吗?",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_returns_need_clarification_and_commits() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_need_clarification())
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/refine/clarify",
|
||||
json={"segment": "原始段落。", "instruction": "更有张力"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is True
|
||||
assert len(body["questions"]) == 1
|
||||
assert body["questions"][0]["options"][0]["label"] == "加快节奏"
|
||||
assert body["questions"][0]["allow_free_text"] is True
|
||||
# analyst 档 + 末尾 commit(记账落库);指令折进输入文本。
|
||||
assert gateway.requests[0].tier == "analyst"
|
||||
assert session.commits == 1
|
||||
assert "更有张力" in str(gateway.requests[0].input)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_returns_clear_decision_read_only() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_clear_decision())
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/2/refine/clarify",
|
||||
json={"segment": "把这句的被字句改成主动句。", "instruction": ""},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is False
|
||||
assert body["questions"] == []
|
||||
assert body["verification"] is not None
|
||||
# 只读不写库:无写侧 repo 参与,仅一次 commit 落网关 usage(不变量 #3)。
|
||||
assert session.commits == 1
|
||||
assert session.rollbacks == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_unknown_project_404() -> None:
|
||||
app = _app_with_overrides(
|
||||
project_repo=FakeProjectRepo(),
|
||||
clarify_gateway=FakeReviewGateway(parsed=_clear_decision()),
|
||||
)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/chapters/1/refine/clarify",
|
||||
json={"segment": "原段", "instruction": "x"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_empty_segment_422() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app_with_overrides(
|
||||
project_repo=project_repo, clarify_gateway=FakeReviewGateway(parsed=_clear_decision())
|
||||
)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_without_credentials_503() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
|
||||
async def _no_creds() -> object:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session)
|
||||
from ww_api.services.project_deps import get_clarify_gateway
|
||||
|
||||
app.dependency_overrides[get_clarify_gateway] = _no_creds
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": "原段"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
|
||||
@@ -33,8 +33,13 @@ class _FakeTemplateRepo:
|
||||
self.rows[view.id] = view
|
||||
return view
|
||||
|
||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
|
||||
return list(self.rows.values())
|
||||
async def list_for_owner(
|
||||
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
|
||||
) -> list[TemplateView]:
|
||||
views = list(self.rows.values())
|
||||
if limit is None:
|
||||
return views
|
||||
return views[offset : offset + limit]
|
||||
|
||||
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
|
||||
if template_id in self.rows:
|
||||
|
||||
@@ -32,7 +32,7 @@ from ww_core.domain.outline_write_repo import OutlineWriteView
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.repositories import OutlineView
|
||||
from ww_core.domain.rule_repo import RuleWriteView
|
||||
from ww_core.domain.world_entity_repo import WorldEntityWriteView
|
||||
from ww_core.domain.world_entity_repo import WorldEntityFields, WorldEntityWriteView
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
@@ -75,6 +75,15 @@ class _FakeWorldWriteRepo:
|
||||
self.rows.append({"type": type, "name": name, "rules": list(rules)})
|
||||
return WorldEntityWriteView(id=uuid.uuid4(), type=type, name=name)
|
||||
|
||||
async def create_many(
|
||||
self, project_id: uuid.UUID, entities: list[WorldEntityFields]
|
||||
) -> list[WorldEntityWriteView]:
|
||||
# 镜像 Sql:批量插入,保序返回逐条 View。
|
||||
return [
|
||||
await self.create(project_id, type=e.type, name=e.name, rules=list(e.rules))
|
||||
for e in entities
|
||||
]
|
||||
|
||||
|
||||
class _FakeOutlineWriteRepo:
|
||||
def __init__(self) -> None:
|
||||
@@ -103,7 +112,7 @@ class _FakeRuleWriteRepo:
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
self.rows.append({"project_id": project_id, "level": level, "content": content})
|
||||
return RuleWriteView(project_id=project_id, level=level, content=content)
|
||||
return RuleWriteView(id=uuid.uuid4(), project_id=project_id, level=level, content=content)
|
||||
|
||||
|
||||
class _FakeOutlineReadRepo:
|
||||
@@ -569,6 +578,30 @@ async def test_ingest_world_entities_no_conflict_writes_201() -> None:
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_multiple_world_entities_all_land_in_one_batch() -> None:
|
||||
# 批量入库:多条实体一次 create_many 全部落库,created 保序,单次 commit。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
|
||||
payload = {
|
||||
"world_entities": [
|
||||
{"type": "力量体系", "name": "吞噬系统", "rules": ["每日上限"]},
|
||||
{"type": "势力", "name": "天机阁", "rules": ["中立"]},
|
||||
{"type": "地点", "name": "无尽海", "rules": []},
|
||||
]
|
||||
}
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=payload)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["created"] == ["吞噬系统", "天机阁", "无尽海"]
|
||||
assert [r["name"] for r in world_repo.rows] == ["吞噬系统", "天机阁", "无尽海"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_world_entities_conflict_blocks_409() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
|
||||
@@ -14,8 +14,13 @@ from ww_db import get_sessionmaker
|
||||
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import configure_logging, get_logger
|
||||
from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware
|
||||
from ww_api.middleware import (
|
||||
REQUEST_ID_HEADER,
|
||||
body_size_limit_middleware,
|
||||
request_id_middleware,
|
||||
)
|
||||
from ww_api.routers import (
|
||||
ai_messages,
|
||||
chain,
|
||||
foreshadow,
|
||||
generation,
|
||||
@@ -74,6 +79,8 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
|
||||
)
|
||||
app.middleware("http")(request_id_middleware)
|
||||
# 请求体大小守卫(CR-H9):在路由前拦截超大 body,直接 413。
|
||||
app.middleware("http")(body_size_limit_middleware)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
@@ -117,6 +124,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(rules.router)
|
||||
app.include_router(style.router)
|
||||
app.include_router(templates.router)
|
||||
app.include_router(ai_messages.router)
|
||||
app.include_router(generation.router)
|
||||
app.include_router(generation.skills_router)
|
||||
app.include_router(toolbox.router)
|
||||
|
||||
@@ -12,10 +12,14 @@ from collections.abc import Awaitable, Callable
|
||||
|
||||
import structlog
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from ww_shared import ErrorBody, ErrorCode, ErrorEnvelope
|
||||
|
||||
REQUEST_ID_HEADER = "x-request-id"
|
||||
|
||||
# 请求体大小上限(2 MiB):超过即 413,防超大 body 撑爆内存/成本(CR-H9)。
|
||||
MAX_BODY_BYTES = 2 * 1024 * 1024
|
||||
|
||||
# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 uuid4().hex、常见 trace id 与客户端
|
||||
# 自定义短 id;含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经
|
||||
# 校验的客户端值原样写进日志/响应头(日志注入防护)。
|
||||
@@ -38,3 +42,29 @@ async def request_id_middleware(
|
||||
response = await call_next(request)
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
return response
|
||||
|
||||
|
||||
async def body_size_limit_middleware(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
"""按 `Content-Length` 拦截超大请求体,超限直接 413(CR-H9)。
|
||||
|
||||
用户中间件跑在 FastAPI 的 AppError 处理器**之外**,故必须自建错误信封响应(不能靠抛
|
||||
AppError)。分块编码(无 `Content-Length`)无法在此拦截——单用户原型可接受的限制。
|
||||
"""
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
size = int(content_length)
|
||||
except ValueError:
|
||||
size = 0 # 非法头防御性视作 0,交由下游正常解析/报错。
|
||||
if size > MAX_BODY_BYTES:
|
||||
envelope = ErrorEnvelope(
|
||||
error=ErrorBody(
|
||||
code=ErrorCode.PAYLOAD_TOO_LARGE,
|
||||
message="请求体过大",
|
||||
request_id=_sanitize_request_id(request.headers.get(REQUEST_ID_HEADER)),
|
||||
)
|
||||
)
|
||||
return JSONResponse(status_code=413, content=envelope.model_dump())
|
||||
return await call_next(request)
|
||||
|
||||
37
apps/api/ww_api/pagination.py
Normal file
37
apps/api/ww_api/pagination.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""列表端点的分页参数(`limit`/`offset`,边界校验 + 合理默认)。
|
||||
|
||||
无界列表端点(owner 维度:projects / templates)原来一次返回全部行,行数随用户
|
||||
数据线性增长。这里提供一个可复用的 FastAPI 依赖 `get_page`,把 `limit`/`offset`
|
||||
在边界处校验(越界 → 422)后交端点。默认返回第一页(`offset=0` + `DEFAULT_PAGE_LIMIT`),
|
||||
不传参的老客户端行为不破坏;`limit` 有上限(`MAX_PAGE_LIMIT`)防一次性拉爆。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Query
|
||||
|
||||
# 合理默认 + 上限(单用户原型:默认页足够覆盖常见数据量;上限防一次拉全表)。
|
||||
DEFAULT_PAGE_LIMIT = 50
|
||||
MAX_PAGE_LIMIT = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Page:
|
||||
"""一页的边界(不可变):`limit` 条数上限、`offset` 起始偏移。"""
|
||||
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
def get_page(
|
||||
limit: Annotated[int, Query(ge=1, le=MAX_PAGE_LIMIT)] = DEFAULT_PAGE_LIMIT,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Page:
|
||||
"""校验并封装分页参数(`limit∈[1,MAX]`、`offset≥0`;越界 FastAPI 返 422)。"""
|
||||
return Page(limit=limit, offset=offset)
|
||||
|
||||
|
||||
PageDep = Annotated[Page, Depends(get_page)]
|
||||
114
apps/api/ww_api/routers/ai_messages.py
Normal file
114
apps/api/ww_api/routers/ai_messages.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""AI 对话(聊天记录)端点(AC-2 / ai-chat-history-plan §3.3)。
|
||||
|
||||
作者↔AI 五类往复交换(refine/rewrite/clarify/continue/generator)落库,供每章「AI 对话」
|
||||
抽屉重放。**本端点是显式的记录写入**(前端在一轮交换结束后 POST 一批)——不是生成端点;
|
||||
五个生成端点保持只读、逐字节不变(守不变量 #3)。`ai_messages` 是侧记录,assemble()/agent
|
||||
永不读它(守 #1/#6)。
|
||||
|
||||
- POST /projects/{id}/ai-messages 一批 append(201;项目不存在 → 404)。
|
||||
- GET /projects/{id}/ai-messages 列出(newest-first;`chapter_no` 过滤含项目级 NULL;分页)。
|
||||
|
||||
提交边界:`AiMessageRepo.append` 只 flush,POST 端点写后 `commit()`(仿模板/规则写侧)。
|
||||
GET 只读不 commit。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import AiMessageRepo, AiTurnRow, ProjectRepo
|
||||
from ww_core.domain.ai_message_repo import AiMessageView as AiMessageRepoView
|
||||
from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.pagination import PageDep
|
||||
from ww_api.schemas.ai_messages import (
|
||||
AiKind,
|
||||
AiMessageAppendRequest,
|
||||
AiMessageListResponse,
|
||||
AiMessageView,
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
|
||||
|
||||
log = get_logger("ww.api.ai_messages")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["ai-messages"])
|
||||
|
||||
AiMessageRepoDep = Annotated[AiMessageRepo, Depends(get_ai_message_repo)]
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _to_response_view(v: AiMessageRepoView) -> AiMessageView:
|
||||
"""repo 视图 → API 视图(同字段镜像;避免路由碰 repo 视图类型泄漏)。"""
|
||||
return AiMessageView(
|
||||
id=v.id,
|
||||
project_id=v.project_id,
|
||||
chapter_no=v.chapter_no,
|
||||
thread_id=v.thread_id,
|
||||
seq=v.seq,
|
||||
kind=v.kind,
|
||||
tool_key=v.tool_key,
|
||||
role=v.role,
|
||||
content=v.content,
|
||||
meta=v.meta,
|
||||
created_at=v.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/ai-messages", status_code=201)
|
||||
async def append_ai_messages(
|
||||
project_id: uuid.UUID,
|
||||
body: AiMessageAppendRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
repo: AiMessageRepoDep,
|
||||
session: SessionDep,
|
||||
) -> AiMessageListResponse:
|
||||
"""一批往复 bubble append(201)。项目不存在 → 404;非法 kind/role/空/超上限 → 422。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
views = await repo.append(
|
||||
project_id,
|
||||
thread_id=body.thread_id,
|
||||
chapter_no=body.chapter_no,
|
||||
kind=body.kind,
|
||||
tool_key=body.tool_key,
|
||||
rows=[AiTurnRow(role=m.role, content=m.content, meta=m.meta) for m in body.messages],
|
||||
)
|
||||
await session.commit()
|
||||
log.info(
|
||||
"ai_messages_appended",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
thread_id=str(body.thread_id),
|
||||
kind=body.kind,
|
||||
count=len(views),
|
||||
)
|
||||
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])
|
||||
|
||||
|
||||
@router.get("/{project_id}/ai-messages")
|
||||
async def list_ai_messages(
|
||||
project_id: uuid.UUID,
|
||||
project_repo: ProjectRepoDep,
|
||||
repo: AiMessageRepoDep,
|
||||
page: PageDep,
|
||||
chapter_no: Annotated[int | None, Query(ge=1)] = None,
|
||||
kind: AiKind | None = None,
|
||||
) -> AiMessageListResponse:
|
||||
"""列出 AI 对话(newest-first)。`chapter_no` → 本章 ∪ 项目级(NULL);缺省 → 仅项目级。"""
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
views = await repo.list_for_chapter(
|
||||
project_id, chapter_no, kind=kind, limit=page.limit, offset=page.offset
|
||||
)
|
||||
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])
|
||||
@@ -19,25 +19,31 @@ ledger,否则 usage 静默丢失,同 draft/review 纪律)。无凭据 →
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import (
|
||||
CharacterCard,
|
||||
CharacterRelation,
|
||||
ProjectPlanResult,
|
||||
character_gen_spec,
|
||||
continuity_spec,
|
||||
project_plan_spec,
|
||||
worldbuilder_spec,
|
||||
)
|
||||
from ww_core.domain import (
|
||||
CharacterWriteFields,
|
||||
CharacterWriteRepo,
|
||||
ProjectRepo,
|
||||
RuleWriteRepo,
|
||||
)
|
||||
from ww_core.domain.repositories import MemoryRepos, RulesRepo
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.orchestrator import (
|
||||
build_brief_context,
|
||||
precheck_generated_cards,
|
||||
run_character_gen,
|
||||
run_generator,
|
||||
run_worldbuilder,
|
||||
)
|
||||
from ww_db import get_session
|
||||
@@ -63,6 +69,7 @@ from ww_api.schemas.generation import (
|
||||
WorldGenerateRequest,
|
||||
WorldGenPreviewResponse,
|
||||
)
|
||||
from ww_api.schemas.projects import ProjectPlanGenerateRequest, ProjectPlanView
|
||||
from ww_api.schemas.rules import RuleView
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import (
|
||||
@@ -70,8 +77,9 @@ from ww_api.services.project_deps import (
|
||||
get_character_write_repo,
|
||||
get_memory_repos,
|
||||
get_precheck_gateway,
|
||||
get_project_plan_gateway,
|
||||
get_project_repo,
|
||||
get_rules_read_repo,
|
||||
get_rule_write_repo,
|
||||
get_skill_registry,
|
||||
get_worldbuilder_gateway,
|
||||
)
|
||||
@@ -84,11 +92,12 @@ skills_router = APIRouter(prefix="/skills", tags=["skills"])
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
CharacterWriteRepoDep = Annotated[CharacterWriteRepo, Depends(get_character_write_repo)]
|
||||
RulesReadRepoDep = Annotated[RulesRepo, Depends(get_rules_read_repo)]
|
||||
RuleRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
|
||||
SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
|
||||
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
|
||||
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)]
|
||||
PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)]
|
||||
ProjectPlanGatewayDep = Annotated[Gateway, Depends(get_project_plan_gateway)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@@ -104,6 +113,46 @@ def _project_context(title: str, genre: str | None, premise: str | None, theme:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _plan_seed_context(body: ProjectPlanGenerateRequest) -> str:
|
||||
"""把立项向导草稿序列化为作品种子(喂 project-plan;仿 `_project_context`)。
|
||||
|
||||
确定性、无时间戳/UUID;只拼非空字段(有则贴合、无则不臆造)。
|
||||
genre + logline 由端点种子门控保证非空。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
label_fields: list[tuple[str, str | None]] = [
|
||||
("暂定书名", body.title),
|
||||
("题材", body.genre),
|
||||
("一句话故事", body.logline),
|
||||
("立意", body.premise),
|
||||
("主题", body.theme),
|
||||
("故事结构", body.structure),
|
||||
("基调", body.tone),
|
||||
("结局取向", body.ending_type),
|
||||
("叙事视角", body.narrative_pov),
|
||||
]
|
||||
for label, value in label_fields:
|
||||
text = (value or "").strip()
|
||||
if text:
|
||||
lines.append(f"{label}:{text}")
|
||||
points = [p.strip() for p in body.selling_points if p.strip()]
|
||||
if points:
|
||||
lines.append(f"核心卖点:{'、'.join(points)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _plan_to_view(plan: ProjectPlanResult) -> ProjectPlanView:
|
||||
"""ww_agents.ProjectPlanResult → API ProjectPlanView(字段同名直传)。"""
|
||||
return ProjectPlanView(
|
||||
title_candidates=list(plan.title_candidates),
|
||||
setting=plan.setting,
|
||||
narrative_structure=plan.narrative_structure,
|
||||
story_core=plan.story_core,
|
||||
ending_design=plan.ending_design,
|
||||
tone=plan.tone,
|
||||
)
|
||||
|
||||
|
||||
def _render_characters_context(cards: list[CharacterCard]) -> str:
|
||||
"""已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。"""
|
||||
if not cards:
|
||||
@@ -111,10 +160,28 @@ def _render_characters_context(cards: list[CharacterCard]) -> str:
|
||||
return "\n".join(f"- {c.name}({c.role}):{'、'.join(c.traits) or '(未列)'}" for c in cards)
|
||||
|
||||
|
||||
def _relations_from_jsonb(raw: list[Any]) -> list[CharacterRelation]:
|
||||
"""`characters.relations` JSONB list(每条 {name, kind, note?})→ schema CharacterRelation。
|
||||
|
||||
跳过缺 name/kind 的脏条目(历史/外部数据不可信,守输入校验边界)。
|
||||
"""
|
||||
out: list[CharacterRelation] = []
|
||||
for item in raw or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name")
|
||||
kind = item.get("kind")
|
||||
if not name or not kind:
|
||||
continue
|
||||
out.append(CharacterRelation(name=str(name), kind=str(kind), note=item.get("note")))
|
||||
return out
|
||||
|
||||
|
||||
async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]:
|
||||
"""把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck)。
|
||||
"""把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck / 设定库读端点)。
|
||||
|
||||
DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。
|
||||
`relations` 从 JSONB list 还原(设定库 Codex 需展示关系网;precheck 不读此字段,无害)。
|
||||
"""
|
||||
views = await memory.character.list_for_project(project_id)
|
||||
cards: list[CharacterCard] = []
|
||||
@@ -129,11 +196,13 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li
|
||||
name=v.name,
|
||||
role=v.role or "",
|
||||
traits=traits,
|
||||
appearance=v.appearance or "",
|
||||
motive=v.motive or "",
|
||||
backstory=v.backstory or "",
|
||||
arc=arc or "",
|
||||
speech_tics=tics,
|
||||
tags=list(v.tags or []),
|
||||
relations=[],
|
||||
relations=_relations_from_jsonb(list(v.relations or [])),
|
||||
)
|
||||
)
|
||||
return cards
|
||||
@@ -225,6 +294,7 @@ async def generate_characters(
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
role_mix=body.role_mix,
|
||||
)
|
||||
await session.commit()
|
||||
log.info(
|
||||
@@ -303,20 +373,23 @@ async def ingest_characters(
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
for card in allowed.get("characters", []):
|
||||
view = await char_repo.create(
|
||||
project_id,
|
||||
fields = [
|
||||
CharacterWriteFields(
|
||||
name=card.name,
|
||||
role=card.role,
|
||||
traits=list(card.traits),
|
||||
appearance=card.appearance,
|
||||
motive=card.motive,
|
||||
backstory=card.backstory,
|
||||
arc=card.arc,
|
||||
speech_tics=list(card.speech_tics),
|
||||
tags=list(card.tags),
|
||||
relations=[r.model_dump() for r in card.relations],
|
||||
)
|
||||
created.append(view.name)
|
||||
for card in allowed.get("characters", [])
|
||||
]
|
||||
# 批量 upsert(一次 flush 落全部),保序返回逐卡 View。
|
||||
created = [view.name for view in await char_repo.create_many(project_id, fields)]
|
||||
|
||||
# 提交边界:网关 ledger(precheck)+ 角色写侧均只 flush → 端点末尾一次 commit。
|
||||
await session.commit()
|
||||
@@ -377,11 +450,16 @@ async def list_world_entities(
|
||||
@router.get("/{project_id}/rules")
|
||||
async def list_rules(
|
||||
project_id: uuid.UUID,
|
||||
repo: RulesReadRepoDep,
|
||||
repo: RuleRepoDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
) -> RuleListResponse:
|
||||
"""规则列表(按读侧顺序)。规则页用。"""
|
||||
rules = await repo.all_for_project(project_id)
|
||||
return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules])
|
||||
"""规则列表(带 id,供前端删除 handle)。项目不存在 → 404(QA MEDIUM:此前返误导性空 200)。"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
rules = await repo.list_for_project(project_id)
|
||||
return RuleListResponse(
|
||||
rules=[RuleView(id=r.id, level=r.level, content=r.content) for r in rules]
|
||||
)
|
||||
|
||||
|
||||
@skills_router.get("")
|
||||
@@ -402,6 +480,53 @@ async def list_skills(registry: SkillRegistryDep) -> SkillListResponse:
|
||||
return SkillListResponse(skills=skills)
|
||||
|
||||
|
||||
# ---- AI 立项方案生成(灵感⑤;不带 project 前缀,立项前 project 未建)----
|
||||
|
||||
|
||||
@skills_router.post("/project-plan/generate")
|
||||
async def generate_project_plan(
|
||||
body: ProjectPlanGenerateRequest,
|
||||
request: Request,
|
||||
gateway: ProjectPlanGatewayDep,
|
||||
session: SessionDep,
|
||||
) -> ProjectPlanView:
|
||||
"""生成结构化立项方案预览(向导阶段,project 未建,不入库)。
|
||||
|
||||
**不带 project 前缀**——绕开 toolbox 的 `project_repo.get→404`(立项前无 project)。
|
||||
**种子门控**:缺 genre 或 logline → 422(空向导不出方案,防 slop)。无凭据 → 503。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
genre = (body.genre or "").strip()
|
||||
logline = (body.logline or "").strip()
|
||||
if not genre or not logline:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
"生成立项方案至少需要「题材」与「一句话故事(logline)」两个种子",
|
||||
{"has_genre": bool(genre), "has_logline": bool(logline)},
|
||||
)
|
||||
|
||||
project_context = _plan_seed_context(body)
|
||||
context = build_brief_context(brief=body.brief or "", project_context=project_context)
|
||||
parsed = await run_generator(
|
||||
project_plan_spec,
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=None, # 向导阶段 project 未建;usage_ledger.project_id 可空
|
||||
)
|
||||
# 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage(否则丢失)。
|
||||
await session.commit()
|
||||
if not isinstance(parsed, ProjectPlanResult):
|
||||
# run_generator 已按 output_schema 校验;防御性收窄(不应触达)。
|
||||
raise AppError(ErrorCode.INTERNAL, "立项方案生成返回类型异常")
|
||||
log.info(
|
||||
"project_plan_generate_done",
|
||||
request_id=request_id,
|
||||
title_count=len(parsed.title_candidates),
|
||||
)
|
||||
return _plan_to_view(parsed)
|
||||
|
||||
|
||||
# ---- schema <-> ww_agents 卡转换 ----
|
||||
|
||||
|
||||
@@ -410,6 +535,8 @@ def _card_to_view(card: CharacterCard) -> CharacterCardView:
|
||||
name=card.name,
|
||||
role=card.role,
|
||||
traits=list(card.traits),
|
||||
appearance=card.appearance,
|
||||
motive=card.motive,
|
||||
backstory=card.backstory,
|
||||
arc=card.arc,
|
||||
speech_tics=list(card.speech_tics),
|
||||
@@ -425,6 +552,8 @@ def _view_to_card(view: CharacterCardView) -> CharacterCard:
|
||||
name=view.name,
|
||||
role=view.role,
|
||||
traits=list(view.traits),
|
||||
appearance=view.appearance,
|
||||
motive=view.motive,
|
||||
backstory=view.backstory,
|
||||
arc=view.arc,
|
||||
speech_tics=list(view.speech_tics),
|
||||
|
||||
@@ -6,13 +6,11 @@ import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db import get_session
|
||||
from ww_db.models import Job
|
||||
from ww_core.domain import JobRepo
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.schemas.jobs import JobResponse
|
||||
from ww_api.services.project_deps import get_job_repo
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
|
||||
@@ -23,9 +21,9 @@ router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
)
|
||||
async def get_job(
|
||||
job_id: uuid.UUID,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
job_repo: Annotated[JobRepo, Depends(get_job_repo)],
|
||||
) -> JobResponse:
|
||||
job = (await session.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none()
|
||||
job = await job_repo.get(job_id)
|
||||
if job is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
|
||||
return JobResponse(
|
||||
|
||||
@@ -17,6 +17,8 @@ token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
@@ -47,6 +49,7 @@ from ww_api.services.kimi_oauth import (
|
||||
AuthorizationPending,
|
||||
DeviceAuth,
|
||||
SlowDown,
|
||||
TokenSet,
|
||||
decrypt_oauth_bundle,
|
||||
encrypt_oauth_bundle,
|
||||
poll_token,
|
||||
@@ -70,51 +73,102 @@ _JOB_KIND_KIMI_OAUTH = "kimi_oauth"
|
||||
_MAX_POLL_ATTEMPTS = 200
|
||||
|
||||
|
||||
#: 请求/后台 HTTP 客户端超时(秒)。
|
||||
_HTTP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
def _default_http_client() -> AsyncHttpClient:
|
||||
return httpx.AsyncClient(timeout=30.0)
|
||||
"""裸工厂:现建一个 httpx 客户端(**后台轮询**直接调用,自行在 finally 里 aclose)。"""
|
||||
return httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def _make_poll_work(device: DeviceAuth) -> Any:
|
||||
"""构造后台轮询工作闭包:循环 poll token 直到成功/过期/拒绝。
|
||||
async def _http_client() -> AsyncIterator[AsyncHttpClient]:
|
||||
"""请求级 httpx 客户端**生成器依赖**:请求结束时经 `async with` 自动 aclose(CR-H2)。
|
||||
|
||||
`work(session)` 自建 httpx 客户端 + 凭据 store(用 `run_job` 传入的独立 session)→
|
||||
按 `interval` 轮询 token → 成功则加密存 `oauth_enc` 并返回非密 job 结果 → 过期/拒绝则
|
||||
抛 AppError(`run_job` 置 job failed)。**token 绝不进 job 结果/日志**。
|
||||
普通函数依赖无 teardown,返回的 client 永不关闭 → 每次 `/start` 泄漏一个连接。故请求路径
|
||||
用此生成器依赖(后台路径仍用裸工厂 `_default_http_client`,自管生命周期)。
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS) as http:
|
||||
yield http
|
||||
|
||||
async def work(session: AsyncSession) -> dict[str, Any]:
|
||||
enc_key = get_settings().credential_enc_key.get_secret_value()
|
||||
store = SqlCredentialStore(session)
|
||||
|
||||
# SlowDown 退避每次增大的间隔量(秒)。
|
||||
_SLOW_DOWN_STEP_SECONDS = 5
|
||||
|
||||
|
||||
async def _poll_for_token(http: AsyncHttpClient, device: DeviceAuth) -> TokenSet:
|
||||
"""设备授权轮询循环(**纯 HTTP,无 DB 会话**,CR-H1):直到拿到 token 或超时。
|
||||
|
||||
`authorization_pending` → 继续;`slow_down` → 增大 interval;成功 → 返回 `TokenSet`;
|
||||
次数耗尽 → 抛 `AppError`(视作过期,由调用方经 `run_job` 置 job failed)。
|
||||
"""
|
||||
interval = max(1, device.interval)
|
||||
|
||||
http = _default_http_client()
|
||||
try:
|
||||
for _ in range(_MAX_POLL_ATTEMPTS):
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
token = await poll_token(http, device.device_code)
|
||||
return await poll_token(http, device.device_code)
|
||||
except AuthorizationPending:
|
||||
continue
|
||||
except SlowDown:
|
||||
interval += 5
|
||||
interval += _SLOW_DOWN_STEP_SECONDS
|
||||
continue
|
||||
# 成功:加密 token 包入库(auth_type=oauth)。明文 token 不进结果/日志。
|
||||
blob = encrypt_oauth_bundle(token, key=enc_key)
|
||||
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
|
||||
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
|
||||
# 轮询次数耗尽(视作过期)。
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"Kimi 设备授权轮询超时",
|
||||
{"provider": KIMI_CODE_PROVIDER},
|
||||
)
|
||||
|
||||
|
||||
def _make_persist_work(token: TokenSet) -> Any:
|
||||
"""构造**最终持久化**工作闭包:只做「加密 token → upsert oauth 凭据」的一次短事务。
|
||||
|
||||
`run_job` 用独立 session 调用此 work;轮询已在会话外完成,故此处会话极短。
|
||||
**明文 token 绝不进 job 结果/日志。**
|
||||
"""
|
||||
|
||||
async def work(session: AsyncSession) -> dict[str, Any]:
|
||||
enc_key = get_settings().credential_enc_key.get_secret_value()
|
||||
store = SqlCredentialStore(session)
|
||||
blob = encrypt_oauth_bundle(token, key=enc_key)
|
||||
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
|
||||
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
|
||||
|
||||
return work
|
||||
|
||||
|
||||
def _raise_work(exc: Exception) -> Any:
|
||||
"""构造只把 `exc` 重抛的 work(轮询失败路径:交由 `run_job` 分类脱敏并置 job failed)。"""
|
||||
|
||||
async def work(_session: AsyncSession) -> dict[str, Any]:
|
||||
raise exc
|
||||
|
||||
return work
|
||||
|
||||
|
||||
async def _run_kimi_oauth_poll(
|
||||
session_factory: SessionFactory,
|
||||
job_id: uuid.UUID,
|
||||
device: DeviceAuth,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
"""后台任务:**先在会话外**轮询 token,再经 `run_job` 开短会话持久化(CR-H1)。
|
||||
|
||||
轮询期间不占用任何 DB 会话(防连接池饥饿);成功 → 交由 `run_job` 落库并置 done;
|
||||
失败 → 把异常带进 `run_job`,走其 `_classify_job_error`/`_mark_failed` 脱敏置 failed。
|
||||
"""
|
||||
http = _default_http_client()
|
||||
try:
|
||||
token = await _poll_for_token(http, device)
|
||||
work = _make_persist_work(token)
|
||||
except Exception as exc: # noqa: BLE001 — 失败经 run_job 统一脱敏置 job failed,不在此处冒泡。
|
||||
work = _raise_work(exc)
|
||||
finally:
|
||||
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
|
||||
aclose = getattr(http, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
|
||||
return work
|
||||
await run_job(session_factory, job_id, work, request_id=request_id)
|
||||
|
||||
|
||||
@router.post("/start", status_code=202)
|
||||
@@ -125,7 +179,7 @@ async def start_oauth(
|
||||
job_repo: JobRepoDep,
|
||||
session: SessionDep,
|
||||
session_factory: SessionFactoryDep,
|
||||
http: Annotated[AsyncHttpClient, Depends(_default_http_client)],
|
||||
http: Annotated[AsyncHttpClient, Depends(_http_client)],
|
||||
) -> OAuthStartResponse:
|
||||
"""发起 device authorization:返回 202 + user_code/verification_uri,后台轮询 token。
|
||||
|
||||
@@ -139,11 +193,12 @@ async def start_oauth(
|
||||
job = await job_repo.create(None, _JOB_KIND_KIMI_OAUTH)
|
||||
await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。
|
||||
|
||||
# 轮询移出 DB 会话(CR-H1):后台先在会话外轮询 token,仅最终持久化开一个短会话。
|
||||
background_tasks.add_task(
|
||||
run_job,
|
||||
_run_kimi_oauth_poll,
|
||||
session_factory,
|
||||
job.id,
|
||||
_make_poll_work(device),
|
||||
device,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -143,10 +143,12 @@ async def get_outline(
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
outline_repo: OutlineReadRepoDep,
|
||||
volume: int | None = None,
|
||||
) -> OutlineResponse:
|
||||
"""读取已持久化的大纲(逐章,按 chapter_no 升序)。
|
||||
|
||||
项目不存在 → 404;项目存在但尚无大纲 → 200 空列表(非 404,页面初次访问的常态)。
|
||||
可选 `?volume=N`:只返回该卷的章节(前端按卷切换);不传 → 全部章节(向后兼容)。
|
||||
项目不存在 → 404;项目存在但尚无大纲(或该卷无章节)→ 200 空列表(非 404)。
|
||||
DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形,
|
||||
前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。
|
||||
"""
|
||||
@@ -156,7 +158,7 @@ async def get_outline(
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
views = await outline_repo.list_for_project(project_id)
|
||||
views = await outline_repo.list_for_project(project_id, volume=volume)
|
||||
chapters = [
|
||||
OutlineChapterView(
|
||||
no=view.chapter_no,
|
||||
@@ -171,6 +173,7 @@ async def get_outline(
|
||||
"outline_read",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
volume=volume,
|
||||
chapter_count=len(chapters),
|
||||
)
|
||||
return OutlineResponse(chapters=chapters)
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import Annotated, Any
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import ClarifyDecision, clarify_rewrite_spec
|
||||
from ww_core.domain.chapter_repo import ChapterRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo
|
||||
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
|
||||
@@ -36,13 +37,16 @@ from ww_core.orchestrator import (
|
||||
error_event,
|
||||
normalize_deltas,
|
||||
normalize_review,
|
||||
run_clarify,
|
||||
stream_chapter_draft,
|
||||
stream_chapter_rewrite,
|
||||
)
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.pagination import PageDep
|
||||
from ww_api.schemas.injection import (
|
||||
InjectionEntity,
|
||||
InjectionEntityRef,
|
||||
@@ -52,6 +56,7 @@ from ww_api.schemas.injection import (
|
||||
from ww_api.schemas.projects import (
|
||||
AcceptRequest,
|
||||
AcceptResponse,
|
||||
ChapterListItem,
|
||||
DraftResponse,
|
||||
DraftSaveRequest,
|
||||
DraftStreamRequest,
|
||||
@@ -63,17 +68,22 @@ from ww_api.schemas.projects import (
|
||||
ReviewHistoryItem,
|
||||
ReviewHistoryResponse,
|
||||
ReviewRequest,
|
||||
RewriteClarifyRequest,
|
||||
RewriteStreamRequest,
|
||||
)
|
||||
from ww_api.services.accept_service import (
|
||||
AcceptOutcome,
|
||||
assert_conflicts_resolved,
|
||||
run_accept_transaction,
|
||||
)
|
||||
from ww_api.services.chapter_list import ChapterLister
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
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.project_deps import (
|
||||
get_chapter_lister,
|
||||
get_chapter_repo,
|
||||
get_clarify_gateway,
|
||||
get_digest_append_repo,
|
||||
get_digest_gateway,
|
||||
get_injection_repo,
|
||||
@@ -98,12 +108,21 @@ _ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
}
|
||||
# SSE 端点:如实声明 200 的 media type 为 text/event-stream(否则 OpenAPI 误标 json)。
|
||||
_SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
|
||||
200: {
|
||||
"description": "SSE 事件流(`event: <name>\\ndata: <json>`,见 memory/contracts.md)",
|
||||
"content": {"text/event-stream": {}},
|
||||
}
|
||||
}
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
||||
ChapterListerDep = Annotated[ChapterLister, Depends(get_chapter_lister)]
|
||||
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
||||
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
||||
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
|
||||
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
|
||||
@@ -127,6 +146,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
|
||||
theme=body.theme,
|
||||
selling_points=body.selling_points,
|
||||
structure=body.structure,
|
||||
tone=body.tone,
|
||||
ending_type=body.ending_type,
|
||||
narrative_pov=body.narrative_pov,
|
||||
),
|
||||
)
|
||||
log.info("project_created", project_id=str(view.id), title_len=len(view.title))
|
||||
@@ -134,8 +156,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID)
|
||||
async def list_projects(repo: ProjectRepoDep, page: PageDep) -> ProjectListResponse:
|
||||
"""作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。"""
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
|
||||
return ProjectListResponse(projects=[_to_response(v) for v in views])
|
||||
|
||||
|
||||
@@ -147,6 +170,28 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
|
||||
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(
|
||||
repos: MemoryRepos,
|
||||
project_id: uuid.UUID,
|
||||
@@ -244,7 +289,7 @@ def _encode_sse(event: SseEvent) -> str:
|
||||
return f"event: {event.event}\ndata: {payload}\n\n"
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/draft")
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/draft", responses=_SSE_RESPONSE)
|
||||
async def stream_draft(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
@@ -252,6 +297,7 @@ async def stream_draft(
|
||||
repos: MemoryReposDep,
|
||||
gateway: GatewayDep,
|
||||
injection_repo: InjectionRepoDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
body: DraftStreamRequest | None = None,
|
||||
) -> StreamingResponse:
|
||||
@@ -259,7 +305,12 @@ async def stream_draft(
|
||||
|
||||
`body.directive`(可选,T4-b)是临时本章指令,直通 assemble→volatile(不持久化);
|
||||
无 body 的旧调用方仍可用(向后兼容)。
|
||||
|
||||
项目不存在 → 404(在触网关前 fail-fast):否则非法 project_id 会静默烧一次付费/限流的
|
||||
LLM 调用并返回 200(QA C1)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
directive = body.directive if body else None
|
||||
override = await injection_repo.get(project_id, chapter_no)
|
||||
@@ -301,6 +352,138 @@ async def stream_draft(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/rewrite", responses=_SSE_RESPONSE)
|
||||
async def stream_rewrite(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
request: Request,
|
||||
repos: MemoryReposDep,
|
||||
gateway: GatewayDep,
|
||||
injection_repo: InjectionRepoDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
body: RewriteStreamRequest,
|
||||
) -> StreamingResponse:
|
||||
"""整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
|
||||
|
||||
HITL:新版停前端/草稿,接受才落库(不变量 #3);rewrite 是工具非写节点,不破坏章
|
||||
纯函数性(不变量 #7)。项目不存在 → 404(触网关前 fail-fast,同 draft,QA C1)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
override = await injection_repo.get(project_id, chapter_no)
|
||||
context = await assemble(repos, project_id, chapter_no, override=override)
|
||||
log.info(
|
||||
"rewrite_stream_start",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
stable_core_len=len(context.stable_core),
|
||||
volatile_len=len(context.volatile),
|
||||
prior_draft_len=len(body.prior_draft),
|
||||
feedback_len=len(body.feedback),
|
||||
)
|
||||
|
||||
deltas = stream_chapter_rewrite(
|
||||
gateway,
|
||||
stable_core=context.stable_core,
|
||||
volatile=context.volatile,
|
||||
prior_draft=body.prior_draft,
|
||||
feedback=body.feedback,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
async def _frames() -> AsyncIterator[str]:
|
||||
async for event in normalize_deltas(deltas, request_id=request_id):
|
||||
yield _encode_sse(event)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 — 流已发完,commit 失败不能再改响应;至少记错误。
|
||||
log.error("sse_commit_failed", request_id=request_id, endpoint="rewrite")
|
||||
|
||||
return StreamingResponse(
|
||||
_frames(),
|
||||
media_type="text/event-stream",
|
||||
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# 整章重写预检的草稿摘录上界:只喂 analyst 预检**开头一小段**供锚定,绝不整章入 LLM
|
||||
# (守成本/上下文,且预检只需判意见含糊度、不需读全章)。
|
||||
_REWRITE_CLARIFY_EXCERPT_MAX = 3000
|
||||
|
||||
|
||||
def _build_rewrite_clarify_input(feedback: str, prior_draft: str | None) -> str:
|
||||
"""组整章重写预检输入:作者章级意见 + 当前章草稿的有界摘录(分块标注,供教条区分)。
|
||||
|
||||
只取 `prior_draft` 开头 `_REWRITE_CLARIFY_EXCERPT_MAX` 字符作锚定摘录——预检不需读全章,
|
||||
避免把 200k 整章喂给 analyst 预检(成本/上下文/脱敏)。无草稿时只喂意见(退化,仍可判含糊)。
|
||||
"""
|
||||
parts = [f"【作者的整章修改意见】\n{feedback}"]
|
||||
if prior_draft:
|
||||
excerpt = prior_draft[:_REWRITE_CLARIFY_EXCERPT_MAX]
|
||||
parts.append(f"【当前章节草稿(摘录,仅供锚定)】\n{excerpt}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/chapters/{chapter_no}/rewrite/clarify",
|
||||
responses={
|
||||
404: {"model": ErrorEnvelope, "description": "项目不存在"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
},
|
||||
)
|
||||
async def clarify_rewrite(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
body: RewriteClarifyRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
gateway: ClarifyGatewayDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> ClarifyDecision:
|
||||
"""整章重写预检澄清(WFW-9 M2 路线A 两阶段之「问题」阶段):非流式 JSON,只判不改。
|
||||
|
||||
镜像 refine 侧 clarify——只判「作者这条整章重写意见清不清楚」,含糊则反问 1 问 + 给选项,
|
||||
清楚则给确认语放行;**绝不改正文**(整章重写仍走既有 rewrite SSE 端点)。analyst 档
|
||||
(不变量 #2)。只取 `prior_draft` 有界摘录喂预检(不整章入 LLM)。
|
||||
|
||||
项目不存在 → 404(触网关前 fail-fast,同 rewrite/draft);无凭据 → 503(dep 解析阶段拦下)。
|
||||
**只读不写业务表**(不变量 #3);末尾 `commit()` 让网关 usage_ledger 落库(add-only,否则
|
||||
记账静默丢失,同 rewrite 纪律)。判别/校验失败在 `run_clarify` 内确定性回退
|
||||
`need_clarification=false`(不阻塞整章重写主链路)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
context = _build_rewrite_clarify_input(body.feedback, body.prior_draft)
|
||||
decision = await run_clarify(
|
||||
clarify_rewrite_spec, # 不变量 #2:analyst 档,不传 model
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit(否则记账丢失)。
|
||||
await session.commit()
|
||||
|
||||
log.info(
|
||||
"rewrite_clarify_done",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
feedback_len=len(body.feedback), # 脱敏:只记长度,不记正文(不变量/日志纪律)
|
||||
need_clarification=decision.need_clarification,
|
||||
question_count=len(decision.questions),
|
||||
)
|
||||
return decision
|
||||
|
||||
|
||||
@router.put("/{project_id}/chapters/{chapter_no}/draft")
|
||||
async def save_draft(
|
||||
project_id: uuid.UUID,
|
||||
@@ -382,7 +565,7 @@ async def _resolve_review_draft(
|
||||
return saved.content
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/review")
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/review", responses=_SSE_RESPONSE)
|
||||
async def review_chapter(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
@@ -479,6 +662,7 @@ async def list_reviews(
|
||||
foreshadow_sug=v.foreshadow_sug,
|
||||
style=v.style,
|
||||
pace=v.pace,
|
||||
characterization=v.characterization,
|
||||
health_score=v.health_score,
|
||||
decisions=v.decisions,
|
||||
)
|
||||
|
||||
@@ -15,20 +15,24 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import RuleWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo
|
||||
from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.rules import RuleCreateRequest, RuleView
|
||||
from ww_api.services.project_deps import get_rule_write_repo
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_project_repo, get_rule_write_repo
|
||||
|
||||
log = get_logger("ww.api.rules")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["rules"])
|
||||
|
||||
RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@@ -38,10 +42,16 @@ async def create_rule(
|
||||
body: RuleCreateRequest,
|
||||
request: Request,
|
||||
repo: RuleWriteRepoDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
session: SessionDep,
|
||||
) -> RuleView:
|
||||
"""新增一条规则(201)。非法 level / 空 content → FastAPI 422。"""
|
||||
"""新增一条规则(201)。非法 level / 空 content → FastAPI 422;项目不存在 → 404。
|
||||
|
||||
项目存在性须在 insert 前校验:否则 FK 违例会逃逸成 500(QA H2),而非干净的 404。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
view = await repo.create(project_id, level=body.level, content=body.content)
|
||||
await session.commit()
|
||||
log.info(
|
||||
@@ -50,4 +60,34 @@ async def create_rule(
|
||||
request_id=request_id,
|
||||
level=body.level,
|
||||
)
|
||||
return RuleView(level=view.level, content=view.content)
|
||||
return RuleView(id=view.id, level=view.level, content=view.content)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/rules/{rule_id}", status_code=204)
|
||||
async def delete_rule(
|
||||
project_id: uuid.UUID,
|
||||
rule_id: uuid.UUID,
|
||||
request: Request,
|
||||
repo: RuleWriteRepoDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
session: SessionDep,
|
||||
) -> Response:
|
||||
"""删除一条本作品规则(204)。项目不存在 → 404;规则不存在 / 不属于该项目 → 404。
|
||||
|
||||
删规则是**作者显式动作**(不变量 #3:规则增删不经 AI 静默写库)。repo.delete 只 flush,
|
||||
端点提交;删不到行(未知 id / 跨项目)→ 不提交、抛 404。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
deleted = await repo.delete(project_id, rule_id)
|
||||
if not deleted:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"rule not found: {rule_id}")
|
||||
await session.commit()
|
||||
log.info(
|
||||
"rule_deleted",
|
||||
project_id=str(project_id),
|
||||
rule_id=str(rule_id),
|
||||
request_id=request_id,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -22,10 +22,10 @@ from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import refiner_spec, style_extract_spec
|
||||
from ww_agents import ClarifyDecision, clarify_refine_spec, refiner_spec, style_extract_spec
|
||||
from ww_core.domain import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
|
||||
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo
|
||||
from ww_core.orchestrator import run_style_extraction
|
||||
from ww_core.orchestrator import run_clarify, run_style_extraction
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
@@ -34,6 +34,7 @@ from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.style import (
|
||||
DimensionEntry,
|
||||
RefineClarifyRequest,
|
||||
RefineRequest,
|
||||
RefineResponse,
|
||||
StyleFingerprintResponse,
|
||||
@@ -45,6 +46,7 @@ from ww_api.services.foreshadow_scan import SessionFactory
|
||||
from ww_api.services.job_runner import run_job
|
||||
from ww_api.services.project_deps import (
|
||||
build_gateway_for_tier,
|
||||
get_clarify_gateway,
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
get_refine_gateway,
|
||||
@@ -62,6 +64,7 @@ JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
|
||||
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
|
||||
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
|
||||
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
|
||||
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
|
||||
|
||||
@@ -238,3 +241,59 @@ async def refine_segment(
|
||||
has_instruction=body.instruction is not None,
|
||||
)
|
||||
return RefineResponse(original=body.segment, refined=resp.text)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/chapters/{chapter_no}/refine/clarify",
|
||||
responses={
|
||||
404: {"model": ErrorEnvelope, "description": "项目不存在"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
},
|
||||
)
|
||||
async def clarify_refine(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
body: RefineClarifyRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
gateway: ClarifyGatewayDep,
|
||||
session: SessionDep,
|
||||
) -> ClarifyDecision:
|
||||
"""润色预检澄清(WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON,只判不改。
|
||||
|
||||
独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项,
|
||||
清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点)。analyst 档(不变量 #2)。
|
||||
|
||||
项目不存在 → 404;无凭据 → 503(dep 解析阶段拦下)。**只读不写库**(不变量 #3);
|
||||
末尾 `commit()` 让网关 usage_ledger 落库(add-only,否则记账静默丢失,同 refine 纪律)。
|
||||
判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
context = _build_refine_input(body.segment, body.instruction or None)
|
||||
decision = await run_clarify(
|
||||
clarify_refine_spec, # 不变量 #2:analyst 档,不传 model
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit(否则记账丢失)。
|
||||
await session.commit()
|
||||
|
||||
log.info(
|
||||
"refine_clarify_done",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
segment_len=len(body.segment),
|
||||
instruction_len=len(body.instruction),
|
||||
need_clarification=decision.need_clarification,
|
||||
question_count=len(decision.questions),
|
||||
)
|
||||
return decision
|
||||
|
||||
@@ -23,6 +23,7 @@ from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.pagination import PageDep
|
||||
from ww_api.schemas.templates import TemplateCreateRequest, TemplateResponse
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_template_repo
|
||||
@@ -36,9 +37,9 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]:
|
||||
"""列出当前用户(stub)的模板。"""
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID)
|
||||
async def list_templates(repo: TemplateRepoDep, page: PageDep) -> list[TemplateResponse]:
|
||||
"""列出当前用户(stub)的模板(分页;`?limit=&offset=`,默认第一页)。"""
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
|
||||
return [
|
||||
TemplateResponse(
|
||||
id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key
|
||||
|
||||
@@ -25,7 +25,7 @@ from ww_core.domain.outline_write_repo import OutlineWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo
|
||||
from ww_core.domain.world_entity_repo import WorldEntityWriteRepo
|
||||
from ww_core.domain.world_entity_repo import WorldEntityFields, WorldEntityWriteRepo
|
||||
from ww_core.orchestrator import run_generator
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
@@ -427,12 +427,12 @@ async def _ingest_world_entities(
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
for entity in allowed.get("world_entities", []):
|
||||
view = await world_write_repo.create(
|
||||
project_id, type=entity.type, name=entity.name, rules=list(entity.rules)
|
||||
)
|
||||
created.append(view.name)
|
||||
fields = [
|
||||
WorldEntityFields(type=entity.type, name=entity.name, rules=list(entity.rules))
|
||||
for entity in allowed.get("world_entities", [])
|
||||
]
|
||||
# 批量插入(一次 flush 落全部),保序返回逐条 View。
|
||||
created = [view.name for view in await world_write_repo.create_many(project_id, fields)]
|
||||
|
||||
await session.commit()
|
||||
log.info(
|
||||
|
||||
74
apps/api/ww_api/schemas/ai_messages.py
Normal file
74
apps/api/ww_api/schemas/ai_messages.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""AI 对话(聊天记录)端点的请求/响应 schema(AC-2 / ai-chat-history-plan §3.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
|
||||
`Literal` 是 kind/role 的**枚举权威**(DB 列自由 Text,边界在此收窄):非法 kind/role → 422。
|
||||
批级字段(thread_id/chapter_no/kind/tool_key)提到请求顶层,`messages` 是本批各条 bubble。
|
||||
长文进 `content`(有手稿级上限),`meta` 有序列化上界兜底防绕过 content 上限。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints, field_validator
|
||||
|
||||
# kind/role 的枚举权威(DB 列自由 Text;加新 kind 改这里 + 零迁移)。
|
||||
AiKind = Literal["refine", "rewrite", "clarify", "continue", "generator"]
|
||||
AiRole = Literal["author", "ai"]
|
||||
|
||||
# content 上界 = 手稿上限(rewrite 整章文本级);命名常量非魔法数(CR-H9)。
|
||||
AI_MESSAGE_CONTENT_MAX = 200_000
|
||||
# meta 序列化上界兜底:防长文绕过 content 上限塞进 meta。
|
||||
_META_MAX_SERIALIZED = 50_000
|
||||
# 一次 append 的最大条数(一次交换的多轮 bubble)。
|
||||
_MAX_TURNS_PER_CALL = 50
|
||||
|
||||
|
||||
class AiMessageInput(BaseModel):
|
||||
"""一条待落库 bubble(角色 + 人读文本 + 结构化 meta)。"""
|
||||
|
||||
role: AiRole
|
||||
content: Annotated[str, StringConstraints(min_length=1, max_length=AI_MESSAGE_CONTENT_MAX)]
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("meta")
|
||||
@classmethod
|
||||
def _bound_meta(cls, v: dict[str, Any]) -> dict[str, Any]:
|
||||
if len(json.dumps(v, ensure_ascii=False)) > _META_MAX_SERIALIZED:
|
||||
raise ValueError("meta too large; put long text in content")
|
||||
return v
|
||||
|
||||
|
||||
class AiMessageAppendRequest(BaseModel):
|
||||
"""POST /projects/{id}/ai-messages:一次交换的一批 bubble(批级字段提顶层)。"""
|
||||
|
||||
thread_id: uuid.UUID
|
||||
chapter_no: Annotated[int, Field(ge=1)] | None = None # NULL = 项目级(工具箱)
|
||||
kind: AiKind
|
||||
tool_key: str | None = None
|
||||
messages: Annotated[list[AiMessageInput], Field(min_length=1, max_length=_MAX_TURNS_PER_CALL)]
|
||||
|
||||
|
||||
class AiMessageView(BaseModel):
|
||||
"""聊天留痕视图(append/list 回显;snake_case,镜像 repo view)。"""
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
chapter_no: int | None
|
||||
thread_id: uuid.UUID
|
||||
seq: int
|
||||
kind: str
|
||||
tool_key: str | None
|
||||
role: str
|
||||
content: str
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AiMessageListResponse(BaseModel):
|
||||
"""append/list 信封:`messages` 复数键(repo 约定,非 `items`)。"""
|
||||
|
||||
messages: list[AiMessageView]
|
||||
@@ -11,12 +11,18 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。代号=短标识;标题=一句话。
|
||||
_CODE_MAX = 100
|
||||
_TITLE_MAX = 500
|
||||
|
||||
|
||||
class ForeshadowRegisterRequest(BaseModel):
|
||||
"""POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。"""
|
||||
|
||||
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一")
|
||||
title: str = Field(min_length=1, description="伏笔标题/一句话描述")
|
||||
code: str = Field(
|
||||
min_length=1, max_length=_CODE_MAX, description="伏笔代号,`(project_id, code)` 唯一"
|
||||
)
|
||||
title: str = Field(min_length=1, max_length=_TITLE_MAX, description="伏笔标题/一句话描述")
|
||||
planted_at: int | None = Field(default=None, description="埋设章号")
|
||||
content: str | None = Field(default=None, description="伏笔正文/线索")
|
||||
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")
|
||||
|
||||
@@ -12,13 +12,16 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from ww_api.schemas.rules import RuleView
|
||||
|
||||
# 批量生成数量上限(防一次性巨量调用;原型保守值)。
|
||||
MAX_GENERATE_COUNT = 12
|
||||
|
||||
# 一句话需求文本上界(CR-H9:防超长入参撑爆上下文/成本)。
|
||||
_BRIEF_MAX = 10_000
|
||||
|
||||
|
||||
# ---- 世界观生成(预览)----
|
||||
|
||||
@@ -26,7 +29,9 @@ MAX_GENERATE_COUNT = 12
|
||||
class WorldGenerateRequest(BaseModel):
|
||||
"""POST /projects/:id/world/generate:据需求生成世界观实体(预览)。"""
|
||||
|
||||
brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)")
|
||||
brief: str = Field(
|
||||
min_length=1, max_length=_BRIEF_MAX, description="世界观生成需求(题材/设定方向/约束)"
|
||||
)
|
||||
|
||||
|
||||
class WorldEntityCardView(BaseModel):
|
||||
@@ -47,11 +52,40 @@ class WorldGenPreviewResponse(BaseModel):
|
||||
|
||||
|
||||
class CharacterGenerateRequest(BaseModel):
|
||||
"""POST /projects/:id/characters/generate:据需求生成角色卡(预览,群像防雷同)。"""
|
||||
"""POST /projects/:id/characters/generate:据需求生成角色卡(预览,群像防雷同)。
|
||||
|
||||
brief: str = Field(min_length=1, description="角色生成需求")
|
||||
count: int = Field(default=1, ge=1, le=MAX_GENERATE_COUNT, description="生成数量")
|
||||
`role_mix`(⑦ 群像按定位配比)与 `count` 的关系:`role_mix` 在场时 `count` 取各值之和
|
||||
(忽略客户端显式 count),每项数量须 ≥ 1、总和须 ≤ `MAX_GENERATE_COUNT`;缺省则退回
|
||||
单一 `role` + `count` 语义。
|
||||
"""
|
||||
|
||||
brief: str = Field(min_length=1, max_length=_BRIEF_MAX, description="角色生成需求")
|
||||
count: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=MAX_GENERATE_COUNT,
|
||||
description="生成数量(role_mix 在场时取各值之和)",
|
||||
)
|
||||
role: str | None = Field(default=None, description="角色定位(主角/CP/对手/导师/工具人 等)")
|
||||
role_mix: dict[str, int] | None = Field(
|
||||
default=None,
|
||||
description="按定位配比生成 {定位: 数量};在场时 count=各值之和(每项≥1、总和≤12)",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _reconcile_role_mix(self) -> CharacterGenerateRequest:
|
||||
"""配比在场时校验并把 count 定为各值之和(构造期归一化,非外部对象变更)。"""
|
||||
if self.role_mix is None:
|
||||
return self
|
||||
if not self.role_mix:
|
||||
raise ValueError("role_mix 不能为空对象")
|
||||
if any(n < 1 for n in self.role_mix.values()):
|
||||
raise ValueError("role_mix 每个定位的数量须 ≥ 1")
|
||||
total = sum(self.role_mix.values())
|
||||
if total > MAX_GENERATE_COUNT:
|
||||
raise ValueError(f"role_mix 数量总和须 ≤ {MAX_GENERATE_COUNT}")
|
||||
self.count = total
|
||||
return self
|
||||
|
||||
|
||||
class CharacterRelationView(BaseModel):
|
||||
@@ -67,7 +101,9 @@ class CharacterCardView(BaseModel):
|
||||
|
||||
name: str = Field(description="角色名")
|
||||
role: str = Field(description="角色定位")
|
||||
traits: list[str] = Field(default_factory=list, description="性格特质清单")
|
||||
traits: list[str] = Field(default_factory=list, description="性格特质清单(性格三层)")
|
||||
appearance: str = Field(default="", description="外貌形象(缺则空串)")
|
||||
motive: str = Field(default="", description="核心动机/欲望/恐惧(缺则空串)")
|
||||
backstory: str = Field(description="背景故事")
|
||||
arc: str = Field(description="人物弧光(一句话)")
|
||||
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")
|
||||
|
||||
@@ -6,21 +6,31 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# 请求文本长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;章正文=整章级。
|
||||
_TITLE_MAX = 200
|
||||
_CHAPTER_TEXT_MAX = 200_000
|
||||
_DIRECTIVE_MAX = 20_000 # CR-H9 收尾:本章指令喂 LLM,加上界(正文级 body 中间件仍兜底)
|
||||
|
||||
|
||||
class ProjectCreateRequest(BaseModel):
|
||||
"""POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。"""
|
||||
|
||||
title: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1, max_length=_TITLE_MAX)
|
||||
genre: str | None = None
|
||||
logline: str | None = None
|
||||
premise: str | None = None
|
||||
theme: str | None = None
|
||||
selling_points: list[str] = Field(default_factory=list)
|
||||
structure: str | None = None
|
||||
# 立项书级常量(灵感④)——free text + 前端预设,无 Literal(Literal 只留 Verdict)。
|
||||
tone: str | None = None
|
||||
ending_type: str | None = None
|
||||
narrative_pov: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
@@ -34,6 +44,11 @@ class ProjectResponse(BaseModel):
|
||||
theme: str | None = None
|
||||
selling_points: list[str] = Field(default_factory=list)
|
||||
structure: str | None = None
|
||||
tone: str | None = None
|
||||
ending_type: str | None = None
|
||||
narrative_pov: str | None = None
|
||||
updated_at: datetime | None = Field(default=None, description="项目最近更新时间")
|
||||
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
|
||||
|
||||
|
||||
class ProjectListResponse(BaseModel):
|
||||
@@ -42,6 +57,64 @@ class ProjectListResponse(BaseModel):
|
||||
projects: list[ProjectResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---- AI 立项方案生成(灵感⑤;向导阶段 project 未建,纯预览不写库)----
|
||||
|
||||
# 请求字段长度上界(CR-H9:防超长向导草稿撑爆上下文/成本)。短字段=标签级,长字段=段落级。
|
||||
_PLAN_SHORT_MAX = 200
|
||||
_PLAN_LONG_MAX = 4000
|
||||
_PLAN_LIST_MAX = 20
|
||||
|
||||
|
||||
class ProjectPlanGenerateRequest(BaseModel):
|
||||
"""POST /skills/project-plan/generate:向导草稿种子(不带 project 前缀,立项前 project 未建)。
|
||||
|
||||
**种子门控**:`genre` + `logline` 为必给种子(端点校验非空,否则 422)——空向导不出方案,
|
||||
防 slop。其余字段有则贴合、无则不臆造(序列化进 project_context,仿 `_project_context`)。
|
||||
全字段带 `max_length` 上界(CR-H9),`brief` = 作者一句话方向(可空)。
|
||||
"""
|
||||
|
||||
genre: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="题材(种子)")
|
||||
logline: str | None = Field(
|
||||
default=None, max_length=_PLAN_LONG_MAX, description="一句话故事(种子)"
|
||||
)
|
||||
title: str | None = Field(
|
||||
default=None, max_length=_PLAN_SHORT_MAX, description="暂定书名(可空)"
|
||||
)
|
||||
premise: str | None = Field(default=None, max_length=_PLAN_LONG_MAX, description="立意(可空)")
|
||||
theme: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="主题(可空)")
|
||||
structure: str | None = Field(
|
||||
default=None, max_length=_PLAN_SHORT_MAX, description="故事结构(可空)"
|
||||
)
|
||||
tone: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="基调(可空)")
|
||||
ending_type: str | None = Field(
|
||||
default=None, max_length=_PLAN_SHORT_MAX, description="结局取向(可空)"
|
||||
)
|
||||
narrative_pov: str | None = Field(
|
||||
default=None, max_length=_PLAN_SHORT_MAX, description="叙事视角(可空)"
|
||||
)
|
||||
selling_points: list[str] = Field(
|
||||
default_factory=list, max_length=_PLAN_LIST_MAX, description="核心卖点(可空)"
|
||||
)
|
||||
# `str | None`(非 `str=""`):codegen 渲染为可选字段,前端种子请求可省略(brief 本就可空)。
|
||||
brief: str | None = Field(
|
||||
default=None, max_length=_PLAN_LONG_MAX, description="作者一句话方向/需求(可空)"
|
||||
)
|
||||
|
||||
|
||||
class ProjectPlanView(BaseModel):
|
||||
"""立项方案生成结果(贴 ww_agents.ProjectPlanResult;结构化预览,不入库)。
|
||||
|
||||
全字段有默认值(守解析韧性)——前端按字段回填立项向导,只填非空交集、不覆盖作者已编辑值。
|
||||
"""
|
||||
|
||||
title_candidates: list[str] = Field(default_factory=list, description="书名候选清单")
|
||||
setting: str = Field(default="", description="时空背景")
|
||||
narrative_structure: str = Field(default="", description="叙事结构")
|
||||
story_core: str = Field(default="", description="故事核心")
|
||||
ending_design: str = Field(default="", description="结局设计")
|
||||
tone: str = Field(default="", description="基调")
|
||||
|
||||
|
||||
class DraftStreamRequest(BaseModel):
|
||||
"""POST /projects/:id/chapters/:no/draft:本章生成的可选输入(T4-b)。
|
||||
|
||||
@@ -49,13 +122,44 @@ class DraftStreamRequest(BaseModel):
|
||||
后端只入 volatile(守缓存前缀不变量 #9)。无 body 时退化为纯按大纲生成(向后兼容)。
|
||||
"""
|
||||
|
||||
directive: str | None = None
|
||||
directive: str | None = Field(default=None, max_length=_DIRECTIVE_MAX)
|
||||
|
||||
|
||||
# 整章重写输入上界(守 DoS/成本,CR-H9 方向):意见短、整章草稿大(复用章正文上界,DRY)。
|
||||
_REWRITE_FEEDBACK_MAX = 4000
|
||||
_REWRITE_DRAFT_MAX = _CHAPTER_TEXT_MAX
|
||||
|
||||
|
||||
class RewriteStreamRequest(BaseModel):
|
||||
"""POST /projects/:id/chapters/:no/rewrite:整章再沟通/重写。
|
||||
|
||||
`feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆
|
||||
注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile(守缓存前缀不变量 #9)。
|
||||
新版停前端/草稿,接受才落库(HITL,不变量 #3)。
|
||||
"""
|
||||
|
||||
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
|
||||
prior_draft: str = Field(min_length=1, max_length=_REWRITE_DRAFT_MAX)
|
||||
|
||||
|
||||
class RewriteClarifyRequest(BaseModel):
|
||||
"""整章重写预检澄清:章级意见 + 可选当前草稿(WFW-9 M2 路线A 两阶段之「问题」阶段)。
|
||||
|
||||
与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文**
|
||||
(整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景,
|
||||
可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检,
|
||||
不整章入 LLM)。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents,端点直接返回,
|
||||
供前端 gen:api 生成强类型客户端)。
|
||||
"""
|
||||
|
||||
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
|
||||
prior_draft: str | None = Field(default=None, max_length=_REWRITE_DRAFT_MAX)
|
||||
|
||||
|
||||
class DraftSaveRequest(BaseModel):
|
||||
"""PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""
|
||||
|
||||
text: str
|
||||
text: str = Field(max_length=_CHAPTER_TEXT_MAX)
|
||||
|
||||
|
||||
class DraftResponse(BaseModel):
|
||||
@@ -85,6 +189,23 @@ class DraftView(BaseModel):
|
||||
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)----
|
||||
|
||||
|
||||
@@ -94,7 +215,7 @@ class ReviewRequest(BaseModel):
|
||||
不传 `draft` 时端点回退到已保存的草稿(chapter_repo)。
|
||||
"""
|
||||
|
||||
draft: str | None = None
|
||||
draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX)
|
||||
|
||||
|
||||
class ReviewConflictView(BaseModel):
|
||||
@@ -123,6 +244,8 @@ class ReviewHistoryItem(BaseModel):
|
||||
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
|
||||
style: dict[str, Any] | None = None
|
||||
pace: dict[str, Any] | None = None
|
||||
# 灵感③ advisory 人物塑造审留痕(issues 列表 dict);仅建议、不阻断验收。
|
||||
characterization: dict[str, Any] | None = None
|
||||
health_score: int | None = None
|
||||
decisions: dict[str, Any] | None = None
|
||||
|
||||
@@ -150,7 +273,9 @@ class ConflictDecision(BaseModel):
|
||||
class AcceptRequest(BaseModel):
|
||||
"""POST /projects/:id/chapters/:no/accept:裁决清单 + 可能改过的终稿。"""
|
||||
|
||||
final_text: str = Field(min_length=1, description="作者裁决/改稿后的最终验收文本")
|
||||
final_text: str = Field(
|
||||
min_length=1, max_length=_CHAPTER_TEXT_MAX, description="作者裁决/改稿后的最终验收文本"
|
||||
)
|
||||
decisions: list[ConflictDecision] = Field(
|
||||
default_factory=list, description="对最近审稿每个冲突的裁决(每冲突必有其一,R5)"
|
||||
)
|
||||
|
||||
@@ -6,6 +6,12 @@ snake_case;响应一律 **掩码 Key**,绝不含明文。前端经 OpenAPI
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from ww_llm_gateway.types import Tier
|
||||
|
||||
# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。
|
||||
_PROVIDER_MAX = 64
|
||||
_MODEL_MAX = 128
|
||||
_API_KEY_MAX = 512
|
||||
|
||||
|
||||
class ProviderView(BaseModel):
|
||||
@@ -34,16 +40,17 @@ class ProvidersResponse(BaseModel):
|
||||
class ProviderCredentialInput(BaseModel):
|
||||
"""单条提供商凭据写入。"""
|
||||
|
||||
provider: str = Field(min_length=1)
|
||||
api_key: str = Field(min_length=1) # 明文入站,加密入库,绝不回显
|
||||
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||
api_key: str = Field(min_length=1, max_length=_API_KEY_MAX) # 明文入站,加密入库,绝不回显
|
||||
|
||||
|
||||
class TierRoutingInput(BaseModel):
|
||||
"""单条档位路由写入。"""
|
||||
|
||||
tier: str = Field(min_length=1)
|
||||
provider: str = Field(min_length=1)
|
||||
model: str = Field(min_length=1)
|
||||
# tier 限定已知档位 writer/analyst/light;未知档位 → 422(QA MEDIUM:原接受任意字符串)。
|
||||
tier: Tier
|
||||
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||
model: str = Field(min_length=1, max_length=_MODEL_MAX)
|
||||
fallback: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -57,7 +64,7 @@ class ProvidersUpsertRequest(BaseModel):
|
||||
class TestConnectionRequest(BaseModel):
|
||||
"""POST /test:最小探测请求。"""
|
||||
|
||||
provider: str = Field(min_length=1)
|
||||
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||
|
||||
|
||||
class CapabilitiesView(BaseModel):
|
||||
|
||||
@@ -7,22 +7,35 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
import uuid
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
RuleLevel = Literal["global", "genre", "style", "project"]
|
||||
|
||||
# 规则正文长度上界(CR-H9:防超长入参 DoS/成本)。
|
||||
_RULE_CONTENT_MAX = 10_000
|
||||
|
||||
# 先 strip 再校验长度:纯空白内容(" ")strip 后为空 → 422(QA MEDIUM:此前被接受入库)。
|
||||
RuleContent = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_RULE_CONTENT_MAX)
|
||||
]
|
||||
|
||||
|
||||
class RuleCreateRequest(BaseModel):
|
||||
"""POST /projects/:id/rules:新增一条规则。"""
|
||||
|
||||
level: RuleLevel = Field(description="规则级别(global/genre/style/project,越具体越优先)")
|
||||
content: str = Field(min_length=1, description="规则正文")
|
||||
content: RuleContent = Field(description="规则正文(首尾空白会被裁剪,不可全空白)")
|
||||
|
||||
|
||||
class RuleView(BaseModel):
|
||||
"""规则视图(创建后回显;snake_case)。"""
|
||||
"""规则视图(创建后回显 + 列表项;snake_case)。
|
||||
|
||||
`id` 是该规则行的稳定主键——前端规则页用它作删除 handle(DELETE /rules/{rule_id})。
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
level: str
|
||||
content: str
|
||||
|
||||
@@ -7,15 +7,26 @@ snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
# 请求文本长度上界(CR-H9:防超长入参空跑 LLM/DoS)。选段/样本 20k 字符,样本列表 ≤20 条。
|
||||
_SEGMENT_MAX = 20_000
|
||||
_SAMPLE_MAX = 20_000
|
||||
_SAMPLES_LIST_MAX = 20
|
||||
# 预检澄清沿用相同选段上界(DRY);指令短。
|
||||
_MAX_CLARIFY_SEGMENT_LEN = _SEGMENT_MAX
|
||||
_MAX_CLARIFY_INSTRUCTION_LEN = 2000
|
||||
|
||||
|
||||
class StyleLearnRequest(BaseModel):
|
||||
"""学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。"""
|
||||
|
||||
samples: list[str] = Field(min_length=1)
|
||||
# 同时约束**列表条数**与**每条样本长度**(最大 DoS 面:多条超长正文)。
|
||||
samples: list[Annotated[str, StringConstraints(max_length=_SAMPLE_MAX)]] = Field(
|
||||
min_length=1, max_length=_SAMPLES_LIST_MAX
|
||||
)
|
||||
mode: Literal["create", "update"] = "create"
|
||||
|
||||
|
||||
@@ -47,8 +58,9 @@ class StyleFingerprintResponse(BaseModel):
|
||||
class RefineRequest(BaseModel):
|
||||
"""回炉:重写选中段(可选改写指令)。"""
|
||||
|
||||
segment: str = Field(min_length=1)
|
||||
instruction: str | None = None
|
||||
segment: str = Field(min_length=1, max_length=_SEGMENT_MAX)
|
||||
# CR-H9 收尾:再沟通意见喂 LLM,加上界(与 clarify instruction 同口径 2000)。
|
||||
instruction: str | None = Field(default=None, max_length=_MAX_CLARIFY_INSTRUCTION_LEN)
|
||||
|
||||
|
||||
class RefineResponse(BaseModel):
|
||||
@@ -56,3 +68,15 @@ class RefineResponse(BaseModel):
|
||||
|
||||
original: str
|
||||
refined: str
|
||||
|
||||
|
||||
class RefineClarifyRequest(BaseModel):
|
||||
"""润色预检澄清:选段 + 再沟通意见(WFW-9 M1 路线A 两阶段之「问题」阶段)。
|
||||
|
||||
与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的
|
||||
再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision`
|
||||
(在 ww_agents,端点直接返回,供前端 gen:api 生成强类型客户端)。
|
||||
"""
|
||||
|
||||
segment: str = Field(min_length=1, max_length=_MAX_CLARIFY_SEGMENT_LEN)
|
||||
instruction: str = Field(default="", max_length=_MAX_CLARIFY_INSTRUCTION_LEN)
|
||||
|
||||
@@ -12,15 +12,24 @@ from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
# 长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;正文=段落级。
|
||||
_TITLE_MAX = 200
|
||||
_BODY_MAX = 20_000
|
||||
|
||||
# 先去首尾空白再校验长度:纯空白(" ")strip 后为空 → min_length=1 不满足 → 422。
|
||||
NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
NonBlankTitle = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_TITLE_MAX)
|
||||
]
|
||||
NonBlankBody = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_BODY_MAX)
|
||||
]
|
||||
|
||||
|
||||
class TemplateCreateRequest(BaseModel):
|
||||
"""POST /templates:新建一条提示词模板。"""
|
||||
|
||||
title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422)")
|
||||
body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text)")
|
||||
title: NonBlankTitle = Field(description="模板标题(非空,纯空白 → 422)")
|
||||
body: NonBlankBody = Field(description="模板正文(非空,一键填入生成器的 brief/text)")
|
||||
category: str | None = Field(default=None, description="可选分类")
|
||||
tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)")
|
||||
|
||||
|
||||
@@ -28,6 +28,18 @@ if TYPE_CHECKING:
|
||||
CheckpointerFactory = Callable[[], AbstractAsyncContextManager[Any]]
|
||||
|
||||
|
||||
def _libpq_conn_string() -> str:
|
||||
"""从 settings 的 sync URL 派生 psycopg 的 libpq conn string。
|
||||
|
||||
`database_url_sync` 带 SQLAlchemy 驱动后缀(``postgresql+psycopg://…``),但
|
||||
`AsyncPostgresSaver.from_conn_string` 直接交给 psycopg 解析——psycopg 不识别
|
||||
``+psycopg``/``+asyncpg`` 方言段,会抛 ``ProgrammingError: missing "=" …``。
|
||||
故剥离后缀还原为标准 libpq 串(``postgresql://…``)。与迁移 `d3e4f5a6b7c8`
|
||||
的 `_psycopg_conn_string()` 同义。
|
||||
"""
|
||||
return get_settings().database_url_sync.replace("+psycopg", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def get_checkpointer_factory() -> CheckpointerFactory:
|
||||
"""运行时 checkpointer 工厂:建连 `DATABASE_URL` 的 `AsyncPostgresSaver` 上下文。
|
||||
|
||||
@@ -38,7 +50,8 @@ def get_checkpointer_factory() -> CheckpointerFactory:
|
||||
def _factory() -> AbstractAsyncContextManager[BaseCheckpointSaver[Any]]:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
|
||||
# langgraph 的 Postgres saver 走 psycopg(同步驱动串);用 sync 串建连接。
|
||||
return AsyncPostgresSaver.from_conn_string(get_settings().database_url_sync)
|
||||
# langgraph 的 Postgres saver 走 psycopg(同步驱动串);须剥离 SQLAlchemy 驱动
|
||||
# 后缀(+psycopg),否则 psycopg 解析连接串报 ProgrammingError。
|
||||
return AsyncPostgresSaver.from_conn_string(_libpq_conn_string())
|
||||
|
||||
return _factory
|
||||
|
||||
@@ -50,6 +50,11 @@ log = structlog.get_logger(__name__)
|
||||
# 链 job 的 kind(jobs.kind 自由 Text 列,零迁移复用;§7)。
|
||||
JOB_KIND_CHAIN = "chain"
|
||||
|
||||
# 每章在链图里的超级步数(write→review→decide→accept),用于算 recursion_limit 上界。
|
||||
NODES_PER_CHAPTER = 4 # 每章 4 个超级步:write→review→decide→accept
|
||||
# START 边 + interrupt/resume 重入的余量(防御性冗余,非精确计数)。
|
||||
RECURSION_LIMIT_HEADROOM = 10 # START 边 + interrupt/resume 重入余量
|
||||
|
||||
|
||||
# 「按 session 建 digest 网关」缝:accept 节点在自建短事务里需 light 档网关跑终稿提炼。
|
||||
DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]]
|
||||
@@ -156,13 +161,13 @@ def _chain_result(state: dict[str, Any], *, awaiting_chapter: int | None) -> dic
|
||||
}
|
||||
|
||||
|
||||
def _extract_awaiting_chapter(final: dict[str, Any]) -> int | None:
|
||||
"""从图返回值判 interrupt:有 `__interrupt__` → 取暂停章号;否则 None(跑完)。
|
||||
def _extract_awaiting_chapter(interrupts: Sequence[Any]) -> int | None:
|
||||
"""从 pending interrupts 判暂停章号:空 → None(跑完);否则取首个 interrupt 载荷的章号。
|
||||
|
||||
interrupt 载荷 = `{"chapter_no": n}`(见链图 `_accept` 节点)。LangGraph 把它放在
|
||||
`final["__interrupt__"]`(Interrupt 对象列表)。容错读 `.value`/dict 两种形。
|
||||
interrupts 经**公开** `graph.aget_state(config).interrupts`(`StateSnapshot`)拿到,不再
|
||||
反手 ainvoke 返回值里的私有 `__interrupt__` 键。载荷 = `{"chapter_no": n}`(见链图 `_accept`
|
||||
节点)。容错读 `.value`/dict 两种形。
|
||||
"""
|
||||
interrupts = final.get("__interrupt__")
|
||||
if not interrupts:
|
||||
return None
|
||||
first = interrupts[0]
|
||||
@@ -207,7 +212,14 @@ async def run_chain_job(
|
||||
try:
|
||||
async with checkpointer_ctx() as checkpointer:
|
||||
await _set_running(session_factory, job_id)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": str(job_id)}}
|
||||
# 显式每链 recursion_limit 上界(防御硬化,CR-C2):count 已被端点上限 MAX_CHAIN_COUNT
|
||||
# 夹住,故 count*每章步数+余量 是安全且贴合的上界。**必须放 config 顶层**——放到
|
||||
# `configurable` 下 langgraph 会静默忽略。单份 config 同时喂初始 run 与 resume 分支。
|
||||
recursion_limit = count * NODES_PER_CHAPTER + RECURSION_LIMIT_HEADROOM
|
||||
config: RunnableConfig = {
|
||||
"configurable": {"thread_id": str(job_id)},
|
||||
"recursion_limit": recursion_limit,
|
||||
}
|
||||
graph = build_chain_graph(
|
||||
chain_gateway_builder,
|
||||
session_factory=session_factory,
|
||||
@@ -232,9 +244,11 @@ async def run_chain_job(
|
||||
resume_value = [d.model_dump() for d in resume_decisions]
|
||||
raw_final = await graph.ainvoke(Command(resume=resume_value), config=config)
|
||||
|
||||
final: dict[str, Any] = dict(raw_final)
|
||||
awaiting_chapter = _extract_awaiting_chapter(final)
|
||||
result = _chain_result(final, awaiting_chapter=awaiting_chapter)
|
||||
# 经公开 `StateSnapshot.interrupts` 判 pending interrupt(不反手私有 `__interrupt__`);
|
||||
# checkpointer 仍在 `checkpointer_ctx()` 上下文内,aget_state 可读同一 thread 检查点。
|
||||
snapshot = await graph.aget_state(config)
|
||||
awaiting_chapter = _extract_awaiting_chapter(snapshot.interrupts)
|
||||
result = _chain_result(dict(raw_final), awaiting_chapter=awaiting_chapter)
|
||||
await _finish(session_factory, job_id, result, awaiting=awaiting_chapter is not None)
|
||||
log.info(
|
||||
"chain_job_settled",
|
||||
|
||||
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)
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
||||
from ww_core.domain.ai_message_repo import AiMessageRepo, SqlAiMessageRepo
|
||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
@@ -52,6 +53,7 @@ from ww_api.security.credentials import (
|
||||
CredentialKeyError,
|
||||
decrypt_api_key,
|
||||
)
|
||||
from ww_api.services.chapter_list import ChapterLister, SqlChapterLister
|
||||
from ww_api.services.credentials import (
|
||||
AUTH_TYPE_OAUTH,
|
||||
STUB_OWNER_ID,
|
||||
@@ -98,6 +100,13 @@ def get_chapter_repo(
|
||||
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(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> MemoryRepos:
|
||||
@@ -160,6 +169,16 @@ def get_rule_write_repo(
|
||||
return SqlRuleWriteRepo(session)
|
||||
|
||||
|
||||
def get_ai_message_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> AiMessageRepo:
|
||||
"""AI 对话留痕 repo(POST/GET /projects/{id}/ai-messages;append 只 flush,端点提交)。
|
||||
|
||||
测试经 `app.dependency_overrides[get_ai_message_repo]` 注入内存 fake。
|
||||
"""
|
||||
return SqlAiMessageRepo(session)
|
||||
|
||||
|
||||
async def get_template_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> TemplateRepo:
|
||||
@@ -268,6 +287,14 @@ async def get_precheck_gateway(
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
async def get_project_plan_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""AI 立项方案生成(analyst 档位)的可注入网关缝。测试注 mock(产 ProjectPlanResult)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
def get_session_factory() -> SessionFactory:
|
||||
"""验收后到期扫描的**独立 session 工厂**缝。
|
||||
|
||||
@@ -511,6 +538,18 @@ async def get_refine_gateway(
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
async def get_clarify_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""润色预检澄清(analyst 档位)的可注入网关缝。
|
||||
|
||||
`POST .../refine/clarify` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
|
||||
(503,进入端点前拦下)。测试经 override 注 mock(产 `ClarifyDecision`)。
|
||||
"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
async def get_chain_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway.adapters.base import Capabilities
|
||||
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
|
||||
from ww_llm_gateway.factory import build_adapter
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
@@ -85,10 +86,17 @@ class GatewayProviderProbe:
|
||||
|
||||
# 经网关工厂构造适配器(base_url 单点归网关 build_adapter,不在此本地构造 AsyncOpenAI)。
|
||||
adapter = build_adapter(provider, api_key=api_key, base_url=base_url)
|
||||
# `_PROVIDER_BASE_URLS` 里的 api_key 型 provider 均映射到 OpenAI 兼容适配器(含
|
||||
# kimi-code-key 子类);据此收窄类型,用公开 `probe_connection` 探测(不反手私有客户端)。
|
||||
if not isinstance(adapter, OpenAICompatAdapter):
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"provider {provider} 不支持 api_key 连接测试",
|
||||
{"provider": provider},
|
||||
)
|
||||
try:
|
||||
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。底层 AsyncOpenAI 由适配器持有
|
||||
# (已知 provider 为 OpenAI 兼容,见 _PROVIDER_BASE_URLS);读私有客户端属同仓既有约定。
|
||||
await adapter._client.models.list() # type: ignore[attr-defined] # noqa: SLF001
|
||||
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
|
||||
await adapter.probe_connection()
|
||||
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
|
||||
13
apps/web/.dockerignore
Normal file
13
apps/web/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# web 镜像构建上下文(context=./apps/web)。排除宿主产物,避免 `COPY . .` 把宿主
|
||||
# node_modules(含平台原生 next-swc/esbuild 二进制)覆盖容器内 `pnpm install` 的 Linux 版,
|
||||
# 导致 `pnpm build` 加载原生绑定失败。同时排除构建产物/日志加速上下文传输、保证可复现。
|
||||
node_modules
|
||||
.next
|
||||
.turbo
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
1
apps/web/.gitignore
vendored
1
apps/web/.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
/.next
|
||||
/lib/api/openapi.json
|
||||
next-env.d.ts
|
||||
.gstack/
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
FROM node:22-slim AS build
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
# npm/corepack registry(可经 build arg 覆盖):部分区域 registry.npmjs.org 不可达,部署时指向镜像
|
||||
# (如 registry.npmmirror.com,整源镜像 npmjs 且保留 integrity,与 --frozen-lockfile 兼容)。
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
||||
ENV npm_config_registry=$NPM_REGISTRY
|
||||
# corepack 默认硬编码从 npmjs 下载 pnpm 本体;此处改指同一镜像。镜像不提供 npm 签名密钥,
|
||||
# 关掉签名校验(下载的 pnpm tarball 仍按内容哈希校验,安装的依赖仍按 lockfile integrity 校验)。
|
||||
ENV COREPACK_NPM_REGISTRY=$NPM_REGISTRY
|
||||
ENV COREPACK_INTEGRITY_KEYS=0
|
||||
RUN corepack enable
|
||||
# pnpm-workspace.yaml 必须在 install 前就位:它携带 minimumReleaseAgeExclude(豁免新发布包的
|
||||
# 供应链策略)、onlyBuiltDependencies/allowBuilds(构建脚本白名单)。缺它则容器内 install 看不到
|
||||
# 这些策略,会误拒近期发布的依赖(如 @xyflow/*)而失败——与宿主行为不一致。
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
||||
# pnpm 11 不读 npm_config_registry env,须用 --registry 显式指定镜像(lockfile v9 仅存 integrity,
|
||||
# 不含绝对 URL,故换源不破坏 --frozen-lockfile)。
|
||||
RUN pnpm install --frozen-lockfile --registry="$NPM_REGISTRY" || pnpm install --registry="$NPM_REGISTRY"
|
||||
COPY . .
|
||||
# NEXT_PUBLIC_* 在 Next 构建期内联进浏览器 bundle——必须在 `pnpm build` 前用 build ARG 注入。
|
||||
# 默认 http://localhost:8000 供 dev;生产经 compose build args 传公网 API 基址。
|
||||
ARG NEXT_PUBLIC_API_BASE=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_API_BASE=$NEXT_PUBLIC_API_BASE
|
||||
RUN pnpm build
|
||||
# 本项目 public/ 为空目录(不入 git,git archive 构建上下文里不存在);确保它存在,
|
||||
# 否则下方 run 阶段 `COPY --from=build /app/public` 会因源缺失而失败。
|
||||
RUN mkdir -p /app/public
|
||||
|
||||
FROM node:22-slim AS run
|
||||
WORKDIR /app
|
||||
@@ -12,4 +32,7 @@ COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
EXPOSE 3000
|
||||
# 存活探测:node 打首页(run 阶段无 curl);5xx/连接失败退出非零。
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["node", "-e", "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
37
apps/web/app/error.tsx
Normal file
37
apps/web/app/error.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export default function Error({ reset }: ErrorBoundaryProps) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
|
||||
<div className="w-full max-w-md">
|
||||
<EmptyState
|
||||
icon={TriangleAlert}
|
||||
title="出错了"
|
||||
description="刚才的操作没能完成。可以重试,或先回到作品库。"
|
||||
action={
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="primary" onClick={reset}>
|
||||
重试
|
||||
</Button>
|
||||
<Link href="/" className={buttonClass({ variant: "secondary" })}>
|
||||
返回作品库
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,88 @@
|
||||
@tailwind utilities;
|
||||
|
||||
/* 纸感设计 token(UX_SPEC §2.1) */
|
||||
:root {
|
||||
:root,
|
||||
[data-theme="paper"] {
|
||||
/* 面色阶:底 → 分隔带 → 内容卡(比底暗一步)→ 选中强调 → 浮起面板 */
|
||||
--color-bg: #f5f1e8;
|
||||
--color-surface-soft: #efe9dc;
|
||||
--color-surface-card: #ece4d3;
|
||||
--color-surface-strong: #e4d9c4;
|
||||
--color-panel: #fbf8f1;
|
||||
/* 文字色阶(5 级):标题 → 强调段 → 正文 → 次级 → 说明/细则 */
|
||||
--color-ink: #2b2620;
|
||||
--color-body-strong: #3a342b;
|
||||
--color-body: #4a4234;
|
||||
--color-ink-soft: #6b6356;
|
||||
/* muted-soft 达 WCAG AA 小字(≥4.5:1)于 bg/card/panel/soft 各面(P4-1)。 */
|
||||
--color-muted-soft: #6f6556;
|
||||
/* 边框:主边框 + 同带内更弱分隔 */
|
||||
--color-line: #e5ddcd;
|
||||
--color-line-soft: #ece4d5;
|
||||
/* 朱砂 coral:主色 + 按压变深 + 奶油化禁用 + 淡底 */
|
||||
--color-cinnabar: #a23b2e;
|
||||
--color-cinnabar-active: #8a2f24;
|
||||
--color-cinnabar-disabled: #d9cdbd;
|
||||
--color-cinnabar-wash: #a23b2e14;
|
||||
--color-conflict: #b5543a;
|
||||
--color-overdue: #c8893a;
|
||||
--color-pass: #5a6b4f;
|
||||
--color-info: #4a5a6b;
|
||||
--color-conflict-mark: #b5543a26;
|
||||
--color-conflict-mark-strong: #b5543a8c;
|
||||
/* 暖深色 callout:仅用于英雄/CTA 时刻(对标 DESIGN.md cta-band-dark),非内容底。 */
|
||||
--color-callout: #201d18;
|
||||
--color-on-callout: #f2ede2;
|
||||
--color-on-callout-soft: #b8ae9c;
|
||||
--shadow-paper: #2b26200f;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* 动效 token(与主题无关,单处定义;夜读模式不覆盖) */
|
||||
:root {
|
||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--dur-fast: 120ms;
|
||||
--dur-base: 180ms;
|
||||
}
|
||||
|
||||
[data-theme="night"] {
|
||||
/* 面色阶:底 → 分隔带 → 浮起面板 → 内容卡(暗面向上抬)→ 选中强调 */
|
||||
--color-bg: #181614;
|
||||
--color-surface-soft: #1e1b17;
|
||||
--color-surface-card: #2a2620;
|
||||
--color-surface-strong: #332e26;
|
||||
--color-panel: #221f1b;
|
||||
/* 文字色阶(5 级) */
|
||||
--color-ink: #efe6d7;
|
||||
--color-body-strong: #ddd2c0;
|
||||
--color-body: #cdc0ab;
|
||||
--color-ink-soft: #b9ab96;
|
||||
/* muted-soft 达 AA 小字(≥4.5:1)于夜读 bg/card/soft 各面(P4-1)。 */
|
||||
--color-muted-soft: #9a8e7c;
|
||||
/* 边框 */
|
||||
--color-line: #3c352c;
|
||||
--color-line-soft: #2f2a23;
|
||||
/* 朱砂 coral */
|
||||
--color-cinnabar: #e07866;
|
||||
--color-cinnabar-active: #c85f4d;
|
||||
--color-cinnabar-disabled: #3c352c;
|
||||
--color-cinnabar-wash: #e0786620;
|
||||
--color-conflict: #ef8a72;
|
||||
--color-overdue: #d8a65d;
|
||||
--color-pass: #95b47d;
|
||||
--color-info: #8ca8ca;
|
||||
--color-conflict-mark: #ef8a7230;
|
||||
--color-conflict-mark-strong: #ef8a7290;
|
||||
/* 夜读下 callout 为更深的凹陷暖井,仍与 bg 拉开层次。 */
|
||||
--color-callout: #100e0c;
|
||||
--color-on-callout: #efe6d7;
|
||||
--color-on-callout-soft: #a89d8a;
|
||||
--shadow-paper: #00000045;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -22,6 +92,11 @@ body {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-cinnabar-wash);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motion(UX §10)。 */
|
||||
.typewriter-cursor {
|
||||
animation: typewriter-blink 1s step-end infinite;
|
||||
@@ -42,7 +117,7 @@ body {
|
||||
|
||||
/* 终稿正文:冲突原文持续高亮(朱砂淡底),点锚点定位时闪烁一下(UX §8.3)。 */
|
||||
.draft-mark {
|
||||
background-color: #b5543a26; /* --color-conflict @ ~15% */
|
||||
background-color: var(--color-conflict-mark);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.draft-mark-flash {
|
||||
@@ -52,14 +127,118 @@ body {
|
||||
@keyframes draft-mark-flash {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #b5543a26;
|
||||
background-color: var(--color-conflict-mark);
|
||||
}
|
||||
30% {
|
||||
background-color: #b5543a8c; /* --color-conflict @ ~55% */
|
||||
background-color: var(--color-conflict-mark-strong);
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题切换图标:显隐由 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-enter {
|
||||
animation: toast-in var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
.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 {
|
||||
animation: none;
|
||||
}
|
||||
@@ -70,4 +249,7 @@ body {
|
||||
animation: none;
|
||||
outline: 2px solid var(--color-conflict);
|
||||
}
|
||||
.toast-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
298
apps/web/app/help/page.tsx
Normal file
298
apps/web/app/help/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import Link from "next/link";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
BookMarked,
|
||||
BookOpen,
|
||||
Blocks,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
Flag,
|
||||
LayoutGrid,
|
||||
ListTree,
|
||||
Moon,
|
||||
PanelLeftClose,
|
||||
PenLine,
|
||||
Route,
|
||||
Search,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { PageContainer } from "@/components/ui/PageContainer";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
// 帮助中心:功能介绍 + 新手上手(静态内容,Server Component)。
|
||||
|
||||
interface Step {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{
|
||||
icon: PenLine,
|
||||
title: "立项,或直接开始写",
|
||||
body: "从一句灵感开始:「先立项」立好核心卖点与一句话故事,再到设定库、大纲把架构搭起来;或「直接开始写」落笔即成书,边写边补设定。",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "让 AI 写这章",
|
||||
body: "在写作台告诉 AI 这章想怎么写,它对着大纲给出草稿(流式出稿)。你可以续写接着写、润色选段、或整章重写——原文永远你说了算。",
|
||||
},
|
||||
{
|
||||
icon: ClipboardCheck,
|
||||
title: "四审,然后验收",
|
||||
body: "写完点「审稿」,AI 从一致性 / 伏笔 / 文风 / 节奏四个维度逐条检查、就地标注冲突。你裁决后验收,系统更新真相源与伏笔账本。",
|
||||
},
|
||||
];
|
||||
|
||||
interface Principle {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const PRINCIPLES: Principle[] = [
|
||||
{
|
||||
title: "把写小说当软件工程",
|
||||
body: "先立架构(世界观 + 人物 + 大纲),再对着规格逐章生成,每章写完即测,全程维护单一真相源。不让 AI 一口气瞎写十万字。",
|
||||
},
|
||||
{
|
||||
title: "单一真相源 · 记忆不衰减",
|
||||
body: "设定、状态、伏笔集中记账;AI 写每一章都按需读取相关记忆(确定性选择,非一次性塞满上下文),对抗长篇遗忘。",
|
||||
},
|
||||
{
|
||||
title: "你始终掌控",
|
||||
body: "AI 是灵感增幅器 + 质检员,不是全自动黑箱。四审只读、只给建议,任何写入都要你裁决采纳,不会静默覆盖你的原文。",
|
||||
},
|
||||
];
|
||||
|
||||
interface Feature {
|
||||
icon: LucideIcon;
|
||||
name: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface FeatureGroup {
|
||||
label: string;
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
const FEATURE_GROUPS: FeatureGroup[] = [
|
||||
{
|
||||
label: "写作",
|
||||
features: [
|
||||
{
|
||||
icon: PenLine,
|
||||
name: "写作台",
|
||||
desc: "对着大纲逐章生成、流式出稿;续写接着写、润色选段、整章重写,随时进入专注模式沉浸写作。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "资料库",
|
||||
features: [
|
||||
{ icon: ListTree, name: "大纲", desc: "卷 → 章 → 节拍逐层拆解;可用「细纲生成器」把某章展开为场景,入库时并入该章节拍。" },
|
||||
{ icon: BookMarked, name: "设定库", desc: "世界观圣经 + 人物卡(动机、人物弧光,像接口约束行为)+ 关系图谱。" },
|
||||
{ icon: Flag, name: "伏笔", desc: "「埋设 → 回收」的显式账本,逾期未收进「已逾期」泳道并标记,不再漏坑。" },
|
||||
{ icon: FileText, name: "文风", desc: "抽取你的文风指纹并注入生成,抵抗机翻腔与风格漂移。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "审阅",
|
||||
features: [
|
||||
{
|
||||
icon: ClipboardCheck,
|
||||
name: "审稿 · 四审",
|
||||
desc: "每章写完即审:一致性 / 伏笔 / 文风 / 节奏,冲突就地标注、逐条裁决后验收。",
|
||||
},
|
||||
{ icon: Route, name: "多章链", desc: "从指定章起循环「写章 → 四审 → 验收」批量推进,遇未决冲突暂停等你裁决再续跑。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "工具与全局",
|
||||
features: [
|
||||
{ icon: Blocks, name: "工具箱", desc: "十多个生成器:脑洞 / 书名 / 简介 / 金手指 / 词条 / 黄金开篇 / 细纲 / 拆书等;部分可一键入库,部分为预览产出。" },
|
||||
{ icon: ShieldCheck, name: "规则", desc: "沉淀你的创作规则,分全局 / 题材 / 文风 / 本作四级,约束 AI 少犯同类错。" },
|
||||
{ icon: LayoutGrid, name: "模板库", desc: "可复用的提示词模板,在工具箱生成器里一键填入需求 / 原文。" },
|
||||
{ icon: Settings, name: "设置", desc: "写手 / 分析 / 轻量三档模型路由 + API Key / Kimi OAuth。" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Tip {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const TIPS: Tip[] = [
|
||||
{ icon: Search, title: "命令面板 ⌘K", body: "任意页面按 ⌘K 快速跳转页面或执行命令。" },
|
||||
{ icon: PanelLeftClose, title: "专注写作模式", body: "写作台底栏点「专注」,隐藏侧栏、加宽正文,沉浸写作。" },
|
||||
{ icon: Moon, title: "夜读模式", body: "顶栏月亮图标切换暖色夜读,长时间写作更护眼。" },
|
||||
{ icon: CheckCircle2, title: "原文永远你说了算", body: "AI 给的是草稿,采不采用由你定,绝不静默覆盖你的正文。" },
|
||||
];
|
||||
|
||||
interface Faq {
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
const FAQS: Faq[] = [
|
||||
{
|
||||
q: "AI 会自动改我的正文吗?",
|
||||
a: "不会。四审是只读的、只给建议;任何写入都要你在验收环节裁决,冲突未解决会阻止验收。",
|
||||
},
|
||||
{
|
||||
q: "用的是哪个大模型?",
|
||||
a: "后端是多提供商网关,写手 / 分析 / 轻量三档可各自路由(Claude / DeepSeek / Kimi / GPT / Gemini / 通义 / GLM 等,可回退),在「设置 → 模型与提供商」里配。",
|
||||
},
|
||||
{
|
||||
q: "写到几十万字会不会记忆衰减?",
|
||||
a: "靠单一真相源 + 按需记忆注入:写每章只挑相关的设定、角色与最近若干章,而不是把全书塞进上下文。",
|
||||
},
|
||||
{
|
||||
q: "我的数据在哪里?",
|
||||
a: "本地 Postgres,单用户原型;设定/正文/审稿结果都以数据库为唯一真相源,记忆追加保留历史、不就地覆盖。",
|
||||
},
|
||||
];
|
||||
|
||||
function SectionTitle({ eyebrow, title }: { eyebrow: string; title: string }) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<Eyebrow className="mb-1.5">{eyebrow}</Eyebrow>
|
||||
<h2 className="font-serif text-display-sm text-ink">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconChip({ icon: Icon }: { icon: LucideIcon }) {
|
||||
return (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HelpPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PageContainer>
|
||||
{/* 英雄:暖深色编辑部条(对标 DESIGN.md cta-band-dark)。 */}
|
||||
<section className="mb-12 rounded-xl border border-line/50 bg-callout px-8 py-12 shadow-paper sm:px-10">
|
||||
<Eyebrow className="text-on-callout-soft">墨痕 · 使用指南</Eyebrow>
|
||||
<h1 className="mt-3 max-w-2xl font-serif text-display-md text-on-callout">
|
||||
把写小说,当软件工程来做
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-body leading-7 text-on-callout-soft">
|
||||
立项 → 设定 → 大纲 → 写章 → 四审 → 验收。先立架构再对着规格生成,每章写完即测,全程维护单一真相源。下面三步带你上手,并逐一介绍每个功能。
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<Link href="/write" className={buttonClass({ variant: "primary" })}>
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
直接开始写
|
||||
</Link>
|
||||
<Link href="/projects/new" className={buttonClass({ variant: "secondary" })}>
|
||||
先立项
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 新手三步 */}
|
||||
<section className="mb-14">
|
||||
<SectionTitle eyebrow="快速上手" title="三步写出第一章" />
|
||||
<ol className="grid gap-4 sm:grid-cols-3">
|
||||
{STEPS.map((step, i) => (
|
||||
<li key={step.title}>
|
||||
<Card tone="card" className="flex h-full flex-col gap-3 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<IconChip icon={step.icon} />
|
||||
<span className="font-mono text-caption text-muted-soft">
|
||||
第 {i + 1} 步
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-serif text-title-md text-ink">{step.title}</h3>
|
||||
<p className="text-caption leading-6 text-ink-soft">{step.body}</p>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* 核心理念 */}
|
||||
<section className="mb-14">
|
||||
<SectionTitle eyebrow="为什么这样设计" title="三个核心理念" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{PRINCIPLES.map((p) => (
|
||||
<Card key={p.title} tone="soft" flat className="p-5">
|
||||
<h3 className="font-serif text-title-md text-ink">{p.title}</h3>
|
||||
<p className="mt-2 text-caption leading-6 text-ink-soft">{p.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 功能一览 */}
|
||||
<section className="mb-14">
|
||||
<SectionTitle eyebrow="功能介绍" title="每个板块是做什么的" />
|
||||
<div className="space-y-8">
|
||||
{FEATURE_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h3 className="mb-3 flex items-center gap-2 font-sans text-title-md text-ink">
|
||||
{group.label}
|
||||
<Badge variant="neutral">{group.features.length}</Badge>
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{group.features.map((f) => (
|
||||
<Card key={f.name} className="flex gap-3 p-4">
|
||||
<IconChip icon={f.icon} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-serif text-title-md text-ink">{f.name}</p>
|
||||
<p className="mt-1 text-caption leading-6 text-ink-soft">{f.desc}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 小技巧 */}
|
||||
<section className="mb-14">
|
||||
<SectionTitle eyebrow="顺手小技巧" title="用得更顺" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{TIPS.map((t) => (
|
||||
<div key={t.title} className="flex gap-3">
|
||||
<IconChip icon={t.icon} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-ink">{t.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-6 text-ink-soft">{t.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 常见问题 */}
|
||||
<section>
|
||||
<SectionTitle eyebrow="常见问题" title="你可能想问" />
|
||||
<dl className="space-y-3">
|
||||
{FAQS.map((f) => (
|
||||
<Card key={f.q} as="div" className="p-5">
|
||||
<dt className="font-serif text-title-md text-ink">{f.q}</dt>
|
||||
<dd className="mt-2 text-caption leading-6 text-ink-soft">{f.a}</dd>
|
||||
</Card>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</PageContainer>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
|
||||
import { ThemeScript } from "@/components/ThemeScript";
|
||||
import { ToastProvider } from "@/components/Toast";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -19,6 +20,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased" suppressHydrationWarning>
|
||||
<ThemeScript />
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<CommandPaletteMount />
|
||||
|
||||
79
apps/web/app/loading.tsx
Normal file
79
apps/web/app/loading.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { PageContainer } from "@/components/ui/PageContainer";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
|
||||
// 全站加载骨架:与作品库结构近似(标题条 + 工具条 + 卡片网格),
|
||||
// motion-safe 脉冲,尊重 prefers-reduced-motion。
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="min-h-screen bg-bg">
|
||||
<PageContainer>
|
||||
<div role="status" aria-label="加载中" className="space-y-8">
|
||||
<span className="sr-only">加载中…</span>
|
||||
<HeaderSkeleton />
|
||||
<ToolbarSkeleton />
|
||||
<GridSkeleton />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// 标题区:大标题条 + 说明条 + 右侧两枚按钮位。
|
||||
function HeaderSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72 max-w-full" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 工具条:搜索框 + 三枚控件位。
|
||||
function ToolbarSkeleton() {
|
||||
return (
|
||||
<Card flat className="grid gap-3 p-3 md:grid-cols-[1fr_auto_auto_auto]">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-20" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 卡片网格:6 张与作品卡等高的占位卡。
|
||||
function GridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 单张作品卡骨架:标题 + 徽章 + 三行简介 + 底部时间。
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<Card flat className="flex h-full min-h-[176px] flex-col p-6">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 shrink-0" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
<Skeleton className="mt-auto h-3 w-28" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
24
apps/web/app/not-found.tsx
Normal file
24
apps/web/app/not-found.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { FileQuestion } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-bg px-6 py-16">
|
||||
<div className="w-full max-w-md">
|
||||
<EmptyState
|
||||
icon={FileQuestion}
|
||||
title="页面不存在"
|
||||
description="你要找的内容可能已被移动或从未存在。回到作品库继续创作吧。"
|
||||
action={
|
||||
<Link href="/" className={buttonClass({ variant: "primary" })}>
|
||||
返回作品库
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, PenLine, Plus } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
|
||||
import { PageContainer } from "@/components/ui/PageContainer";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { fetchProjects } from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
// 作品库(Dashboard 入口,UX §6.1)。Server Component 读取。
|
||||
export default async function DashboardPage() {
|
||||
@@ -18,63 +23,63 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="font-serif text-3xl text-ink">我的作品</h1>
|
||||
<PageContainer width="wide">
|
||||
<PageHeader
|
||||
title="我的作品"
|
||||
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href="/write"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
直接开始写
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
+ 新建作品
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
先立项
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{loadError ? (
|
||||
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
</p>
|
||||
<BackendDownNotice />
|
||||
) : projects.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<ProjectCard project={p} />
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<NewProjectCard />
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
// 首次进入的暖深色编辑部英雄(对标 DESIGN.md cta-band-dark;仅零作品时出现,不干扰工作库)。
|
||||
<div className="rounded-xl border border-line/50 bg-callout px-8 py-14 text-center shadow-paper sm:px-12 sm:py-20">
|
||||
<BookOpen className="mx-auto h-9 w-9 text-cinnabar" aria-hidden="true" />
|
||||
<h2 className="mt-5 font-serif text-display-sm text-on-callout">
|
||||
从一句灵感,写成一本书
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-md text-body leading-7 text-on-callout-soft">
|
||||
直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap items-center justify-center gap-2">
|
||||
<Link
|
||||
href="/write"
|
||||
className={buttonClass({ variant: "primary", size: "lg" })}
|
||||
>
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
直接开始写
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "secondary", size: "lg" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
先立项
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectLibrary projects={projects} />
|
||||
)}
|
||||
</PageContainer>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 rounded border border-line bg-panel py-20 text-center">
|
||||
<p className="font-serif text-2xl text-ink">还没有作品</p>
|
||||
<p className="text-ink-soft">从一句灵感开始你的第一本书。</p>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
+ 新建作品
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewProjectCard() {
|
||||
return (
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className="flex h-full min-h-[160px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel text-center hover:border-cinnabar"
|
||||
>
|
||||
<span className="font-serif text-xl text-cinnabar">+ 新建</span>
|
||||
<span className="mt-2 text-sm text-ink-soft">从一句灵感开始一本书</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||
import { CodexPage } from "@/components/codex/CodexPage";
|
||||
import {
|
||||
fetchCharacters,
|
||||
@@ -37,6 +36,6 @@ export default async function CodexRoutePage({
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
return <BackendDownNotice className="m-6" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||
import { ForeshadowBoard } from "@/components/foreshadow/ForeshadowBoard";
|
||||
import { fetchForeshadow, fetchProject } from "@/lib/api/server";
|
||||
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -11,13 +13,30 @@ interface PageProps {
|
||||
// ForeshadowBoard(Client)按 status 分四泳道并承载登记/状态变更交互。
|
||||
export default async function ForeshadowPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// 仅当项目确实不存在(404)才判 notFound;后端不可用等其它失败降级为「后端不可达」提示,
|
||||
// 不把整页变成「找不到页面」。
|
||||
let project: ProjectResponse;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
const board = await fetchForeshadow(id);
|
||||
return (
|
||||
<ForeshadowBoard project={project} initialItems={board.foreshadow ?? []} />
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
project = await fetchProject(id);
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) notFound();
|
||||
return <BackendDownNotice className="m-6" />;
|
||||
}
|
||||
|
||||
// 伏笔看板(GET .../foreshadow)。瞬时错误降级为空看板,不阻塞进页。
|
||||
let initialItems: ForeshadowView[] = [];
|
||||
try {
|
||||
const board = await fetchForeshadow(id);
|
||||
initialItems = board.foreshadow ?? [];
|
||||
} catch {
|
||||
initialItems = [];
|
||||
}
|
||||
|
||||
return <ForeshadowBoard project={project} initialItems={initialItems} />;
|
||||
}
|
||||
|
||||
// fetchProject 抛错信息形如「请求失败 404: ...」;据此区分「项目不存在」与「后端不可用」。
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message.includes("请求失败 404");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||
import { OutlineEditor } from "@/components/outline/OutlineEditor";
|
||||
import { fetchOutline, fetchProject } from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -12,13 +14,22 @@ interface PageProps {
|
||||
// 仍按需触发 POST .../outline 生成/覆盖所显章节。
|
||||
export default async function OutlinePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// 仅当项目确实不存在(404)才判 notFound;后端不可用等其它失败降级为「后端不可达」提示,
|
||||
// 不把整页变成「找不到页面」。次级拉取(大纲)的瞬时错误已在 fetchOutline 内降级为空。
|
||||
let project: ProjectResponse;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
const initialChapters = await fetchOutline(id);
|
||||
return (
|
||||
<OutlineEditor project={project} initialChapters={initialChapters} />
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
project = await fetchProject(id);
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) notFound();
|
||||
return <BackendDownNotice className="m-6" />;
|
||||
}
|
||||
|
||||
const initialChapters = await fetchOutline(id);
|
||||
return <OutlineEditor project={project} initialChapters={initialChapters} />;
|
||||
}
|
||||
|
||||
// fetchProject 抛错信息形如「请求失败 404: ...」;据此区分「项目不存在」与「后端不可用」。
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message.includes("请求失败 404");
|
||||
}
|
||||
|
||||
@@ -1,38 +1,89 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ReviewReport } from "@/components/review/ReviewReport";
|
||||
import { fetchDraft, fetchProject, fetchReviews } from "@/lib/api/server";
|
||||
import {
|
||||
fetchChapters,
|
||||
fetchDraft,
|
||||
fetchOutline,
|
||||
fetchProject,
|
||||
fetchReviews,
|
||||
} from "@/lib/api/server";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
buildReviewChapterOptions,
|
||||
defaultReviewChapter,
|
||||
} from "@/lib/review/chapterNav";
|
||||
import { latestReview } from "@/lib/review/history";
|
||||
import type { ChapterEntry } from "@/lib/workbench/chapter";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ chapter?: string }>;
|
||||
}
|
||||
|
||||
const DEFAULT_CHAPTER_NO = 1;
|
||||
|
||||
// 审稿报告页(UX §6.4)。Server Component 取项目 + 审稿留痕历史(新→旧);
|
||||
// ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。
|
||||
export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
const { id } = await params;
|
||||
const { chapter } = await searchParams;
|
||||
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
|
||||
|
||||
// 仅当项目确实取不到时才判 404;审稿/草稿/大纲等次级拉取的瞬时错误不应把整页变成「找不到」。
|
||||
let project: ProjectResponse;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
const history = await fetchReviews(id, chapterNo);
|
||||
// 终稿初值取已存草稿(GET .../draft,404→null→"");否则 final_text 为空 → accept 422。
|
||||
const draft = await fetchDraft(id, chapterNo);
|
||||
return (
|
||||
<ReviewReport
|
||||
project={project}
|
||||
chapterNo={chapterNo}
|
||||
initialReview={latestReview(history.reviews)}
|
||||
initialDraft={draft?.content ?? ""}
|
||||
/>
|
||||
);
|
||||
project = await fetchProject(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 真实存在的章(含可审/已审标记,错误→[]):供选章枚举 + 决定默认章。
|
||||
const chapterList = await fetchChapters(id);
|
||||
// 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章。
|
||||
const chapterNo = parsePositiveInt(chapter) ?? defaultReviewChapter(chapterList);
|
||||
|
||||
// 审稿留痕(GET .../reviews,失败→空历史)。
|
||||
let initialReview;
|
||||
try {
|
||||
const history = await fetchReviews(id, chapterNo);
|
||||
initialReview = latestReview(history.reviews);
|
||||
} catch {
|
||||
initialReview = undefined;
|
||||
}
|
||||
|
||||
// 终稿初值取已存草稿(GET .../draft,404→null→"");否则 final_text 为空 → accept 422。
|
||||
let initialDraft = "";
|
||||
try {
|
||||
const draft = await fetchDraft(id, chapterNo);
|
||||
initialDraft = draft?.content ?? "";
|
||||
} catch {
|
||||
initialDraft = "";
|
||||
}
|
||||
|
||||
// 大纲章节作章节导航目录(fetchOutline 已对空/错误降级为 []);首个节拍当短标题。
|
||||
const chapters: ChapterEntry[] = (await fetchOutline(id)).map((c) => ({
|
||||
no: c.no,
|
||||
title: c.beats?.[0],
|
||||
}));
|
||||
|
||||
// 选章下拉选项:以真实章为准(覆盖「写过但不在大纲」的章),大纲标题补短名,标注可审/已审。
|
||||
const chapterOptions = buildReviewChapterOptions(
|
||||
chapterList,
|
||||
chapters,
|
||||
chapterNo,
|
||||
);
|
||||
|
||||
return (
|
||||
// key=章号:客户端切章(?chapter=N 变化)时强制重挂载,用新章审稿/草稿刷新状态,
|
||||
// 避免 ReviewReport 内 useState 基线沿用上一章。
|
||||
<ReviewReport
|
||||
key={chapterNo}
|
||||
project={project}
|
||||
chapterNo={chapterNo}
|
||||
initialReview={initialReview}
|
||||
initialDraft={initialDraft}
|
||||
chapters={chapters}
|
||||
chapterOptions={chapterOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw: string | undefined): number | null {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||
import { ToolboxPage } from "@/components/toolbox/ToolboxPage";
|
||||
import { fetchProject, fetchToolbox } from "@/lib/api/server";
|
||||
|
||||
@@ -29,6 +28,6 @@ export default async function ToolboxRoutePage({
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
return <BackendDownNotice className="m-6" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||
import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
|
||||
import type { ProvidersResponse } from "@/lib/api/types";
|
||||
|
||||
@@ -27,11 +29,13 @@ export default async function ProvidersSettingsPage() {
|
||||
|
||||
return (
|
||||
<AppShell title="设置 › 模型与提供商">
|
||||
<div className="mx-auto max-w-3xl px-8 py-10">
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-8 sm:py-10">
|
||||
<PageHeader
|
||||
title="模型与提供商"
|
||||
description="配置写手、分析、轻量三类能力档位的路由,并管理 API Key 与 Kimi Code OAuth。"
|
||||
/>
|
||||
{loadError ? (
|
||||
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
</p>
|
||||
<BackendDownNotice />
|
||||
) : (
|
||||
<ProvidersSettings initial={initial} kimiOauth={kimiOauth} />
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { TemplatesManager } from "@/components/templates/TemplatesManager";
|
||||
import { fetchTemplates } from "@/lib/api/server";
|
||||
import type { TemplateResponse } from "@/lib/api/types";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { fetchTemplates, fetchToolbox } from "@/lib/api/server";
|
||||
import type { TemplateResponse, ToolDescriptorView } from "@/lib/api/types";
|
||||
|
||||
// 提示词/模板库(F3,全局单用户本地版)。Server Component 预取列表;CRUD 在客户端组件。
|
||||
// 提示词/模板库(F3,全局单用户本地版)。Server Component 预取列表 + 工具描述符(供「关联工具」下拉);
|
||||
// CRUD 在客户端组件。fetchToolbox 任何错误内部降级为空列表,不阻塞进页。
|
||||
export default async function TemplatesPage() {
|
||||
let initial: TemplateResponse[] = [];
|
||||
let tools: ToolDescriptorView[] = [];
|
||||
let loadError = false;
|
||||
try {
|
||||
initial = await fetchTemplates();
|
||||
const [templates, toolbox] = await Promise.all([
|
||||
fetchTemplates(),
|
||||
fetchToolbox(),
|
||||
]);
|
||||
initial = templates;
|
||||
tools = toolbox.tools ?? [];
|
||||
} catch {
|
||||
loadError = true;
|
||||
}
|
||||
@@ -16,15 +25,17 @@ export default async function TemplatesPage() {
|
||||
return (
|
||||
<AppShell title="提示词 / 模板库">
|
||||
<div className="mx-auto max-w-3xl px-8 py-10">
|
||||
<p className="mb-6 text-xs text-ink-soft">
|
||||
保存常用提示词,复用时一键填入生成器的 brief / 原文输入。仅本地、单用户,不做分享。
|
||||
</p>
|
||||
<PageHeader
|
||||
title="提示词 / 模板库"
|
||||
eyebrow="prompt templates"
|
||||
description="保存常用提示词,复用时一键填入生成器的需求 / 原文输入。仅本地、单用户,不做分享。"
|
||||
/>
|
||||
{loadError ? (
|
||||
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
</p>
|
||||
<StatusNote variant="danger" title="无法连接后端服务">
|
||||
<p>请确认 API 已启动后刷新页面。</p>
|
||||
</StatusNote>
|
||||
) : (
|
||||
<TemplatesManager initial={initial} />
|
||||
<TemplatesManager initial={initial} tools={tools} />
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
6
apps/web/app/write/page.tsx
Normal file
6
apps/web/app/write/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { DraftComposer } from "@/components/start/DraftComposer";
|
||||
|
||||
// 进来即写入口(无 project 前缀):直达空白编辑器,敲字后懒建项目并换入 /projects/[id]/write。
|
||||
export default function WriteDraftPage() {
|
||||
return <DraftComposer />;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { aiToolItems } from "@/lib/nav/ai-tools";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
|
||||
interface AiToolbarProps {
|
||||
projectId: string;
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
// T4-a · 项目内页常驻 AI 工具条(顶栏下,UX §3/§5)。
|
||||
// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动。
|
||||
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="AI 工具条"
|
||||
className="flex h-12 items-center gap-2 overflow-x-auto whitespace-nowrap border-b border-line bg-panel px-4 sm:px-6"
|
||||
>
|
||||
{aiToolItems(projectId).map((item) => {
|
||||
const isActive = item.key === activeNav;
|
||||
const className = toolClassName(item.primary === true, isActive);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={className}
|
||||
>
|
||||
<span aria-hidden="true">{item.glyph}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function toolClassName(isPrimary: boolean, isActive: boolean): string {
|
||||
const base =
|
||||
"flex items-center gap-1.5 rounded border px-3 py-1.5 text-sm transition-colors";
|
||||
if (isActive) {
|
||||
return `${base} border-cinnabar text-cinnabar`;
|
||||
}
|
||||
if (isPrimary) {
|
||||
return `${base} border-cinnabar bg-cinnabar text-panel hover:border-cinnabar`;
|
||||
}
|
||||
return `${base} border-line text-ink-soft hover:border-cinnabar hover:text-cinnabar`;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { FileQuestion, Settings } from "lucide-react";
|
||||
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { AiToolbar } from "./AiToolbar";
|
||||
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
||||
import { CommandSearchButton } from "./command/CommandPaletteMount";
|
||||
import { LeftNav } from "./LeftNav";
|
||||
import { NavDrawer } from "./NavDrawer";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
@@ -15,6 +18,9 @@ interface AppShellProps {
|
||||
projectId?: string;
|
||||
// 当前激活的项目级入口键(用于高亮)。
|
||||
activeNav?: ActiveNav;
|
||||
// 专注写作模式(P3-4):隐藏桌面左侧图标导航以加宽正文;顶栏汉堡/命令面板仍可达导航。
|
||||
// 缺省 false → 其余页面外观不变(加法式可选项)。
|
||||
hideNav?: boolean;
|
||||
}
|
||||
|
||||
// 全局外壳:顶栏(64px) + 左导航 + 主区(UX §5.1)。
|
||||
@@ -24,42 +30,79 @@ export function AppShell({
|
||||
subtitle,
|
||||
projectId,
|
||||
activeNav,
|
||||
hideNav = false,
|
||||
}: AppShellProps) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
|
||||
style={{ ["--chrome" as string]: "4rem" }}
|
||||
>
|
||||
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:border focus:border-line focus:bg-panel focus:px-3 focus:py-2 focus:text-sm focus:text-ink focus:shadow-paper"
|
||||
>
|
||||
跳到正文
|
||||
</a>
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
|
||||
<NavDrawer projectId={projectId} activeNav={activeNav} />
|
||||
<Link
|
||||
href="/"
|
||||
className="font-serif text-xl text-cinnabar"
|
||||
className={`shrink-0 whitespace-nowrap rounded font-serif text-xl text-cinnabar ${focusRing}`}
|
||||
aria-label="返回作品库"
|
||||
>
|
||||
墨痕
|
||||
</Link>
|
||||
{title ? (
|
||||
<span className="font-serif text-lg text-ink">{title}</span>
|
||||
<span className="min-w-0 truncate font-serif text-lg text-ink">
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
<span className="font-mono text-xs text-ink-soft">{subtitle}</span>
|
||||
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
|
||||
{subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
<nav className="ml-auto flex items-center gap-4 text-sm">
|
||||
<nav
|
||||
aria-label="快捷操作"
|
||||
className="ml-auto flex shrink-0 items-center gap-2 text-sm"
|
||||
>
|
||||
<CommandSearchButton />
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/help"
|
||||
aria-label="帮助"
|
||||
title="帮助"
|
||||
className={buttonClass({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
className: "border-transparent",
|
||||
})}
|
||||
>
|
||||
<FileQuestion className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">帮助</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings/providers"
|
||||
className="text-ink-soft hover:text-cinnabar"
|
||||
aria-label="设置"
|
||||
title="设置"
|
||||
className={buttonClass({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
className: "border-transparent",
|
||||
})}
|
||||
>
|
||||
设置
|
||||
<Settings className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">设置</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
{projectId ? (
|
||||
<AiToolbar projectId={projectId} activeNav={activeNav} />
|
||||
) : null}
|
||||
<div className="flex">
|
||||
<LeftNav projectId={projectId} activeNav={activeNav} />
|
||||
<main className="min-h-[calc(100vh-var(--chrome,4rem))] flex-1 bg-bg">
|
||||
{hideNav ? null : <LeftNav projectId={projectId} activeNav={activeNav} />}
|
||||
<main
|
||||
id="main"
|
||||
tabIndex={-1}
|
||||
className="min-h-[calc(100vh-var(--chrome,4rem))] min-w-0 flex-1 bg-bg"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
19
apps/web/components/BackendDownNotice.tsx
Normal file
19
apps/web/components/BackendDownNotice.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { StatusNote } from "./ui/StatusNote";
|
||||
|
||||
interface BackendDownNoticeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 后端不可达时的统一提示(多个页面 backend-down 分支复用)。
|
||||
export function BackendDownNotice({ className }: BackendDownNoticeProps) {
|
||||
return (
|
||||
<StatusNote
|
||||
variant="danger"
|
||||
title="无法连接后端服务"
|
||||
role="alert"
|
||||
className={className}
|
||||
>
|
||||
请确认后端 API 已启动后刷新本页。
|
||||
</StatusNote>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
|
||||
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||
import { useMountTransition } from "@/lib/ui/useMountTransition";
|
||||
import { overlayScrim } from "@/lib/ui/variants";
|
||||
|
||||
// 抽屉进出场时长(与 --dur-base 一致)。
|
||||
const DRAWER_MS = 180;
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
@@ -11,22 +20,35 @@ interface DrawerProps {
|
||||
side?: "left" | "right";
|
||||
// 无障碍标签(role=dialog)。
|
||||
label: string;
|
||||
// 抽屉面板 id:供触发按钮 aria-controls 指向(无障碍关联)。
|
||||
id?: string;
|
||||
triggerRef?: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// 移动端侧滑抽屉(<lg;遮罩点击 / Esc 关闭 + 打开聚焦面板)。
|
||||
// 移动端侧滑抽屉(<lg;遮罩点击 / Esc 关闭 + 打开聚焦具名关闭按钮)。
|
||||
// 复用命令面板的 a11y 模式(CommandPalette);桌面用静态布局,不渲染本抽屉。
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
side = "left",
|
||||
label,
|
||||
id,
|
||||
triggerRef,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
|
||||
|
||||
useBodyScrollLock(open);
|
||||
useRestoreFocus(open, triggerRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// 面板真正挂载(shouldRender)且处于打开态时才装监听/落焦——
|
||||
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
|
||||
if (!open || !shouldRender) return;
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -34,19 +56,31 @@ export function Drawer({
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
panelRef.current?.focus();
|
||||
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
||||
closeRef.current?.focus();
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
}, [open, shouldRender, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!shouldRender) return null;
|
||||
|
||||
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
||||
const slideClass =
|
||||
side === "left"
|
||||
? isVisible
|
||||
? "translate-x-0"
|
||||
: "-translate-x-full"
|
||||
: isVisible
|
||||
? "translate-x-0"
|
||||
: "translate-x-full";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden" onClick={onClose}>
|
||||
<div
|
||||
className={`${overlayScrim} lg:hidden motion-safe:transition-opacity motion-safe:duration-base ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
id={id}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
@@ -55,8 +89,19 @@ export function Drawer({
|
||||
// focus trap:Tab/Shift+Tab 在抽屉内循环,不逃逸到背景(WCAG 2.1.2)。
|
||||
if (panelRef.current) handleTabTrap(panelRef.current, e);
|
||||
}}
|
||||
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
|
||||
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none motion-safe:transition-transform motion-safe:duration-base ease-standard ${sideClass} ${slideClass}`}
|
||||
>
|
||||
<div className="mb-2 flex justify-end px-2">
|
||||
<Button
|
||||
ref={closeRef}
|
||||
onClick={onClose}
|
||||
aria-label={`关闭${label}`}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ interface LeftNavProps {
|
||||
export function LeftNav({ projectId, activeNav }: LeftNavProps) {
|
||||
return (
|
||||
<nav
|
||||
className="hidden w-44 shrink-0 border-r border-line bg-panel py-4 lg:block"
|
||||
className="sticky top-[var(--chrome)] hidden max-h-[calc(100vh-var(--chrome))] w-44 shrink-0 self-start overflow-y-auto border-r border-line bg-panel py-4 lg:block"
|
||||
aria-label="主导航"
|
||||
>
|
||||
<NavItems projectId={projectId} activeNav={activeNav} />
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Menu, Search } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { openCommandPalette } from "@/components/command/CommandPalette";
|
||||
import type { ActiveNav } from "@/lib/nav/items";
|
||||
import { focusRing } from "@/lib/ui/variants";
|
||||
import { Drawer } from "./Drawer";
|
||||
import { NavItems } from "./NavItems";
|
||||
|
||||
@@ -11,29 +15,54 @@ interface NavDrawerProps {
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
// 抽屉面板 id:供汉堡按钮 aria-controls 关联(无障碍)。
|
||||
const DRAWER_ID = "nav-drawer";
|
||||
|
||||
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg;桌面用 LeftNav 静态侧栏)。UX §5.1。
|
||||
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const openSearch = (): void => {
|
||||
setOpen(false);
|
||||
openCommandPalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="打开导航"
|
||||
aria-expanded={open}
|
||||
className="-ml-1 rounded p-2 text-ink hover:text-cinnabar lg:hidden"
|
||||
aria-controls={DRAWER_ID}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="-ml-2 lg:hidden"
|
||||
>
|
||||
<span aria-hidden="true" className="block text-lg leading-none">
|
||||
☰
|
||||
</span>
|
||||
</button>
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
side="left"
|
||||
label="主导航"
|
||||
id={DRAWER_ID}
|
||||
triggerRef={triggerRef}
|
||||
>
|
||||
<div className="px-2 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSearch}
|
||||
className={`flex w-full items-center gap-2 rounded border border-line bg-bg px-3 py-2 text-sm text-ink-soft transition-colors hover:text-cinnabar ${focusRing}`}
|
||||
>
|
||||
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
搜索命令或页面
|
||||
<kbd className="ml-auto rounded border border-line bg-panel px-1 font-mono text-2xs">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<NavItems
|
||||
projectId={projectId}
|
||||
activeNav={activeNav}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Blocks,
|
||||
BookOpen,
|
||||
Boxes,
|
||||
ClipboardCheck,
|
||||
Flag,
|
||||
LayoutTemplate,
|
||||
ListTree,
|
||||
Palette,
|
||||
PenLine,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
GLOBAL_NAV_ITEMS,
|
||||
isNavItemActive,
|
||||
projectNavItems,
|
||||
navSections,
|
||||
type ActiveNav,
|
||||
type NavItem,
|
||||
} from "@/lib/nav/items";
|
||||
import { focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface NavItemsProps {
|
||||
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿,UX §3.1)。
|
||||
@@ -20,29 +36,24 @@ interface NavItemsProps {
|
||||
}
|
||||
|
||||
// 导航条目列表(桌面静态侧栏与移动抽屉共用,避免渲染漂移)。
|
||||
// 项目内页把 10 项按 写作/资料库/审阅/更多 分组(P2-1),每组一个小标题;全局组无标题直显于顶部。
|
||||
export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const scoped = projectId ? projectNavItems(projectId) : [];
|
||||
const sections = navSections(projectId);
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1 px-2">
|
||||
{GLOBAL_NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
item={item}
|
||||
active={isNavItemActive(item, { pathname, activeNav })}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{scoped.length > 0 ? (
|
||||
{sections.map((section) => (
|
||||
<Fragment key={section.label ?? "__global"}>
|
||||
{section.label !== null ? (
|
||||
<li
|
||||
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
|
||||
aria-hidden="true"
|
||||
>
|
||||
本作
|
||||
{section.label}
|
||||
</li>
|
||||
) : null}
|
||||
{scoped.map((item) => (
|
||||
{section.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
item={item}
|
||||
@@ -50,10 +61,28 @@ export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function navIcon(item: NavItem): LucideIcon {
|
||||
if (item.key === "write") return PenLine;
|
||||
if (item.key === "outline") return ListTree;
|
||||
if (item.key === "foreshadow") return Flag;
|
||||
if (item.key === "review") return ClipboardCheck;
|
||||
if (item.key === "chains") return Workflow;
|
||||
if (item.key === "style") return Palette;
|
||||
if (item.key === "codex") return BookOpen;
|
||||
if (item.key === "toolbox") return Sparkles;
|
||||
if (item.key === "rules") return Boxes;
|
||||
if (item.key === "skills") return Blocks;
|
||||
if (item.href === "/templates") return LayoutTemplate;
|
||||
if (item.href === "/settings/providers") return Settings;
|
||||
return BookOpen;
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
item,
|
||||
active,
|
||||
@@ -63,18 +92,20 @@ function NavLink({
|
||||
active: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const Icon = navIcon(item);
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm transition-colors ${focusRing} ${
|
||||
active
|
||||
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
|
||||
: "border-l-2 border-transparent text-ink hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -1,26 +1,94 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, ChevronRight, Clock3 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
formatProjectUpdatedAt,
|
||||
pendingReviewCount,
|
||||
} from "@/lib/projects/projects";
|
||||
import { cardClass, focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectResponse;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// 作品卡(UX §6.1):书名衬线大字、题材、一句话故事。
|
||||
// M1 无字数/章数/待办统计端点 → 暂不展示该徽标(避免编造 API)。
|
||||
export function ProjectCard({ project }: ProjectCardProps) {
|
||||
export function ProjectCard({ project, compact = false }: ProjectCardProps) {
|
||||
const pendingCount = pendingReviewCount(project);
|
||||
const updatedLabel = formatProjectUpdatedAt(project.updated_at);
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className="flex h-full min-h-[160px] flex-col rounded border border-line bg-panel p-6 shadow-paper hover:border-cinnabar"
|
||||
className={cardClass(
|
||||
`group flex items-center gap-3 p-3 transition-colors hover:border-cinnabar ${focusRing}`,
|
||||
)}
|
||||
>
|
||||
<h2 className="font-serif text-2xl text-ink">〈{project.title}〉</h2>
|
||||
{project.genre ? (
|
||||
<p className="mt-2 text-sm text-ink-soft">{project.genre}</p>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-serif text-base text-ink">
|
||||
〈{project.title || "未命名作品"}〉
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-xs text-ink-soft">
|
||||
{project.genre ? <Badge>{project.genre}</Badge> : null}
|
||||
{pendingCount > 0 ? (
|
||||
<Badge variant="warning">{pendingCount} 待审</Badge>
|
||||
) : null}
|
||||
<span className="truncate">{project.logline ?? "暂无一句话故事"}</span>
|
||||
</span>
|
||||
<span className="mt-1 block font-mono text-[11px] text-ink-soft">
|
||||
最近编辑 {updatedLabel}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-ink-soft transition-colors group-hover:text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${project.id}/write`}
|
||||
className={cardClass(
|
||||
`group flex h-full min-h-[176px] flex-col p-6 transition-colors hover:border-cinnabar ${focusRing}`,
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate font-serif text-2xl text-ink">
|
||||
〈{project.title || "未命名作品"}〉
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{project.genre ? <Badge>{project.genre}</Badge> : null}
|
||||
{pendingCount > 0 ? (
|
||||
<Badge variant="warning">{pendingCount} 待审</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded border border-line bg-bg p-2 text-ink-soft transition-colors group-hover:border-cinnabar group-hover:text-cinnabar">
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
{project.logline ? (
|
||||
<p className="mt-3 line-clamp-3 text-sm text-ink">{project.logline}</p>
|
||||
<p className="line-clamp-3 text-sm leading-6 text-ink">
|
||||
{project.logline}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="mt-3 flex items-center gap-1 font-mono text-[11px] text-ink-soft">
|
||||
<Clock3 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
最近编辑 {updatedLabel}
|
||||
</span>
|
||||
<span className="mt-auto flex items-center gap-1 pt-4 text-xs text-cinnabar opacity-0 motion-safe:transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
进入写作台
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, Check, CheckCircle2, Sparkles } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { Select } from "@/components/ui/Select";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import type { ProjectPlanView } from "@/lib/api/types";
|
||||
import { useProjectPlan } from "@/lib/wizard/useProjectPlan";
|
||||
import {
|
||||
ENDING_TYPES,
|
||||
GENRES,
|
||||
NARRATIVE_POVS,
|
||||
SELLING_POINT_PRESETS,
|
||||
STEP_TITLES,
|
||||
STRUCTURES,
|
||||
TONES,
|
||||
WIZARD_STEPS,
|
||||
applyPlanPatch,
|
||||
canAdvance,
|
||||
canGeneratePlan,
|
||||
canSubmit,
|
||||
clampStep,
|
||||
emptyWizardForm,
|
||||
mapPlanResultToWizardForm,
|
||||
toCreateRequest,
|
||||
toPlanSeedRequest,
|
||||
type WizardForm,
|
||||
} from "@/lib/wizard/wizard";
|
||||
|
||||
const STEP_TITLES = [
|
||||
"书名 + 题材",
|
||||
"一句话故事 + 卖点 + 结构",
|
||||
"立意 / 总纲",
|
||||
"主角 / 金手指",
|
||||
"基础世界观",
|
||||
];
|
||||
|
||||
// 立项向导(UX §6.2)。Client Component:收集字段 → POST /projects → 进工作台。
|
||||
export function ProjectWizard() {
|
||||
const router = useRouter();
|
||||
@@ -34,6 +46,17 @@ export function ProjectWizard() {
|
||||
const [form, setForm] = useState<WizardForm>(emptyWizardForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 换步后把焦点移到新步标题,方便键盘/读屏用户感知步骤变化;首帧不抢第 1 步输入框的 autoFocus。
|
||||
const stepHeadingRef = useRef<HTMLParagraphElement>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
stepHeadingRef.current?.focus();
|
||||
}, [step]);
|
||||
|
||||
const update = (patch: Partial<WizardForm>): void =>
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
@@ -45,6 +68,21 @@ export function ProjectWizard() {
|
||||
: [...prev.sellingPoints, point],
|
||||
}));
|
||||
|
||||
// AI 立项方案按字段回填:只填当前为空的字段(保护作者已编辑内容,不 bulk 覆盖)。
|
||||
const applyPlan = (plan: ProjectPlanView): void => {
|
||||
const patch = mapPlanResultToWizardForm(plan);
|
||||
const filled = (Object.keys(patch) as Array<keyof WizardForm>).filter(
|
||||
(k) => typeof form[k] === "string" && (form[k] as string).trim() === "",
|
||||
);
|
||||
setForm((prev) => applyPlanPatch(prev, patch));
|
||||
toast(
|
||||
filled.length > 0
|
||||
? `已回填 ${filled.length} 个空字段(已编辑内容不覆盖)`
|
||||
: "向导相关字段均已填写,未覆盖任何内容",
|
||||
filled.length > 0 ? "success" : "info",
|
||||
);
|
||||
};
|
||||
|
||||
const goBack = (): void => setStep((s) => clampStep(s - 1));
|
||||
const goNext = (): void => setStep((s) => clampStep(s + 1));
|
||||
|
||||
@@ -69,14 +107,23 @@ export function ProjectWizard() {
|
||||
const isLast = step === WIZARD_STEPS;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-8 shadow-paper">
|
||||
<div className="mx-auto max-w-2xl rounded-lg border border-line bg-panel p-6 shadow-paper sm:p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="font-serif text-2xl text-ink">新建作品</h1>
|
||||
<h1 className="font-serif text-display-sm text-ink">新建作品</h1>
|
||||
<StepDots current={step} />
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-ink-soft">
|
||||
步骤 {step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]}
|
||||
<div className="mb-4">
|
||||
<Badge variant="accent">
|
||||
步骤 {step}/{WIZARD_STEPS}
|
||||
</Badge>
|
||||
<p
|
||||
ref={stepHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="mt-2 text-sm text-ink-soft focus-visible:outline-none"
|
||||
>
|
||||
{STEP_TITLES[step - 1]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[220px]">
|
||||
{step === 1 && <StepBasics form={form} update={update} />}
|
||||
@@ -85,40 +132,37 @@ export function ProjectWizard() {
|
||||
form={form}
|
||||
update={update}
|
||||
toggleSellingPoint={toggleSellingPoint}
|
||||
onApplyPlan={applyPlan}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && <StepPremise form={form} update={update} />}
|
||||
{step === 4 && <StepProtagonist form={form} update={update} />}
|
||||
{step === 5 && <StepWorld />}
|
||||
{step === 5 && <StepConfirm form={form} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
disabled={step === 1}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
|
||||
>
|
||||
← 上一步
|
||||
</button>
|
||||
<Button onClick={goBack} disabled={step === 1} variant="secondary">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
上一步
|
||||
</Button>
|
||||
{isLast ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={submitting || !canSubmit(form)}
|
||||
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
|
||||
variant="primary"
|
||||
>
|
||||
{submitting ? "创建中…" : "完成立项 →"}
|
||||
</button>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
{submitting ? "创建中…" : "完成立项"}
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={goNext}
|
||||
disabled={!canAdvance(step, form)}
|
||||
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
|
||||
variant="primary"
|
||||
>
|
||||
下一步 →
|
||||
</button>
|
||||
下一步
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,30 +189,16 @@ interface StepProps {
|
||||
update: (patch: Partial<WizardForm>) => void;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-1 block text-sm text-ink-soft">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-line bg-bg px-3 py-2 text-ink focus:border-cinnabar focus:outline-none";
|
||||
|
||||
function StepBasics({ form, update }: StepProps) {
|
||||
const titleMissing = form.title.trim().length === 0;
|
||||
return (
|
||||
<div>
|
||||
<Field label="书名(必填)">
|
||||
<input
|
||||
className={inputCls}
|
||||
<Field
|
||||
label="书名"
|
||||
required
|
||||
help={titleMissing ? "请先填写书名才能继续" : undefined}
|
||||
>
|
||||
<TextInput
|
||||
value={form.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
placeholder="例:逐光而行"
|
||||
@@ -176,8 +206,7 @@ function StepBasics({ form, update }: StepProps) {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="题材">
|
||||
<select
|
||||
className={inputCls}
|
||||
<Select
|
||||
value={form.genre}
|
||||
onChange={(e) => update({ genre: e.target.value })}
|
||||
>
|
||||
@@ -187,63 +216,140 @@ function StepBasics({ form, update }: StepProps) {
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AI 立项方案助手(灵感⑤):种子(题材+logline)就绪后一键生成结构化方案 → 预览 → 按字段回填。
|
||||
function PlanAssistant({
|
||||
form,
|
||||
onApplyPlan,
|
||||
}: {
|
||||
form: WizardForm;
|
||||
onApplyPlan: (plan: ProjectPlanView) => void;
|
||||
}) {
|
||||
const { status, plan, generate } = useProjectPlan();
|
||||
const ready = canGeneratePlan(form);
|
||||
const generating = status === "generating";
|
||||
|
||||
return (
|
||||
<div className="my-4 rounded-md border border-line bg-bg p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-1.5 text-sm text-ink">
|
||||
<Sparkles className="h-4 w-4 text-cinnabar" aria-hidden="true" />
|
||||
AI 立项方案
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-ink-soft">
|
||||
{ready
|
||||
? "根据题材 + 一句话故事推演书级蓝图,按字段回填(不覆盖已填内容)。"
|
||||
: "填好「题材」(上一步)与「一句话故事」后即可生成。"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void generate(toPlanSeedRequest(form))}
|
||||
disabled={!ready || generating}
|
||||
variant="secondary"
|
||||
>
|
||||
{generating ? (
|
||||
<ThinkingIndicator label="生成中" />
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
生成方案
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status === "done" && plan && (
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<dl className="space-y-1.5 text-sm">
|
||||
{plan.title_candidates && plan.title_candidates.length > 0 && (
|
||||
<PlanRow label="书名候选" value={plan.title_candidates.join("、")} />
|
||||
)}
|
||||
<PlanRow label="时空背景" value={plan.setting} />
|
||||
<PlanRow label="叙事结构" value={plan.narrative_structure} />
|
||||
<PlanRow label="故事核心" value={plan.story_core} />
|
||||
<PlanRow label="结局设计" value={plan.ending_design} />
|
||||
<PlanRow label="基调" value={plan.tone} />
|
||||
</dl>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button onClick={() => onApplyPlan(plan)} variant="primary">
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
按字段回填向导
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanRow({ label, value }: { label: string; value: string }) {
|
||||
if (!value.trim()) return null;
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<dt className="w-16 shrink-0 text-ink-soft">{label}</dt>
|
||||
<dd className="min-w-0 flex-1 whitespace-pre-wrap text-ink">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepStory({
|
||||
form,
|
||||
update,
|
||||
toggleSellingPoint,
|
||||
}: StepProps & { toggleSellingPoint: (p: string) => void }) {
|
||||
onApplyPlan,
|
||||
}: StepProps & {
|
||||
toggleSellingPoint: (p: string) => void;
|
||||
onApplyPlan: (plan: ProjectPlanView) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Field label="一句话故事(logline)">
|
||||
<textarea
|
||||
className={`${inputCls} h-20 resize-none`}
|
||||
<TextArea
|
||||
className="h-20 resize-none"
|
||||
value={form.logline}
|
||||
onChange={(e) => update({ logline: e.target.value })}
|
||||
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
|
||||
/>
|
||||
</Field>
|
||||
<PlanAssistant form={form} onApplyPlan={onApplyPlan} />
|
||||
<Field label="故事结构">
|
||||
<div className="flex gap-2">
|
||||
{STRUCTURES.map((s) => (
|
||||
<button
|
||||
type="button"
|
||||
key={s}
|
||||
onClick={() => update({ structure: s })}
|
||||
className={`rounded border px-3 py-1.5 text-sm ${
|
||||
form.structure === s
|
||||
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line text-ink"
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
ariaLabel="故事结构"
|
||||
className="flex-wrap"
|
||||
value={form.structure}
|
||||
onChange={(structure) => update({ structure })}
|
||||
options={STRUCTURES.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
</Field>
|
||||
<fieldset>
|
||||
<legend className="mb-1 block text-sm text-ink-soft">核心卖点</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SELLING_POINT_PRESETS.map((p) => (
|
||||
{SELLING_POINT_PRESETS.map((p) => {
|
||||
const selected = form.sellingPoints.includes(p);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={p}
|
||||
aria-pressed={form.sellingPoints.includes(p)}
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleSellingPoint(p)}
|
||||
className={`rounded border px-3 py-1.5 text-sm ${
|
||||
form.sellingPoints.includes(p)
|
||||
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "border-line text-ink"
|
||||
}`}
|
||||
className={buttonClass({
|
||||
variant: selected ? "primary" : "secondary",
|
||||
size: "sm",
|
||||
})}
|
||||
>
|
||||
{selected ? (
|
||||
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : null}
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
@@ -254,37 +360,68 @@ function StepPremise({ form, update }: StepProps) {
|
||||
return (
|
||||
<div>
|
||||
<Field label="立意(premise)">
|
||||
<textarea
|
||||
className={`${inputCls} h-20 resize-none`}
|
||||
<TextArea
|
||||
className="h-20 resize-none"
|
||||
value={form.premise}
|
||||
onChange={(e) => update({ premise: e.target.value })}
|
||||
placeholder="故事的核心命题与前提。"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="主题(theme)">
|
||||
<input
|
||||
className={inputCls}
|
||||
<TextInput
|
||||
value={form.theme}
|
||||
onChange={(e) => update({ theme: e.target.value })}
|
||||
placeholder="例:抗争与代价"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="基调">
|
||||
<Select
|
||||
value={form.tone}
|
||||
onChange={(e) => update({ tone: e.target.value })}
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{TONES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="叙事视角">
|
||||
<SegmentedControl
|
||||
ariaLabel="叙事视角"
|
||||
className="flex-wrap"
|
||||
value={form.narrativePov}
|
||||
onChange={(narrativePov) => update({ narrativePov })}
|
||||
options={NARRATIVE_POVS.map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="结局取向">
|
||||
<SegmentedControl
|
||||
ariaLabel="结局取向"
|
||||
className="flex-wrap"
|
||||
value={form.endingType}
|
||||
onChange={(endingType) => update({ endingType })}
|
||||
options={ENDING_TYPES.map((e) => ({ value: e, label: e }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepProtagonist({ form, update }: StepProps) {
|
||||
// M1 projects 表无独立主角/金手指字段;先并入立意/总纲文本,避免编造 API。
|
||||
// M1 projects 表无独立主角/金手指字段;提交时由 toCreateRequest 合并进 premise(与立意各占一段)。
|
||||
// 必须绑定独立的 form.protagonist,不能复用 form.premise,否则与第 3 步立意互相覆盖(QA H1)。
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-3 text-sm text-ink-soft">
|
||||
主角与金手指(M1 暂并入总纲,后续设定库独立建模)。
|
||||
</p>
|
||||
<Field label="主角 / 金手指概要">
|
||||
<textarea
|
||||
className={`${inputCls} h-28 resize-none`}
|
||||
value={form.premise}
|
||||
onChange={(e) => update({ premise: e.target.value })}
|
||||
<TextArea
|
||||
className="h-28 resize-none"
|
||||
value={form.protagonist}
|
||||
onChange={(e) => update({ protagonist: e.target.value })}
|
||||
placeholder="主角设定、金手指来源与限制……"
|
||||
/>
|
||||
</Field>
|
||||
@@ -292,10 +429,34 @@ function StepProtagonist({ form, update }: StepProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function StepWorld() {
|
||||
// 第 5 步:确认/概览——回显已填字段供复核后提交(取代旧的空「世界观后续里程碑」说明)。
|
||||
function StepConfirm({ form }: { form: WizardForm }) {
|
||||
const rows: Array<{ label: string; value: string }> = [
|
||||
{ label: "书名", value: form.title.trim() || "未填写" },
|
||||
{ label: "一句话故事", value: form.logline.trim() || "未填写" },
|
||||
{ label: "题材", value: form.genre || "未选择" },
|
||||
{ label: "故事结构", value: form.structure || "未选择" },
|
||||
{
|
||||
label: "核心卖点",
|
||||
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
|
||||
},
|
||||
{ label: "基调", value: form.tone || "未选择" },
|
||||
{ label: "叙事视角", value: form.narrativePov || "未选择" },
|
||||
{ label: "结局取向", value: form.endingType || "未选择" },
|
||||
];
|
||||
return (
|
||||
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm text-ink-soft">
|
||||
基础世界观将在「设定库」里建模(后续里程碑)。现在可直接完成立项,进入工作台开始写第一章。
|
||||
<div>
|
||||
<p className="mb-3 text-sm text-ink-soft">
|
||||
复核以下信息,确认无误后即可完成立项。其余设定可进工作台后在「设定库」继续完善。
|
||||
</p>
|
||||
<dl className="divide-y divide-line rounded-md border border-line bg-bg">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex gap-4 px-4 py-2.5 text-sm">
|
||||
<dt className="w-20 shrink-0 text-ink-soft">{row.label}</dt>
|
||||
<dd className="min-w-0 flex-1 text-ink">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
10
apps/web/components/ThemeScript.tsx
Normal file
10
apps/web/components/ThemeScript.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { themeBootstrapScript } from "@/lib/ui/theme";
|
||||
|
||||
export function ThemeScript() {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: themeBootstrapScript() }}
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
);
|
||||
}
|
||||
74
apps/web/components/ThemeToggle.tsx
Normal file
74
apps/web/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import {
|
||||
DEFAULT_THEME_MODE,
|
||||
THEME_STORAGE_KEY,
|
||||
nextThemeMode,
|
||||
normalizeThemeMode,
|
||||
themeModeLabel,
|
||||
themeToggleLabel,
|
||||
type ThemeMode,
|
||||
} from "@/lib/ui/theme";
|
||||
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
document.documentElement.dataset.theme = mode;
|
||||
}
|
||||
|
||||
function readStoredThemeMode(): ThemeMode {
|
||||
try {
|
||||
return normalizeThemeMode(window.localStorage.getItem(THEME_STORAGE_KEY));
|
||||
} catch {
|
||||
return DEFAULT_THEME_MODE;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredThemeMode(mode: ThemeMode) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// 存储不可用时仍允许本次页面会话切换主题。
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
// 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。
|
||||
// 可见的图标由 CSS 按 <html data-theme> 决定(ThemeScript 已在水合前写入),故无首帧闪烁。
|
||||
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
|
||||
|
||||
useEffect(() => {
|
||||
// 水合后以 localStorage 为权威源校正(dataset 已由 ThemeScript 同样依据写入,通常一致)。
|
||||
const initial = readStoredThemeMode();
|
||||
setMode(initial);
|
||||
applyThemeMode(initial);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
setMode((current) => {
|
||||
const next = nextThemeMode(current);
|
||||
writeStoredThemeMode(next);
|
||||
applyThemeMode(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 图标显隐交给 CSS(按 data-theme),两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。
|
||||
// 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。
|
||||
return (
|
||||
<Button
|
||||
onClick={toggle}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={themeToggleLabel(mode)}
|
||||
title={themeToggleLabel(mode)}
|
||||
className="border-transparent"
|
||||
>
|
||||
<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>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -17,12 +17,12 @@ export function ThinkingIndicator({
|
||||
role="status"
|
||||
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) => (
|
||||
<span
|
||||
key={i}
|
||||
className="h-1.5 w-1.5 rounded-full bg-current opacity-70 motion-safe:animate-pulse"
|
||||
style={{ animationDelay: `${i * 200}ms` }}
|
||||
className="ai-dot h-2 w-2 rounded-full bg-current"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -11,19 +11,51 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
|
||||
type ToastKind = "info" | "error" | "success";
|
||||
|
||||
interface ToastAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface ToastOptions {
|
||||
action?: ToastAction;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
type ShowToast = (message: string, kind?: ToastKind) => void;
|
||||
type ShowToast = (
|
||||
message: string,
|
||||
kind?: ToastKind,
|
||||
options?: ToastOptions,
|
||||
) => void;
|
||||
|
||||
const ToastContext = createContext<ShowToast | null>(null);
|
||||
|
||||
// 普通提示 4s 自动消失;错误更需被看到,默认延长到 7s。
|
||||
const TOAST_TTL_MS = 4000;
|
||||
const ERROR_TOAST_TTL_MS = 7000;
|
||||
|
||||
// 屏读前缀:让无视觉的用户也能区分提示性质。
|
||||
const SR_PREFIX: Record<ToastKind, string> = {
|
||||
error: "错误:",
|
||||
success: "成功:",
|
||||
info: "",
|
||||
};
|
||||
|
||||
function toastShellClass(kind: ToastKind): string {
|
||||
if (kind === "error") return "border-conflict bg-panel text-conflict";
|
||||
if (kind === "success") return "border-pass bg-panel text-pass";
|
||||
return "border-line bg-panel text-ink";
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
@@ -32,13 +64,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
// 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。
|
||||
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
const show = useCallback<ShowToast>((message, kind = "info") => {
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setItems((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const show = useCallback<ShowToast>((message, kind = "info", options) => {
|
||||
const id = nextIdRef.current++;
|
||||
setItems((prev) => [...prev, { id, message, kind }]);
|
||||
setItems((prev) => [...prev, { id, message, kind, action: options?.action }]);
|
||||
const ttl =
|
||||
options?.durationMs ??
|
||||
(kind === "error" ? ERROR_TOAST_TTL_MS : TOAST_TTL_MS);
|
||||
const timer = setTimeout(() => {
|
||||
timersRef.current.delete(timer);
|
||||
setItems((prev) => prev.filter((t) => t.id !== id));
|
||||
}, TOAST_TTL_MS);
|
||||
}, ttl);
|
||||
timersRef.current.add(timer);
|
||||
}, []);
|
||||
|
||||
@@ -53,28 +92,46 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const value = useMemo(() => show, [show]);
|
||||
|
||||
const errorItems = items.filter((t) => t.kind === "error");
|
||||
const politeItems = items.filter((t) => t.kind !== "error");
|
||||
|
||||
const renderItem = (t: ToastItem) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast-enter pointer-events-auto flex items-center gap-3 rounded-md border px-4 py-2 text-sm shadow-paper ${toastShellClass(
|
||||
t.kind,
|
||||
)}`}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="sr-only">{SR_PREFIX[t.kind]}</span>
|
||||
{t.message}
|
||||
</span>
|
||||
{t.action ? (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({ variant: "ghost", size: "sm" })}
|
||||
onClick={() => {
|
||||
t.action?.onClick();
|
||||
dismiss(t.id);
|
||||
}}
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto rounded border px-4 py-2 text-sm shadow-paper ${
|
||||
t.kind === "error"
|
||||
? "border-conflict bg-panel text-conflict"
|
||||
: t.kind === "success"
|
||||
? "border-pass bg-panel text-pass"
|
||||
: "border-line bg-panel text-ink"
|
||||
}`}
|
||||
>
|
||||
{t.message}
|
||||
{/* error 用 assertive 立即播报;其余 info/success 用 polite 礼貌播报。两区共用同一锚点容器避免重叠。 */}
|
||||
<div className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2" aria-live="assertive" role="alert">
|
||||
{errorItems.map(renderItem)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2" aria-live="polite" role="status">
|
||||
{politeItems.map(renderItem)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { ConflictCard } from "@/components/review/ConflictCard";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { buildResumeRequest } from "@/lib/chain/chain";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { friendlyFromApiError } from "@/lib/errors/messages";
|
||||
import type { ReviewConflict } from "@/lib/review/sse";
|
||||
import {
|
||||
allResolved,
|
||||
@@ -45,10 +47,9 @@ export function ChainAdjudication({
|
||||
const [drafts, setDrafts] = useState<DecisionDraft[]>([]);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
// 拉取 awaiting 章最近审稿冲突。抽成可复用回调,供进场 effect 与「重新加载」按钮共用。
|
||||
const load = useCallback(async (): Promise<boolean> => {
|
||||
setLoadState("loading");
|
||||
void (async () => {
|
||||
try {
|
||||
const { data, error } = await api.GET(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/reviews",
|
||||
@@ -58,23 +59,32 @@ export function ChainAdjudication({
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!active) return;
|
||||
if (error || !data) {
|
||||
setLoadState("error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const next = normalizeConflicts(latestReview(data.reviews));
|
||||
setConflicts(next);
|
||||
setDrafts(emptyDecisions(next.length));
|
||||
setLoadState("ready");
|
||||
return true;
|
||||
} catch {
|
||||
if (active) setLoadState("error");
|
||||
setLoadState("error");
|
||||
return false;
|
||||
}
|
||||
}, [projectId, chapterNo]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void (async () => {
|
||||
// 进场加载:组件已卸载则丢弃结果(避免 setState-after-unmount)。
|
||||
if (!active) return;
|
||||
await load();
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [projectId, chapterNo]);
|
||||
}, [load]);
|
||||
|
||||
const onVerdict = useCallback((index: number, verdict: Verdict): void => {
|
||||
setDrafts((prev) => setVerdict(prev, index, verdict));
|
||||
@@ -99,7 +109,7 @@ export function ChainAdjudication({
|
||||
},
|
||||
);
|
||||
if (error) {
|
||||
toast(friendlyError(undefined).text, "error");
|
||||
toast(friendlyFromApiError(error).text, "error");
|
||||
setResuming(false);
|
||||
return;
|
||||
}
|
||||
@@ -115,9 +125,17 @@ export function ChainAdjudication({
|
||||
}
|
||||
if (loadState === "error") {
|
||||
return (
|
||||
<p className="text-sm text-conflict">
|
||||
加载第 {chapterNo} 章审稿冲突失败,请刷新重试。
|
||||
</p>
|
||||
<StatusNote variant="danger" title={`加载第 ${chapterNo} 章审稿冲突失败`}>
|
||||
<p>请检查网络后重新加载。</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
className="mt-3"
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</StatusNote>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,6 +161,7 @@ export function ChainAdjudication({
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
total={conflicts.length}
|
||||
conflict={conflict}
|
||||
draft={draft}
|
||||
missing={false}
|
||||
@@ -158,14 +177,14 @@ export function ChainAdjudication({
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void onResume()}
|
||||
disabled={resuming || !allResolved(drafts)}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
className="self-start"
|
||||
>
|
||||
{resuming ? "续跑中…" : "裁决完成,续跑"}
|
||||
</button>
|
||||
</Button>
|
||||
{pending.length > 0 ? (
|
||||
<p className="text-xs text-conflict">
|
||||
还有 {pending.length} 条冲突未裁决。
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { friendlyFromApiError } from "@/lib/errors/messages";
|
||||
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { ChainAdjudication } from "./ChainAdjudication";
|
||||
@@ -58,7 +60,7 @@ export function ChainPage({ project }: ChainPageProps) {
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
toast(friendlyError(undefined).text, "error");
|
||||
toast(friendlyFromApiError(error).text, "error");
|
||||
return;
|
||||
}
|
||||
setJobId(data.job_id);
|
||||
@@ -77,6 +79,11 @@ export function ChainPage({ project }: ChainPageProps) {
|
||||
if (jobId) poll.poll(jobId);
|
||||
}, [jobId, poll]);
|
||||
|
||||
// 链失败重试:重启轮询同一 job(覆盖轮询/网络抖动这类瞬时失败;图从检查点续)。
|
||||
const onRetry = useCallback((): void => {
|
||||
if (jobId) poll.poll(jobId);
|
||||
}, [jobId, poll]);
|
||||
|
||||
const showProgress = jobId !== null && result !== null;
|
||||
// 链运行中/等待裁决时禁止重复发起。
|
||||
const busy =
|
||||
@@ -112,13 +119,25 @@ export function ChainPage({ project }: ChainPageProps) {
|
||||
) : null}
|
||||
|
||||
{phase === "failed" ? (
|
||||
<p className="text-sm text-conflict">
|
||||
{poll.error ?? "链运行失败,请重试。"}
|
||||
</p>
|
||||
<StatusNote variant="danger" title="链运行失败">
|
||||
<p>{poll.error ?? "请稍后重试。"}</p>
|
||||
{jobId !== null ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="mt-3"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</StatusNote>
|
||||
) : null}
|
||||
|
||||
{phase === "completed" ? (
|
||||
<p className="text-sm text-pass">本链已全部跑完。</p>
|
||||
<StatusNote variant="success" title="本链已全部跑完">
|
||||
<p>各章已写完并验收,可在大纲或正文里继续推进。</p>
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import type { BadgeVariant } from "@/lib/ui/variants";
|
||||
import type { ChainPhase, ChainResultView } from "@/lib/chain/chain";
|
||||
|
||||
interface ChainProgressProps {
|
||||
@@ -15,32 +18,40 @@ const PHASE_LABEL: Record<ChainPhase, string> = {
|
||||
failed: "已失败",
|
||||
};
|
||||
|
||||
// 相位 → 语义色:失败用危险色而非朱砂品牌色,运行/等待/完成各取信息/警示/成功。
|
||||
const PHASE_VARIANT: Record<ChainPhase, BadgeVariant> = {
|
||||
running: "info",
|
||||
awaiting: "warning",
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
};
|
||||
|
||||
// 链进度视图:相位徽标 + 进度条 + 已写章号清单(token/正文绝不展示,result 只含元数据)。
|
||||
export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
|
||||
<Card
|
||||
as="section"
|
||||
className="flex flex-col gap-3 p-5"
|
||||
aria-label="链进度"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-serif text-lg text-ink">链进度</h2>
|
||||
<span
|
||||
className="rounded bg-cinnabar/10 px-2 py-0.5 text-xs text-cinnabar"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Badge variant={PHASE_VARIANT[phase]} aria-live="polite">
|
||||
{PHASE_LABEL[phase]}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-2 w-full overflow-hidden rounded bg-bg"
|
||||
role="progressbar"
|
||||
aria-label="链写章进度"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuetext={`已完成 ${result.written.length} 章`}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-cinnabar transition-all"
|
||||
className="h-full bg-cinnabar motion-safe:transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -58,6 +69,6 @@ export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
|
||||
第 {result.awaitingChapter} 章存在未决冲突,请在下方逐条裁决后续跑。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { Radio } from "@/components/ui/Radio";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
|
||||
|
||||
interface ChainStarterProps {
|
||||
@@ -41,12 +47,10 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
if (valid && !disabled) onStart(chainKey, startNo, countNo);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">连续写多章</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">
|
||||
从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。
|
||||
</p>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="连续写多章"
|
||||
description="从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。"
|
||||
/>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm text-ink-soft">链类型</legend>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
@@ -55,8 +59,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
key={kind.key}
|
||||
className="flex items-start gap-2 text-sm text-ink"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
<Radio
|
||||
name="chain-kind"
|
||||
value={kind.key}
|
||||
checked={chainKey === kind.key}
|
||||
@@ -75,37 +78,35 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
起始章号
|
||||
<input
|
||||
<Field label="起始章号" className="w-32">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={1}
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="起始章号"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
连续章数(1..{MAX_COUNT})
|
||||
<input
|
||||
</Field>
|
||||
<Field label={`连续章数(1..${MAX_COUNT})`} className="w-40">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COUNT}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="连续章数"
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !valid}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
variant="primary"
|
||||
className="self-start"
|
||||
>
|
||||
<Play className="h-4 w-4" aria-hidden="true" />
|
||||
{disabled ? "运行中…" : "发起多章链"}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
149
apps/web/components/characters/RelationshipGraph.tsx
Normal file
149
apps/web/components/characters/RelationshipGraph.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
ReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
} from "@xyflow/react";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
// React Flow 样式必须显式引入(组件经 dynamic ssr:false 加载,样式随该 chunk 打包)。
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import {
|
||||
buildRelationshipGraph,
|
||||
NODE_WIDTH,
|
||||
type GraphNode,
|
||||
} from "@/lib/characters/graphLayout";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
|
||||
interface RelationshipGraphProps {
|
||||
characters: CharacterCardView[];
|
||||
}
|
||||
|
||||
// 纸感节点样式:面板底 + 细线框 + 左侧按分组配色的强调条(色值走主题 CSS 变量)。
|
||||
function paperNodeStyle(colorVar: string): React.CSSProperties {
|
||||
return {
|
||||
width: NODE_WIDTH,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
background: "var(--color-panel)",
|
||||
color: "var(--color-ink)",
|
||||
border: "1px solid var(--color-line)",
|
||||
borderLeft: `4px solid var(${colorVar})`,
|
||||
boxShadow: "0 1px 3px var(--shadow-paper)",
|
||||
fontSize: 12,
|
||||
textAlign: "left",
|
||||
};
|
||||
}
|
||||
|
||||
// 节点内容:角色名(宋体)+ 定位副标题。
|
||||
function NodeLabel({ name, role }: { name: string; role: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-serif text-sm text-ink">{name}</p>
|
||||
{role ? <p className="truncate text-2xs text-ink-soft">{role}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toReactFlowNodes(nodes: GraphNode[]): Node[] {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
data: { label: <NodeLabel name={node.data.label} role={node.data.role} /> },
|
||||
style: paperNodeStyle(node.data.colorVar),
|
||||
// 只读可视化:可拖动重排,不可新建连线。
|
||||
connectable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function toReactFlowEdges(
|
||||
edges: { id: string; source: string; target: string; label: string }[],
|
||||
): Edge[] {
|
||||
return edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.label,
|
||||
labelStyle: { fill: "var(--color-ink-soft)", fontSize: 11 },
|
||||
labelBgStyle: { fill: "var(--color-bg)", fillOpacity: 0.9 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: { stroke: "var(--color-line)", strokeWidth: 1.5 },
|
||||
}));
|
||||
}
|
||||
|
||||
// 尊重 prefers-reduced-motion:减动偏好下关闭 fitView 平滑动画与边流动动画。
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const update = () => setReduced(media.matches);
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
// 角色关系图谱(灵感⑥ 缩减版 / D4):只用现有 relations 数据,节点按 role 上色、
|
||||
// 边按 name-join、kind 作中文标签。悬空边(引用未入库角色)已在 graphLayout 略过并计数。
|
||||
export function RelationshipGraph({ characters }: RelationshipGraphProps) {
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const graph = useMemo(
|
||||
() => buildRelationshipGraph(characters),
|
||||
[characters],
|
||||
);
|
||||
const nodes = useMemo(() => toReactFlowNodes(graph.nodes), [graph.nodes]);
|
||||
const edges = useMemo(() => toReactFlowEdges(graph.edges), [graph.edges]);
|
||||
|
||||
if (graph.nodes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="暂无可视化的人物"
|
||||
description="先入库角色并登记彼此关系,这里会绘制出关系图谱。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* React Flow 必须有显式尺寸的容器,否则画布高度为 0。 */}
|
||||
<div className="h-[28rem] w-full overflow-hidden rounded border border-line bg-bg">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, duration: reducedMotion ? 0 : 400 }}
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
proOptions={{ hideAttribution: false }}
|
||||
minZoom={0.2}
|
||||
maxZoom={1.75}
|
||||
defaultEdgeOptions={{ animated: false }}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
color="var(--color-line)"
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{graph.danglingCount > 0 ? (
|
||||
<p className="text-2xs text-ink-soft">
|
||||
有 {graph.danglingCount} 条关系引用了未入库的角色,已略过。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Clock3, Globe2, Share2, Sparkles, UserRound } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
|
||||
import { WorldGenerator } from "@/components/generation/WorldGenerator";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type {
|
||||
CharacterCardView,
|
||||
ProjectResponse,
|
||||
@@ -32,6 +40,23 @@ const TABS: { key: CodexTab; label: string }[] = [
|
||||
{ key: "timeline", label: "时间线" },
|
||||
];
|
||||
|
||||
// 关系图谱(React Flow)只在客户端渲染:ssr:false 绕开 canvas/window 依赖,
|
||||
// 并把 React Flow 及其样式拆到独立 chunk,进人物 tab 才加载。
|
||||
const RelationshipGraph = dynamic(
|
||||
() =>
|
||||
import("@/components/characters/RelationshipGraph").then(
|
||||
(mod) => mod.RelationshipGraph,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[28rem] items-center justify-center rounded-lg border border-line bg-bg text-sm text-ink-soft">
|
||||
关系图谱加载中…
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
// 设定库 Codex(UX §6.5):管理人物 / 世界观 / 时间线。
|
||||
// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显,
|
||||
// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards)。
|
||||
@@ -60,49 +85,92 @@ export function CodexPage({
|
||||
activeNav="codex"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
|
||||
<div className="mb-4 flex items-center gap-2" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`rounded px-3 py-1.5 text-sm ${
|
||||
tab === t.key
|
||||
? "border-b-2 border-cinnabar text-cinnabar"
|
||||
: "text-ink-soft hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PageHeader
|
||||
title="设定库"
|
||||
description="人物、世界观与时间线共同构成写作时的真相源。AI 生成内容也要先预览、再确认入库。"
|
||||
/>
|
||||
<SegmentedControl
|
||||
className="mb-4 w-fit"
|
||||
ariaLabel="设定库分区"
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
options={TABS.map((t) => ({ value: t.key, label: t.label }))}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{tab === "characters" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 font-serif text-sm text-ink">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-title-md text-ink">
|
||||
已入库人物({characters.length})
|
||||
</h3>
|
||||
{characters.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{characters.map((c, i) => (
|
||||
<li
|
||||
key={`${c.name}-${i}`}
|
||||
className="rounded bg-bg px-2 py-1 text-xs text-ink-soft"
|
||||
className="rounded-lg border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
{c.name}({c.role})
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<UserRound className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-serif text-base text-ink">
|
||||
{c.name}
|
||||
</p>
|
||||
<p className="text-xs text-ink-soft">{c.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
{c.relations && c.relations.length > 0 ? (
|
||||
<ul className="mt-1 flex flex-wrap gap-1">
|
||||
{c.relations.map((r, j) => (
|
||||
<li
|
||||
key={`${r.name}-${r.kind}-${j}`}
|
||||
className="rounded-full bg-panel px-2 py-0.5 text-2xs"
|
||||
title={r.note ?? undefined}
|
||||
>
|
||||
{r.kind} · {r.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-ink-soft">
|
||||
暂无入库人物,先在下方生成并入库。
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={UserRound}
|
||||
title="暂无入库人物"
|
||||
description="先生成或手动整理主角、配角与关系,再让写作上下文稳定引用。"
|
||||
action={
|
||||
<Button
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("character-generator")
|
||||
?.scrollIntoView({ block: "center" })
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
生成角色
|
||||
</Button>
|
||||
}
|
||||
className="bg-bg/50"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</Card>
|
||||
{characters.length > 0 ? (
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-serif text-title-md text-ink">
|
||||
<Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" />
|
||||
关系图谱
|
||||
</h3>
|
||||
<RelationshipGraph characters={characters} />
|
||||
</Card>
|
||||
) : null}
|
||||
<div id="character-generator">
|
||||
<CharacterGenerator
|
||||
projectId={project.id}
|
||||
onIngested={(_, cards) =>
|
||||
@@ -110,12 +178,13 @@ export function CodexPage({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "world" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 font-serif text-sm text-ink">
|
||||
<Card as="section" className="p-4">
|
||||
<h3 className="mb-3 font-serif text-title-md text-ink">
|
||||
已入库世界观({initialWorldEntities.length})
|
||||
</h3>
|
||||
{initialWorldEntities.length > 0 ? (
|
||||
@@ -123,15 +192,19 @@ export function CodexPage({
|
||||
{initialWorldEntities.map((entity, i) => (
|
||||
<article
|
||||
key={`${entity.name}-${i}`}
|
||||
className="rounded border border-line bg-bg p-3 text-sm"
|
||||
className="rounded-lg border border-line bg-bg p-3 text-sm"
|
||||
>
|
||||
<header className="mb-2 flex items-center gap-2">
|
||||
<Globe2
|
||||
className="h-4 w-4 text-cinnabar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-serif text-base text-ink">
|
||||
{entity.name}
|
||||
</span>
|
||||
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft">
|
||||
<Badge>
|
||||
{entity.type}
|
||||
</span>
|
||||
</Badge>
|
||||
</header>
|
||||
{worldEntityRules(entity).length > 0 ? (
|
||||
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
|
||||
@@ -146,19 +219,40 @@ export function CodexPage({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-soft">
|
||||
暂无入库世界观,可在下方生成预览参考。
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={Globe2}
|
||||
title="暂无入库世界观"
|
||||
description="先沉淀硬规则、地理、势力与能力边界,减少后续章节设定漂移。"
|
||||
action={
|
||||
<Button
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("world-generator")
|
||||
?.scrollIntoView({ block: "center" })
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
生成世界观
|
||||
</Button>
|
||||
}
|
||||
className="bg-bg/50"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</Card>
|
||||
<div id="world-generator">
|
||||
<WorldGenerator projectId={project.id} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "timeline" ? (
|
||||
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft">
|
||||
时间线为派生视图(P2),将由章节摘要 + 伏笔窗口自动汇总,敬请期待。
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Clock3}
|
||||
title="时间线尚未启用"
|
||||
description="时间线会从章节摘要、伏笔窗口和验收记录派生,后续可用于全书一致性扫描。"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
@@ -13,6 +19,61 @@ import {
|
||||
} from "@/lib/command/palette";
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||
import { focusRing, overlayScrim } from "@/lib/ui/variants";
|
||||
|
||||
// 命令面板开关的极简外部 store:⌘K 与可见入口(顶栏按钮 / 移动抽屉)共享同一开态。
|
||||
// 用 useSyncExternalStore 订阅,避免把 open 提升到 RootLayout context 造成全树重渲染。
|
||||
let paletteOpen = false;
|
||||
const paletteListeners = new Set<() => void>();
|
||||
|
||||
function emitPalette(): void {
|
||||
for (const listener of paletteListeners) listener();
|
||||
}
|
||||
|
||||
export function setCommandPaletteOpen(next: boolean): void {
|
||||
if (paletteOpen === next) return;
|
||||
paletteOpen = next;
|
||||
emitPalette();
|
||||
}
|
||||
|
||||
export function openCommandPalette(): void {
|
||||
setCommandPaletteOpen(true);
|
||||
}
|
||||
|
||||
export function toggleCommandPalette(): void {
|
||||
setCommandPaletteOpen(!paletteOpen);
|
||||
}
|
||||
|
||||
function subscribePalette(callback: () => void): () => void {
|
||||
paletteListeners.add(callback);
|
||||
return () => paletteListeners.delete(callback);
|
||||
}
|
||||
|
||||
function getPaletteSnapshot(): boolean {
|
||||
return paletteOpen;
|
||||
}
|
||||
|
||||
// SSR 快照恒为 false(命令面板永不在服务端渲染开态),避免水合不一致。
|
||||
export function useCommandPaletteOpen(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribePalette,
|
||||
getPaletteSnapshot,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
const LIST_ID = "command-list";
|
||||
const optionId = (cmd: Command): string => `command-option-${cmd.id}`;
|
||||
|
||||
// 统一的键帽(kbd)样式:暖纸感描边 + 等宽小字,与 NavDrawer 的 ⌘K 提示一致。
|
||||
function KeyCap({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="rounded border border-line bg-panel px-1 font-mono text-2xs text-ink-soft">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
// 从 pathname 抽取当前 projectId(/projects/<id>/...)。
|
||||
function projectIdFromPath(pathname: string): string | null {
|
||||
@@ -25,14 +86,17 @@ interface CommandPaletteProps {
|
||||
}
|
||||
|
||||
// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。
|
||||
// 焦点管理(打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11y(role=dialog/listbox)。
|
||||
// WAI-ARIA combobox/listbox:input role=combobox(aria-expanded/controls/activedescendant),
|
||||
// 列表 role=listbox,每项稳定 id + role=option。焦点管理:打开聚焦输入框、关闭归还触发元素。
|
||||
export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const open = useCommandPaletteOpen();
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
// 打开前的焦点元素:关闭时归还焦点(WCAG 2.4.3)。
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
|
||||
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
|
||||
const toolsLoadedRef = useRef(false);
|
||||
@@ -51,7 +115,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setOpen(false);
|
||||
setCommandPaletteOpen(false);
|
||||
setQuery("");
|
||||
setHighlight(0);
|
||||
}, []);
|
||||
@@ -65,21 +129,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
[close, router],
|
||||
);
|
||||
|
||||
useBodyScrollLock(open);
|
||||
|
||||
// ⌘K / Ctrl+K 全局开关。
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
toggleCommandPalette();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// 打开时聚焦输入框;切查询重置高亮。
|
||||
// 打开时记住先前焦点并聚焦输入框;关闭后把焦点归还触发元素。
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
if (open) {
|
||||
restoreFocusRef.current = document.activeElement as HTMLElement | null;
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
restoreFocusRef.current?.focus();
|
||||
restoreFocusRef.current = null;
|
||||
}, [open]);
|
||||
|
||||
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
|
||||
@@ -110,6 +182,8 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const activeCmd = results[highlight];
|
||||
|
||||
const onInputKey = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -128,7 +202,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 pt-[15vh] motion-safe:transition-opacity"
|
||||
className={`${overlayScrim} flex items-start justify-center pt-[15vh] motion-safe:transition-opacity`}
|
||||
onClick={close}
|
||||
>
|
||||
<div
|
||||
@@ -136,35 +210,53 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="命令面板"
|
||||
className="w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-paper"
|
||||
className="w-full max-w-lg overflow-hidden rounded border border-line bg-panel shadow-paper"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
// focus trap:Tab/Shift+Tab 在对话框内循环,不逃逸到背景(WCAG 2.1.2)。
|
||||
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
|
||||
}}
|
||||
>
|
||||
<div className="relative border-b border-line">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onInputKey}
|
||||
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
|
||||
role="combobox"
|
||||
aria-label="命令搜索"
|
||||
aria-controls="command-list"
|
||||
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none"
|
||||
aria-expanded={true}
|
||||
aria-controls={LIST_ID}
|
||||
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
|
||||
aria-autocomplete="list"
|
||||
className={`w-full bg-bg py-3 pl-4 pr-12 text-sm text-ink ${focusRing}`}
|
||||
/>
|
||||
{/* 触发快捷键读数:随面板常驻,强化 ⌘K 的可发现性(纯装饰,不入 Tab 序)。 */}
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<KeyCap>⌘K</KeyCap>
|
||||
</span>
|
||||
</div>
|
||||
<ul
|
||||
id="command-list"
|
||||
id={LIST_ID}
|
||||
role="listbox"
|
||||
aria-label="命令列表"
|
||||
className="max-h-80 overflow-auto py-1"
|
||||
className="max-h-80 overflow-auto py-1 overscroll-contain"
|
||||
>
|
||||
{results.length === 0 ? (
|
||||
<li className="px-4 py-3 text-sm text-ink-soft">无匹配命令</li>
|
||||
<li className="px-4 py-6 text-center">
|
||||
<p className="text-sm text-ink-soft">
|
||||
没有匹配「{query.trim()}」的命令
|
||||
</p>
|
||||
<p className="mt-1 text-caption text-muted-soft">
|
||||
试试「写本章」「审稿」「生成角色」「搜设定」
|
||||
</p>
|
||||
</li>
|
||||
) : (
|
||||
results.map((cmd, i) => (
|
||||
<li
|
||||
key={cmd.id}
|
||||
id={optionId(cmd)}
|
||||
role="option"
|
||||
aria-selected={i === highlight}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
@@ -176,11 +268,30 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
|
||||
}`}
|
||||
>
|
||||
<span>{cmd.title}</span>
|
||||
<span className="text-xs text-ink-soft">{cmd.group}</span>
|
||||
<span className="text-caption text-ink-soft">{cmd.group}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
{/* 键盘操作读数:↑↓ 选择 · ↵ 打开 · esc 关闭(常驻引导,纯装饰)。 */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-line px-4 py-2 text-caption text-muted-soft"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyCap>↑</KeyCap>
|
||||
<KeyCap>↓</KeyCap>
|
||||
选择
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyCap>↵</KeyCap>
|
||||
打开
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<KeyCap>esc</KeyCap>
|
||||
关闭
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import { CommandPalette, openCommandPalette } from "./CommandPalette";
|
||||
|
||||
// 全局挂载命令面板(⌘K):读当前 pathname 注入项目上下文。
|
||||
// 放在 RootLayout 内,对所有页面生效。
|
||||
@@ -10,3 +12,27 @@ export function CommandPaletteMount() {
|
||||
const pathname = usePathname();
|
||||
return <CommandPalette pathname={pathname ?? "/"} />;
|
||||
}
|
||||
|
||||
// 命令面板的可见入口(顶栏常驻):让不知道 ⌘K 的用户也能发现搜索。
|
||||
// 与 ⌘K 触发同一外部 store,移动端折叠为图标。
|
||||
export function CommandSearchButton() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCommandPalette()}
|
||||
aria-label="搜索命令或页面"
|
||||
title="搜索命令或页面(⌘K)"
|
||||
className={buttonClass({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
className: "border-transparent",
|
||||
})}
|
||||
>
|
||||
<Search className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">搜索</span>
|
||||
<kbd className="hidden rounded border border-line bg-bg px-1 font-mono text-2xs text-ink-soft sm:inline">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Flag, Plus } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
LANES,
|
||||
LANE_LABELS,
|
||||
countByStatus,
|
||||
groupByStatus,
|
||||
type ForeshadowStatus,
|
||||
} from "@/lib/foreshadow/board";
|
||||
import type { BadgeVariant } from "@/lib/ui/variants";
|
||||
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
|
||||
import { KanbanColumn } from "./KanbanColumn";
|
||||
import { RegisterForm } from "./RegisterForm";
|
||||
@@ -25,7 +34,9 @@ export function ForeshadowBoard({
|
||||
initialItems,
|
||||
}: ForeshadowBoardProps) {
|
||||
const { items, busy, register, transition } = useForeshadow(initialItems);
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
const lanes = useMemo(() => groupByStatus(items), [items]);
|
||||
const counts = useMemo(() => countByStatus(items), [items]);
|
||||
|
||||
const onTransition = (
|
||||
code: string,
|
||||
@@ -46,16 +57,58 @@ export function ForeshadowBoard({
|
||||
projectId={project.id}
|
||||
activeNav="foreshadow"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<h1 className="font-serif text-lg text-ink">伏笔看板</h1>
|
||||
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col p-4 xl:h-[calc(100vh-var(--chrome,4rem))] xl:min-h-0">
|
||||
<PageHeader
|
||||
title="伏笔看板"
|
||||
description="跟踪每条伏笔从埋设、推进到回收的状态,逾期项会单独提醒。"
|
||||
actions={
|
||||
// registerOpen 时隐藏头部入口,避免与展开的表单重复(全局只一个 RegisterForm)。
|
||||
registerOpen ? undefined : (
|
||||
<Button
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记伏笔
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{registerOpen ? (
|
||||
<div className="mb-4">
|
||||
<RegisterForm
|
||||
projectId={project.id}
|
||||
busy={busy}
|
||||
onRegister={register}
|
||||
open={registerOpen}
|
||||
onOpenChange={setRegisterOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
) : null}
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Flag}
|
||||
title="还没有伏笔"
|
||||
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
|
||||
action={
|
||||
registerOpen ? undefined : (
|
||||
<Button
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记伏笔
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
className="mt-6"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ForeshadowBoardSummary counts={counts} />
|
||||
<div className="grid flex-none grid-cols-1 gap-3 md:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-4">
|
||||
{LANES.map((status) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
@@ -66,7 +119,45 @@ export function ForeshadowBoard({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const SUMMARY_VARIANTS: Record<ForeshadowStatus, BadgeVariant> = {
|
||||
OPEN: "accent",
|
||||
PARTIAL: "info",
|
||||
CLOSED: "success",
|
||||
OVERDUE: "warning",
|
||||
};
|
||||
|
||||
function ForeshadowBoardSummary({
|
||||
counts,
|
||||
}: {
|
||||
counts: Record<ForeshadowStatus, number>;
|
||||
}) {
|
||||
const total = LANES.reduce((sum, lane) => sum + counts[lane], 0);
|
||||
return (
|
||||
<section
|
||||
aria-label="伏笔状态概览"
|
||||
className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||
>
|
||||
{LANES.map((status) => (
|
||||
<Card key={status} data-status={status} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant={SUMMARY_VARIANTS[status]}>
|
||||
{LANE_LABELS[status]}
|
||||
</Badge>
|
||||
<span className="font-mono text-sm text-ink">{counts[status]}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xs text-ink-soft">
|
||||
占比{" "}
|
||||
{total > 0 ? `${Math.round((counts[status] / total) * 100)}%` : "0%"}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
|
||||
interface ForeshadowCardProps {
|
||||
item: ForeshadowView;
|
||||
@@ -44,19 +48,33 @@ export function ForeshadowCard({
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-xs text-cinnabar">{item.code}</span>
|
||||
{overdue ? (
|
||||
<span className="text-xs text-overdue" aria-label="逾期">
|
||||
⚠ 逾期
|
||||
</span>
|
||||
<Badge variant="warning" aria-label="逾期">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
逾期
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
|
||||
|
||||
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
|
||||
{typeof item.planted_at === "number" ? (
|
||||
<div>埋于 {item.planted_at} 章</div>
|
||||
<div className="flex gap-1">
|
||||
<dt>埋于</dt>
|
||||
<dd>{item.planted_at} 章</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{windowText ? (
|
||||
<div className="flex gap-1">
|
||||
<dt>窗口</dt>
|
||||
<dd>{windowText}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{item.importance ? (
|
||||
<div className="flex gap-1">
|
||||
<dt className="sr-only">重要度</dt>
|
||||
<dd>{item.importance}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{windowText ? <div>窗口 {windowText}</div> : null}
|
||||
{item.importance ? <div>{item.importance}</div> : null}
|
||||
</dl>
|
||||
|
||||
{item.progress && item.progress.length > 0 ? (
|
||||
@@ -72,15 +90,16 @@ export function ForeshadowCard({
|
||||
{transitions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{transitions.map((to) => (
|
||||
<button
|
||||
<Button
|
||||
key={to}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onTransition(item.code, to, "")}
|
||||
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar hover:text-cinnabar disabled:opacity-40"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="px-2 py-0.5"
|
||||
>
|
||||
→ {to === "CLOSED" ? "回收" : to}
|
||||
</button>
|
||||
→ {to === "CLOSED" ? "回收" : LANE_LABELS[to]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -89,25 +108,27 @@ export function ForeshadowCard({
|
||||
<label htmlFor={`prog-${item.code}`} className="sr-only">
|
||||
{item.code} 进展
|
||||
</label>
|
||||
<input
|
||||
<TextInput
|
||||
id={`prog-${item.code}`}
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="加进展(如:23章发光)"
|
||||
className="min-w-0 flex-1 rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink focus:border-cinnabar focus:outline-none"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1 py-0.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={busy || note.trim().length === 0}
|
||||
onClick={() => {
|
||||
onTransition(item.code, null, note.trim());
|
||||
setNote("");
|
||||
}}
|
||||
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar disabled:opacity-40"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="px-2 py-0.5"
|
||||
>
|
||||
记
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { LANE_LABELS, type ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { ForeshadowCard } from "./ForeshadowCard";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
@@ -25,23 +26,29 @@ export function KanbanColumn({
|
||||
const overdue = status === "OVERDUE";
|
||||
return (
|
||||
<section
|
||||
data-status={status}
|
||||
className="flex min-w-0 flex-col rounded border border-line bg-bg"
|
||||
aria-label={`${status} 泳道`}
|
||||
aria-label={`${LANE_LABELS[status]} 泳道`}
|
||||
>
|
||||
<header
|
||||
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
|
||||
overdue
|
||||
? "border-overdue bg-overdue/10 text-overdue"
|
||||
? "border-overdue bg-overdue/10 text-ink"
|
||||
: "border-line bg-panel text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{overdue ? "⚠ " : ""}
|
||||
{status}
|
||||
<span className="flex items-center gap-2">
|
||||
{overdue ? (
|
||||
<Badge variant="warning">{LANE_LABELS[status]}</Badge>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-ink">
|
||||
{LANE_LABELS[status]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono text-xs">{items.length}</span>
|
||||
</header>
|
||||
<ul className="flex-1 space-y-2 overflow-auto p-2">
|
||||
<ul className="space-y-2 p-2 xl:flex-1 xl:overflow-auto">
|
||||
{items.length === 0 ? (
|
||||
<li className="px-1 py-3 text-xs text-ink-soft/60">—</li>
|
||||
) : (
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
import type { RegisterInput } from "@/lib/foreshadow/board";
|
||||
|
||||
interface RegisterFormProps {
|
||||
projectId: string;
|
||||
busy: boolean;
|
||||
onRegister: (input: RegisterInput) => Promise<boolean>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const EMPTY = {
|
||||
@@ -25,9 +33,16 @@ export function RegisterForm({
|
||||
projectId,
|
||||
busy,
|
||||
onRegister,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: RegisterFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localOpen, setLocalOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...EMPTY });
|
||||
const open = controlledOpen ?? localOpen;
|
||||
const setOpen = (next: boolean): void => {
|
||||
if (controlledOpen === undefined) setLocalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const set = (key: keyof typeof EMPTY, value: string): void =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -51,18 +66,16 @@ export function RegisterForm({
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
+ 登记伏笔
|
||||
</button>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
登记伏笔
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
form.code.trim().length > 0 && form.title.trim().length > 0 && !busy;
|
||||
const missingRequired =
|
||||
form.code.trim().length === 0 || form.title.trim().length === 0;
|
||||
const canSubmit = !missingRequired && !busy;
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -70,113 +83,77 @@ export function RegisterForm({
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="rounded border border-line bg-panel p-4"
|
||||
className={cardClass("p-4")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<Field label="代号 *" id="f-code">
|
||||
<input
|
||||
id="f-code"
|
||||
<Field label="代号" required>
|
||||
<TextInput
|
||||
value={form.code}
|
||||
onChange={(e) => set("code", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标题 *" id="f-title" span2>
|
||||
<input
|
||||
id="f-title"
|
||||
<Field label="标题" required className="col-span-2">
|
||||
<TextInput
|
||||
value={form.title}
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="埋设章" id="f-planted">
|
||||
<input
|
||||
id="f-planted"
|
||||
<Field label="埋设章">
|
||||
<TextInput
|
||||
inputMode="numeric"
|
||||
value={form.plantedAt}
|
||||
onChange={(e) => set("plantedAt", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口起" id="f-from">
|
||||
<input
|
||||
id="f-from"
|
||||
<Field label="回收窗口起">
|
||||
<TextInput
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseFrom}
|
||||
onChange={(e) => set("expectedCloseFrom", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口止" id="f-to">
|
||||
<input
|
||||
id="f-to"
|
||||
<Field label="回收窗口止">
|
||||
<TextInput
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseTo}
|
||||
onChange={(e) => set("expectedCloseTo", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="重要度" id="f-imp">
|
||||
<input
|
||||
id="f-imp"
|
||||
<Field label="重要度">
|
||||
<TextInput
|
||||
value={form.importance}
|
||||
onChange={(e) => set("importance", e.target.value)}
|
||||
placeholder="主线/支线"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="线索内容" id="f-content" span3>
|
||||
<input
|
||||
id="f-content"
|
||||
<Field label="线索内容" className="col-span-2 sm:col-span-3">
|
||||
<TextArea
|
||||
rows={2}
|
||||
value={form.content}
|
||||
onChange={(e) => set("content", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{busy ? "登记中…" : "登记"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded border border-line px-3 py-1.5 text-sm text-ink-soft hover:border-cinnabar"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)} variant="secondary" size="sm">
|
||||
取消
|
||||
</button>
|
||||
</Button>
|
||||
{missingRequired && !busy ? (
|
||||
<span className="text-xs text-ink-soft">先填代号与标题</span>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
id: string;
|
||||
span2?: boolean;
|
||||
span3?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Field({ label, id, span2, span3, children }: FieldProps) {
|
||||
const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
|
||||
return (
|
||||
<div className={span}>
|
||||
<label htmlFor={id} className="block text-xs text-ink-soft">
|
||||
{label}
|
||||
</label>
|
||||
<div className="mt-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toInt(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@/components/ui/Checkbox";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { CharacterCardView } from "@/lib/api/types";
|
||||
|
||||
interface CharacterCardItemProps {
|
||||
@@ -24,19 +26,18 @@ export function CharacterCardItem({
|
||||
}`}
|
||||
>
|
||||
<header className="mb-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
aria-label={`选择角色 ${card.name}`}
|
||||
className="size-4 accent-cinnabar"
|
||||
/>
|
||||
{onEdit ? (
|
||||
<input
|
||||
<TextInput
|
||||
value={card.name}
|
||||
onChange={(e) => onEdit({ name: e.target.value })}
|
||||
aria-label="角色名"
|
||||
className="flex-1 rounded border border-line bg-bg px-2 py-1 font-serif text-base text-ink"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1 font-serif text-base"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 font-serif text-base text-ink">{card.name}</span>
|
||||
@@ -53,6 +54,40 @@ export function CharacterCardItem({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{onEdit || card.appearance ? (
|
||||
<div className="mb-1 flex items-start gap-1 text-ink-soft">
|
||||
<span className="shrink-0 text-ink">外貌:</span>
|
||||
{onEdit ? (
|
||||
<TextInput
|
||||
value={card.appearance ?? ""}
|
||||
onChange={(e) => onEdit({ appearance: e.target.value })}
|
||||
aria-label="外貌形象"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<span>{card.appearance}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{onEdit || card.motive ? (
|
||||
<div className="mb-1 flex items-start gap-1 text-ink-soft">
|
||||
<span className="shrink-0 text-ink">动机:</span>
|
||||
{onEdit ? (
|
||||
<TextInput
|
||||
value={card.motive ?? ""}
|
||||
onChange={(e) => onEdit({ motive: e.target.value })}
|
||||
aria-label="核心动机"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<span>{card.motive}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mb-1 text-ink-soft">
|
||||
<span className="text-ink">背景:</span>
|
||||
{card.backstory}
|
||||
@@ -61,11 +96,12 @@ export function CharacterCardItem({
|
||||
<div className="mb-1 flex items-start gap-1 text-ink-soft">
|
||||
<span className="shrink-0 text-ink">弧光:</span>
|
||||
{onEdit ? (
|
||||
<input
|
||||
<TextInput
|
||||
value={card.arc}
|
||||
onChange={(e) => onEdit({ arc: e.target.value })}
|
||||
aria-label="人物弧光"
|
||||
className="flex-1 rounded border border-line bg-bg px-2 py-1 text-ink"
|
||||
controlSize="sm"
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<span>{card.arc}</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user