Compare commits
90 Commits
72d7111581
...
57b3183564
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -6,6 +6,7 @@ DATABASE_URL_SYNC=postgresql+psycopg://writer:writer@localhost:5432/writer
|
|||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
LOG_JSON=false
|
LOG_JSON=false
|
||||||
# 凭据加密密钥(Fernet, urlsafe-base64 32B)— **必填**:API 启动即校验,缺失/非法则拒绝启动。
|
# 凭据加密密钥(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())"
|
# 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
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,3 +19,5 @@ apps/web/lib/api/openapi.json
|
|||||||
# os
|
# os
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.gstack/
|
.gstack/
|
||||||
|
# coverage artifact(不入库;覆盖率跑生成,误提交由 CR 批 c596a8d 撤销)
|
||||||
|
.coverage
|
||||||
|
|||||||
@@ -481,7 +481,6 @@ class LlmRequest(BaseModel):
|
|||||||
input: str | list[Block] # 易变内容(断点之后)
|
input: str | list[Block] # 易变内容(断点之后)
|
||||||
stream: bool = False
|
stream: bool = False
|
||||||
output_schema: type[BaseModel] | None = None # 需结构化输出时(Pydantic 模型/instructor)
|
output_schema: type[BaseModel] | None = None # 需结构化输出时(Pydantic 模型/instructor)
|
||||||
thinking: bool = False # 是否启用思考/推理(有则启用)
|
|
||||||
max_tokens: int | None = None
|
max_tokens: int | None = None
|
||||||
scope: "Scope" # {project_id; user_id 原型可固定}
|
scope: "Scope" # {project_id; user_id 原型可固定}
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed.
|
|||||||
|
|
||||||
- **环境变量**:`uv` 装到 `~/.local/bin`,命令前 `export PATH="$HOME/.local/bin:$PATH"`。
|
- **环境变量**:`uv` 装到 `~/.local/bin`,命令前 `export PATH="$HOME/.local/bin:$PATH"`。
|
||||||
- **依赖安装**:`uv sync`(后端,仓库根);`pnpm install`(前端,`cd apps/web`)。
|
- **依赖安装**:`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 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 -q --cov --cov-report=term-missing --cov-fail-under=80`。单测:`uv run pytest path::test -q`(单测不带 `--cov`,避免 fail-under 误报)。
|
- **后端门禁**:`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/**`)。
|
- **前端门禁**(`cd apps/web`):`pnpm lint` · `pnpm typecheck` · `pnpm test`(vitest)· `pnpm build`。覆盖率门禁:`pnpm test:coverage`(阈值 80%,范围 `lib/**`)。
|
||||||
|
|||||||
106
PROGRESS.md
106
PROGRESS.md
@@ -8,7 +8,21 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 当前阶段:QA-CR · 全项目 Code Review 整改批
|
## ✅ 已交付: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 面。
|
> **背景**:[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。
|
> **整改 DoD**:CRITICAL + HIGH 全部 ✅ 且各自补回归测试;MEDIUM 择批清理;门禁全绿(后端 ruff/format/mypy/pytest + alembic 无漂移;前端 lint/tsc/vitest/build)。**守 §Definition of Done**:每项 TDD 先红后绿、完成即小步 commit。
|
||||||
@@ -17,29 +31,62 @@
|
|||||||
|
|
||||||
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| CR-C1 🔴 升级 Next.js 修 RCE(CVE-2025-66478)+ React 补丁 + 轮换密钥 | ⬜ | @frontend | — | `apps/web/package.json` next **15.1.3→15.1.9**(或 15.5.7/16.x),react/react-dom 19.0.0→补丁版(上游 CVE-2025-55182);App Router + RSC 应用正受影响。`pnpm install` 重锁 + `pnpm build` 验;**升级后轮换全部应用密钥**(联动 CR-H8)。Next15 LTS 2026-10-21 EOL,规划 16.x 迁移 |
|
| 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 | — | `services/chain_runner.py:210` 默认 25 supersteps、每章 4 节点→超 ~6 章即 `GraphRecursionError` 整 job 失败,「批量产一卷(K章)」在目标规模不可用。`config` 加 `recursion_limit=(last-start+1)*4+10`;补超默认上限的 mock-gateway 回归测试 |
|
| 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 | — | `services/job_runner.py:75`+`routers/kimi_oauth.py:85` 轮询全程(最坏 >200s)占连接池连接、饿死其他请求;轮询循环无需 DB,仅最终 `upsert_oauth_credential` 开短会话 |
|
| 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 | — | `routers/kimi_oauth.py:73` `Depends(_default_http_client)` 非 generator 依赖、永不 `aclose()`;改 `async def _http_client(): async with httpx.AsyncClient(...) as http: yield http` |
|
| CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | ✅ | @backend | — | `5dd336b`+`26f10d0` 请求级依赖改 `_http_client` 生成器(`async with`→`aclose`);两处测 override 跟随改,堵住 E2E 误触真网 |
|
||||||
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | ⬜ | @llm | — | `adapters/anthropic.py:161` 硬编码 `usage=0`→`usage_ledger` 全记 0 成本、腐蚀计费(违 ARCH §4.8);用 `create_with_completion` 取原始响应 usage |
|
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | ✅ | @llm | — | `cb8fddf` 改 `create_with_completion` 取 `raw.usage`,弃硬编码 0(修计费腐蚀) |
|
||||||
| CR-H4 🟠 `request_id` 贯通网关日志 | ⬜ | @llm | — | `gateway.py:183` 所有 `llm_call`/`llm_provider_failed` 无 request_id;`LlmRequest` 加 `request_id` 字段并从 API 层透传(违 ARCH §9.3 端到端追踪不变量) |
|
| CR-H4 🟠 `request_id` 贯通网关日志 | ✅ | @llm | — | `51fe9f4`+`e08b01a` LlmRequest 加可选 `request_id` + **条件透传**(仅非 None,否则覆盖 contextvars 反退化 sync/SSE 追踪);`llm_call`+两处 `llm_provider_failed` 全锁测。契约 C1-扩 |
|
||||||
| CR-H5 🟠 `thinking` 字段:实现或删除 | ⬜ | @llm | — | `types.py:41` 声明但所有 adapter 忽略,`thinking=True` 静默无效、无报错;按 YAGNI 删除或实现转发 + 测试 |
|
| CR-H5 🟠 `thinking` 字段:实现或删除 | ✅ | @llm | — | `afeac51` 删死字段 `LlmRequest.thinking`(YAGNI);保 `Capabilities.thinking`(异名活字段);回写 ARCH/contracts |
|
||||||
| CR-H6 🟠 流式路径加 per-provider 重试 | ⬜ | @llm | — | `gateway.py:268` 流式仅 1 次尝试,首 token 前一次瞬时 429 烧光 fallback 链(与非流式 `_complete_with_retry` 不对称);用 `_retrying()` 包裹首 token 前尝试或显式文档化 |
|
| CR-H6 🟠 流式路径加 per-provider 重试 | ✅ | @llm | — | `8a2b1b0` `_stream_with_retry` 对称 run:仅首 token 前重试,mid-stream 失败照抛(不变量 §4.5) |
|
||||||
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | ⬜ | @backend | — | `packages/skills/ww_skills/toolbox_registry.py:70` 裸 dict 可变;仿同库 `SPECS` 用 `Final[Mapping]=MappingProxyType(...)`(违不可变不变量) |
|
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | ✅ | @backend | — | `25d48b7` `Final[Mapping]=MappingProxyType(_TOOLBOX_BY_KEY)`(仿 SPECS) |
|
||||||
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | ⬜ | @devops | — | 已确认提交真实可用 key(注释「可直接用于本地」),换非功能占位符 + 突出轮换提示;联动 CR-C1 轮换 |
|
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | ✅ | @devops | — | `baa41d3` 换不可用占位符(Fernet 拒绝→照抄即启动失败)+ 轮换提示。**⚠️ 真 key 仍在 git 历史,曾用该 key 的环境须轮换(用户)** |
|
||||||
| CR-H9 🟠 API 文本输入加 `max_length` | ⬜ | @backend | — | `schemas/{providers,generation,projects}.py` `api_key`/`brief`/`final_text`/`segment` 仅 `min_length=1`(DoS/成本失控);加上限(api_key≤512、brief≤1e4、final_text≤2e5)+ ASGI 请求体大小限 |
|
| 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 | — | `lib/style/{useRefine,useStyleLearn}.ts` 各自重复定义,统一 import 自 `lib/generation/cards.ts:132`(DRY 漂移隐患) |
|
| CR-H10 🟠 前端 `errorCode` 去重 | ✅ | @frontend | — | `5665cc9` `lib/style/{useRefine,useStyleLearn}` 统一 import 自 `generation/cards` |
|
||||||
| CR-H11 🟠 Drawer 关闭还原焦点 | ⬜ | @frontend | — | `components/Drawer.tsx:41` 关闭后焦点掉到 body(违 WCAG 2.4.3);传 `triggerRef`、关闭时 `.focus()` |
|
| CR-H11 🟠 Drawer 关闭还原焦点 | ✅ | @frontend | — | `6e2b478` 行为本分支早已修;提取 `lib/a11y/useRestoreFocus` + renderHook 回归测试 + DRY(Drawer/ContextDrawer 共用) |
|
||||||
| CR-H12 🟠 RefineView 取消在途 refine | ⬜ | @frontend | — | `components/style/RefineView.tsx:33` 无 AbortController,段切换时旧响应可覆盖新响应;`useRefine` 暴露 abort 在 effect cleanup 调用,去 blanket exhaustive-deps disable |
|
| CR-H12 🟠 RefineView 取消在途 refine | ✅ | @frontend | — | `5c02792` useRefine 暴露 `abort`(setState-free)+ AbortController + aborted 守卫弃迟到响应;RefineView cleanup 调用 + 真 deps |
|
||||||
| CR-M1 🟡 后端架构整改批 | ⬜ | @backend | — | `outline.py:162` volume 下推 DB 过滤;`generation.py:326`/`toolbox.py:431` N+1 INSERT→`bulk_create`;`jobs.py:28` 裸 ORM→repo;`provider_deps.py:91` 去私有 `_client`(加 `probe_connection()`);列表端点加分页。⚠️`assemble.py:155` 8 串行查询:**AsyncSession 不支持同一 session 并发,`gather` 须每查询独立 session**,不可照搬 |
|
| 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 | — | `gateway.py:90` `is_open()` 副作用违 CQS;`pricing.py` 未知 model 静默记 0 → 加 warning;adapter `str(exc)` 入异常消息可能带 vendor 响应体→只留类名+status |
|
| 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 | — | `chain_runner.py:124` accept commit 后 `run_overdue_scan` 非幂等且失败无法 resume→部分进度空洞(幂等化或纳入事务);`chain_runner.py:159` 改用公开 `get_state().next` 替私有 `__interrupt__`;`chain/nodes.py:213` 链内 4 审串行→并行/复用 review 子图 |
|
| 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 | — | 可编辑列表去 index-key(`CharacterGenerator.tsx:135`/`GeneratorRunner.tsx:178`/`ForeshadowCard.tsx:68`);SSE `KNOWN_EVENTS` 补 `stop`(核对 `memory/contracts.md`);ARIA 补全(AppShell nav label / NavDrawer aria-controls / ForeshadowCard `<dl>` 结构);ProjectWizard 步骤焦点;`ReviewReport.tsx:301` setTimeout 卸载清理 |
|
| 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 | — | `lib/api/server.ts:34` `parseJsonBody` 加最小结构校验;`useAccept.ts:60` `ApiErrorEnvelope` 从生成 schema 派生;`applyFix.ts` `hasApplicableFix` 改类型谓词去 `as string` |
|
| 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 | — | `chain.py:256` 静默回退加 warning;SSE 端点补 OpenAPI `text/event-stream` 声明;冗余 `as`/`.valueOf()` cast 清理;测试脚本 `print`→logging |
|
| 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 | — | `pyproject.toml` 下界过旧(`langgraph>=0.2.40`/`anthropic>=0.34`),抬到接近 lockfile 实锁版本保可复现;后端实锁版本均当前。可选评估 vitest 3.x / tailwind 4.x |
|
| 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。
|
> **维度小结**:后端 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,6 +208,20 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
|||||||
|
|
||||||
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
> 格式:`- [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] @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] @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 契约,作客观锚点)。
|
||||||
@@ -245,3 +306,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
|||||||
- [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-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-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-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**。
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -43,6 +43,8 @@
|
|||||||
|
|
||||||
前置:Python 3.12+ · Node 22 · [uv](https://docs.astral.sh/uv/) · pnpm · Docker
|
前置: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
|
```bash
|
||||||
# 1. 克隆后,安装后端依赖(仓库根,uv workspace)
|
# 1. 克隆后,安装后端依赖(仓库根,uv workspace)
|
||||||
uv sync
|
uv sync
|
||||||
@@ -59,17 +61,17 @@ cp .env.example .env
|
|||||||
docker compose up -d pg
|
docker compose up -d pg
|
||||||
uv run alembic upgrade head
|
uv run alembic upgrade head
|
||||||
|
|
||||||
# 5. 起后端 API(默认 http://localhost:8000,文档 /docs)
|
# 5. 起后端 API(http://localhost:9000,文档 /docs)— 宿主端口 +1000
|
||||||
uv run uvicorn ww_api.main:app --reload
|
uv run uvicorn ww_api.main:app --port 9000 --reload
|
||||||
|
|
||||||
# 6. 起前端(另开终端,默认 http://localhost:3000)
|
# 6. 起前端(另开终端,http://localhost:4000)— pnpm dev 已内置 -p 4000
|
||||||
cd apps/web && pnpm dev
|
cd apps/web && pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
或一键起全套(pg + api + web):
|
或一键起全套(pg + api + web,宿主端口均已 +1000):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up
|
docker compose up # web → :4000 · api → :9000 · pg → :6432
|
||||||
```
|
```
|
||||||
|
|
||||||
> **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。
|
> **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ EXPOSE 8000
|
|||||||
# 存活探测:镜像无 curl,用 stdlib urllib 打 /health(非 200/连接失败会抛异常,退出非零)。
|
# 存活探测:镜像无 curl,用 stdlib urllib 打 /health(非 200/连接失败会抛异常,退出非零)。
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
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()"]
|
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
|
||||||
CMD ["uv", "run", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
# --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"
|
version = "0.0.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.111",
|
"fastapi>=0.137",
|
||||||
"uvicorn[standard]>=0.30",
|
"uvicorn[standard]>=0.49",
|
||||||
"structlog>=24.1",
|
"structlog>=26.1",
|
||||||
"cryptography>=43",
|
"cryptography>=49",
|
||||||
"ww-shared",
|
"ww-shared",
|
||||||
"ww-config",
|
"ww-config",
|
||||||
"ww-db",
|
"ww-db",
|
||||||
|
|||||||
@@ -63,8 +63,13 @@ class FakeProjectRepo:
|
|||||||
self.rows[pid] = (owner_id, view)
|
self.rows[pid] = (owner_id, view)
|
||||||
return view
|
return view
|
||||||
|
|
||||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
|
async def list_for_owner(
|
||||||
return [v for (o, v) in self.rows.values() if o == owner_id]
|
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:
|
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
|
||||||
entry = self.rows.get(project_id)
|
entry = self.rows.get(project_id)
|
||||||
@@ -358,8 +363,14 @@ class FakeOutlineReadRepo:
|
|||||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||||
return self.rows.get((project_id, chapter_no))
|
return self.rows.get((project_id, chapter_no))
|
||||||
|
|
||||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
async def list_for_project(
|
||||||
views = [v for (p, _), v in self.rows.items() if p == project_id]
|
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)
|
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
|
||||||
@@ -572,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]
|
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(
|
async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
|
||||||
_patched_runner: None,
|
_patched_runner: None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from ww_agents import (
|
|||||||
WorldEntityCard,
|
WorldEntityCard,
|
||||||
WorldGenResult,
|
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.project_repo import ProjectCreate
|
||||||
from ww_core.domain.rule_repo import RuleListItemView
|
from ww_core.domain.rule_repo import RuleListItemView
|
||||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||||
@@ -120,6 +120,27 @@ class _FakeCharacterWriteRepo:
|
|||||||
)
|
)
|
||||||
return CharacterWriteView(id=row_id, name=name, role=role)
|
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:
|
class _FakeRulesReadRepo:
|
||||||
"""实现规则 repo 的读侧 `list_for_project`(带 id,供 list_rules 端点)。"""
|
"""实现规则 repo 的读侧 `list_for_project`(带 id,供 list_rules 端点)。"""
|
||||||
@@ -352,6 +373,39 @@ async def test_ingest_characters_no_conflict_writes_and_201() -> None:
|
|||||||
assert session.commits == 1
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_ingest_characters_with_conflict_blocks_409() -> None:
|
async def test_ingest_characters_with_conflict_blocks_409() -> None:
|
||||||
repo = FakeProjectRepo()
|
repo = FakeProjectRepo()
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ class _OutlineRepo:
|
|||||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||||
return self._rows.get(chapter_no)
|
return self._rows.get(chapter_no)
|
||||||
|
|
||||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
async def list_for_project(
|
||||||
return [self._rows[k] for k in sorted(self._rows)]
|
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:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -51,12 +53,65 @@ class _ScriptedHttp:
|
|||||||
return None
|
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(
|
def _make_client(
|
||||||
*,
|
*,
|
||||||
store: FakeCredentialStore,
|
store: FakeCredentialStore,
|
||||||
http: _ScriptedHttp,
|
http: Any,
|
||||||
enc_key: str,
|
enc_key: str,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
session_factory: Any = None,
|
||||||
) -> TestClient:
|
) -> TestClient:
|
||||||
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
|
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
|
||||||
from ww_config import get_settings
|
from ww_config import get_settings
|
||||||
@@ -88,12 +143,15 @@ def _make_client(
|
|||||||
job_repo = FakeJobRepo()
|
job_repo = FakeJobRepo()
|
||||||
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
|
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
|
||||||
|
|
||||||
|
sf = session_factory if session_factory is not None else FakeSessionFactory()
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
app.dependency_overrides[get_credential_store] = lambda: store
|
app.dependency_overrides[get_credential_store] = lambda: store
|
||||||
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
||||||
app.dependency_overrides[get_session] = lambda: FakeSession()
|
app.dependency_overrides[get_session] = lambda: FakeSession()
|
||||||
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
|
app.dependency_overrides[get_session_factory] = lambda: sf
|
||||||
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http
|
# 请求级 http 现为生成器依赖 `_http_client`(CR-H2)——override 它注 fake(否则真生成器联网)。
|
||||||
|
app.dependency_overrides[routers.kimi_oauth._http_client] = lambda: http
|
||||||
return TestClient(app)
|
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
|
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:
|
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
enc_key = Fernet.generate_key().decode()
|
enc_key = Fernet.generate_key().decode()
|
||||||
store = FakeCredentialStore()
|
store = FakeCredentialStore()
|
||||||
|
|||||||
@@ -264,6 +264,25 @@ async def test_get_outline_with_volume_filters_to_that_volume() -> None:
|
|||||||
assert all(c["volume"] == 2 for c in vol2_chapters)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
|
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
|
||||||
project_repo = FakeProjectRepo()
|
project_repo = FakeProjectRepo()
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ import uuid
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from cryptography.fernet import Fernet
|
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_api.services.credentials import STUB_OWNER_ID
|
||||||
from ww_core.domain.project_repo import ProjectView
|
from ww_core.domain.project_repo import ProjectView
|
||||||
from ww_core.domain.repositories import (
|
from ww_core.domain.repositories import (
|
||||||
@@ -31,7 +38,9 @@ class _EmptyOutlineRepo:
|
|||||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||||
return 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 []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -213,6 +222,33 @@ async def test_list_projects() -> None:
|
|||||||
assert all(p["pending_review_count"] == 0 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
|
@pytest.mark.asyncio
|
||||||
async def test_get_project_detail() -> None:
|
async def test_get_project_detail() -> None:
|
||||||
client, _, _, _ = _make_client()
|
client, _, _, _ = _make_client()
|
||||||
@@ -361,3 +397,191 @@ async def test_get_draft_404_when_no_draft() -> None:
|
|||||||
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
|
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
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
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ def _app_with_overrides(
|
|||||||
session_factory: FakeSessionFactory | None = None,
|
session_factory: FakeSessionFactory | None = None,
|
||||||
extract_gateway: object | None = None,
|
extract_gateway: object | None = None,
|
||||||
refine_gateway: object | None = None,
|
refine_gateway: object | None = None,
|
||||||
|
clarify_gateway: object | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||||
from ww_api.main import create_app
|
from ww_api.main import create_app
|
||||||
from ww_api.services.project_deps import (
|
from ww_api.services.project_deps import (
|
||||||
|
get_clarify_gateway,
|
||||||
get_job_repo,
|
get_job_repo,
|
||||||
get_project_repo,
|
get_project_repo,
|
||||||
get_refine_gateway,
|
get_refine_gateway,
|
||||||
@@ -85,6 +87,8 @@ def _app_with_overrides(
|
|||||||
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
|
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
|
||||||
if refine_gateway is not None:
|
if refine_gateway is not None:
|
||||||
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -394,3 +398,134 @@ async def test_refine_without_credentials_503() -> None:
|
|||||||
assert resp.status_code == 503
|
assert resp.status_code == 503
|
||||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||||
assert session.commits == 0
|
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
|
self.rows[view.id] = view
|
||||||
return view
|
return view
|
||||||
|
|
||||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
|
async def list_for_owner(
|
||||||
return list(self.rows.values())
|
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:
|
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
|
||||||
if template_id in self.rows:
|
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.project_repo import ProjectCreate
|
||||||
from ww_core.domain.repositories import OutlineView
|
from ww_core.domain.repositories import OutlineView
|
||||||
from ww_core.domain.rule_repo import RuleWriteView
|
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_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||||
from ww_shared import AppError, ErrorCode
|
from ww_shared import AppError, ErrorCode
|
||||||
|
|
||||||
@@ -75,6 +75,15 @@ class _FakeWorldWriteRepo:
|
|||||||
self.rows.append({"type": type, "name": name, "rules": list(rules)})
|
self.rows.append({"type": type, "name": name, "rules": list(rules)})
|
||||||
return WorldEntityWriteView(id=uuid.uuid4(), type=type, name=name)
|
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:
|
class _FakeOutlineWriteRepo:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -569,6 +578,30 @@ async def test_ingest_world_entities_no_conflict_writes_201() -> None:
|
|||||||
assert session.commits == 1
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_ingest_world_entities_conflict_blocks_409() -> None:
|
async def test_ingest_world_entities_conflict_blocks_409() -> None:
|
||||||
repo = FakeProjectRepo()
|
repo = FakeProjectRepo()
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ from ww_db import get_sessionmaker
|
|||||||
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
|
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
|
||||||
|
|
||||||
from ww_api.logging_config import configure_logging, get_logger
|
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 (
|
from ww_api.routers import (
|
||||||
|
ai_messages,
|
||||||
chain,
|
chain,
|
||||||
foreshadow,
|
foreshadow,
|
||||||
generation,
|
generation,
|
||||||
@@ -74,6 +79,8 @@ def create_app() -> FastAPI:
|
|||||||
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
|
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
|
||||||
)
|
)
|
||||||
app.middleware("http")(request_id_middleware)
|
app.middleware("http")(request_id_middleware)
|
||||||
|
# 请求体大小守卫(CR-H9):在路由前拦截超大 body,直接 413。
|
||||||
|
app.middleware("http")(body_size_limit_middleware)
|
||||||
|
|
||||||
@app.exception_handler(AppError)
|
@app.exception_handler(AppError)
|
||||||
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
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(rules.router)
|
||||||
app.include_router(style.router)
|
app.include_router(style.router)
|
||||||
app.include_router(templates.router)
|
app.include_router(templates.router)
|
||||||
|
app.include_router(ai_messages.router)
|
||||||
app.include_router(generation.router)
|
app.include_router(generation.router)
|
||||||
app.include_router(generation.skills_router)
|
app.include_router(generation.skills_router)
|
||||||
app.include_router(toolbox.router)
|
app.include_router(toolbox.router)
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ from collections.abc import Awaitable, Callable
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from starlette.requests import Request
|
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"
|
REQUEST_ID_HEADER = "x-request-id"
|
||||||
|
|
||||||
|
# 请求体大小上限(2 MiB):超过即 413,防超大 body 撑爆内存/成本(CR-H9)。
|
||||||
|
MAX_BODY_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 uuid4().hex、常见 trace id 与客户端
|
# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 uuid4().hex、常见 trace id 与客户端
|
||||||
# 自定义短 id;含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经
|
# 自定义短 id;含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经
|
||||||
# 校验的客户端值原样写进日志/响应头(日志注入防护)。
|
# 校验的客户端值原样写进日志/响应头(日志注入防护)。
|
||||||
@@ -38,3 +42,29 @@ async def request_id_middleware(
|
|||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
response.headers[REQUEST_ID_HEADER] = request_id
|
response.headers[REQUEST_ID_HEADER] = request_id
|
||||||
return response
|
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])
|
||||||
@@ -33,6 +33,7 @@ from ww_agents import (
|
|||||||
worldbuilder_spec,
|
worldbuilder_spec,
|
||||||
)
|
)
|
||||||
from ww_core.domain import (
|
from ww_core.domain import (
|
||||||
|
CharacterWriteFields,
|
||||||
CharacterWriteRepo,
|
CharacterWriteRepo,
|
||||||
ProjectRepo,
|
ProjectRepo,
|
||||||
RuleWriteRepo,
|
RuleWriteRepo,
|
||||||
@@ -372,10 +373,8 @@ async def ingest_characters(
|
|||||||
rejected_tables=rejected,
|
rejected_tables=rejected,
|
||||||
)
|
)
|
||||||
|
|
||||||
created: list[str] = []
|
fields = [
|
||||||
for card in allowed.get("characters", []):
|
CharacterWriteFields(
|
||||||
view = await char_repo.create(
|
|
||||||
project_id,
|
|
||||||
name=card.name,
|
name=card.name,
|
||||||
role=card.role,
|
role=card.role,
|
||||||
traits=list(card.traits),
|
traits=list(card.traits),
|
||||||
@@ -387,7 +386,10 @@ async def ingest_characters(
|
|||||||
tags=list(card.tags),
|
tags=list(card.tags),
|
||||||
relations=[r.model_dump() for r in card.relations],
|
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。
|
# 提交边界:网关 ledger(precheck)+ 角色写侧均只 flush → 端点末尾一次 commit。
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import uuid
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
from ww_core.domain import JobRepo
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from ww_db import get_session
|
|
||||||
from ww_db.models import Job
|
|
||||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||||
|
|
||||||
from ww_api.schemas.jobs import JobResponse
|
from ww_api.schemas.jobs import JobResponse
|
||||||
|
from ww_api.services.project_deps import get_job_repo
|
||||||
|
|
||||||
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||||
|
|
||||||
@@ -23,9 +21,9 @@ router = APIRouter(prefix="/jobs", tags=["jobs"])
|
|||||||
)
|
)
|
||||||
async def get_job(
|
async def get_job(
|
||||||
job_id: uuid.UUID,
|
job_id: uuid.UUID,
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
job_repo: Annotated[JobRepo, Depends(get_job_repo)],
|
||||||
) -> JobResponse:
|
) -> 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:
|
if job is None:
|
||||||
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
|
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
|
||||||
return JobResponse(
|
return JobResponse(
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -47,6 +49,7 @@ from ww_api.services.kimi_oauth import (
|
|||||||
AuthorizationPending,
|
AuthorizationPending,
|
||||||
DeviceAuth,
|
DeviceAuth,
|
||||||
SlowDown,
|
SlowDown,
|
||||||
|
TokenSet,
|
||||||
decrypt_oauth_bundle,
|
decrypt_oauth_bundle,
|
||||||
encrypt_oauth_bundle,
|
encrypt_oauth_bundle,
|
||||||
poll_token,
|
poll_token,
|
||||||
@@ -70,51 +73,102 @@ _JOB_KIND_KIMI_OAUTH = "kimi_oauth"
|
|||||||
_MAX_POLL_ATTEMPTS = 200
|
_MAX_POLL_ATTEMPTS = 200
|
||||||
|
|
||||||
|
|
||||||
|
#: 请求/后台 HTTP 客户端超时(秒)。
|
||||||
|
_HTTP_TIMEOUT_SECONDS = 30.0
|
||||||
|
|
||||||
|
|
||||||
def _default_http_client() -> AsyncHttpClient:
|
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:
|
async def _http_client() -> AsyncIterator[AsyncHttpClient]:
|
||||||
"""构造后台轮询工作闭包:循环 poll token 直到成功/过期/拒绝。
|
"""请求级 httpx 客户端**生成器依赖**:请求结束时经 `async with` 自动 aclose(CR-H2)。
|
||||||
|
|
||||||
`work(session)` 自建 httpx 客户端 + 凭据 store(用 `run_job` 传入的独立 session)→
|
普通函数依赖无 teardown,返回的 client 永不关闭 → 每次 `/start` 泄漏一个连接。故请求路径
|
||||||
按 `interval` 轮询 token → 成功则加密存 `oauth_enc` 并返回非密 job 结果 → 过期/拒绝则
|
用此生成器依赖(后台路径仍用裸工厂 `_default_http_client`,自管生命周期)。
|
||||||
抛 AppError(`run_job` 置 job failed)。**token 绝不进 job 结果/日志**。
|
|
||||||
"""
|
"""
|
||||||
|
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()
|
# SlowDown 退避每次增大的间隔量(秒)。
|
||||||
store = SqlCredentialStore(session)
|
_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)
|
interval = max(1, device.interval)
|
||||||
|
|
||||||
http = _default_http_client()
|
|
||||||
try:
|
|
||||||
for _ in range(_MAX_POLL_ATTEMPTS):
|
for _ in range(_MAX_POLL_ATTEMPTS):
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
try:
|
try:
|
||||||
token = await poll_token(http, device.device_code)
|
return await poll_token(http, device.device_code)
|
||||||
except AuthorizationPending:
|
except AuthorizationPending:
|
||||||
continue
|
continue
|
||||||
except SlowDown:
|
except SlowDown:
|
||||||
interval += 5
|
interval += _SLOW_DOWN_STEP_SECONDS
|
||||||
continue
|
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(
|
raise AppError(
|
||||||
ErrorCode.LLM_UNAVAILABLE,
|
ErrorCode.LLM_UNAVAILABLE,
|
||||||
"Kimi 设备授权轮询超时",
|
"Kimi 设备授权轮询超时",
|
||||||
{"provider": KIMI_CODE_PROVIDER},
|
{"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:
|
finally:
|
||||||
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
|
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
|
||||||
aclose = getattr(http, "aclose", None)
|
aclose = getattr(http, "aclose", None)
|
||||||
if aclose is not None:
|
if aclose is not None:
|
||||||
await aclose()
|
await aclose()
|
||||||
|
await run_job(session_factory, job_id, work, request_id=request_id)
|
||||||
return work
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/start", status_code=202)
|
@router.post("/start", status_code=202)
|
||||||
@@ -125,7 +179,7 @@ async def start_oauth(
|
|||||||
job_repo: JobRepoDep,
|
job_repo: JobRepoDep,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
session_factory: SessionFactoryDep,
|
session_factory: SessionFactoryDep,
|
||||||
http: Annotated[AsyncHttpClient, Depends(_default_http_client)],
|
http: Annotated[AsyncHttpClient, Depends(_http_client)],
|
||||||
) -> OAuthStartResponse:
|
) -> OAuthStartResponse:
|
||||||
"""发起 device authorization:返回 202 + user_code/verification_uri,后台轮询 token。
|
"""发起 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)
|
job = await job_repo.create(None, _JOB_KIND_KIMI_OAUTH)
|
||||||
await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。
|
await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。
|
||||||
|
|
||||||
|
# 轮询移出 DB 会话(CR-H1):后台先在会话外轮询 token,仅最终持久化开一个短会话。
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
run_job,
|
_run_kimi_oauth_poll,
|
||||||
session_factory,
|
session_factory,
|
||||||
job.id,
|
job.id,
|
||||||
_make_poll_work(device),
|
device,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -158,9 +158,7 @@ async def get_outline(
|
|||||||
if project is None:
|
if project is None:
|
||||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
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)
|
||||||
if volume is not None:
|
|
||||||
views = [v for v in views if v.volume == volume]
|
|
||||||
chapters = [
|
chapters = [
|
||||||
OutlineChapterView(
|
OutlineChapterView(
|
||||||
no=view.chapter_no,
|
no=view.chapter_no,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from typing import Annotated, Any
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.chapter_repo import ChapterRepo
|
||||||
from ww_core.domain.digest_repo import DigestAppendRepo
|
from ww_core.domain.digest_repo import DigestAppendRepo
|
||||||
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
|
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
|
||||||
@@ -36,13 +37,16 @@ from ww_core.orchestrator import (
|
|||||||
error_event,
|
error_event,
|
||||||
normalize_deltas,
|
normalize_deltas,
|
||||||
normalize_review,
|
normalize_review,
|
||||||
|
run_clarify,
|
||||||
stream_chapter_draft,
|
stream_chapter_draft,
|
||||||
|
stream_chapter_rewrite,
|
||||||
)
|
)
|
||||||
from ww_db import get_session
|
from ww_db import get_session
|
||||||
from ww_llm_gateway import Gateway
|
from ww_llm_gateway import Gateway
|
||||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||||
|
|
||||||
from ww_api.logging_config import get_logger
|
from ww_api.logging_config import get_logger
|
||||||
|
from ww_api.pagination import PageDep
|
||||||
from ww_api.schemas.injection import (
|
from ww_api.schemas.injection import (
|
||||||
InjectionEntity,
|
InjectionEntity,
|
||||||
InjectionEntityRef,
|
InjectionEntityRef,
|
||||||
@@ -63,6 +67,8 @@ from ww_api.schemas.projects import (
|
|||||||
ReviewHistoryItem,
|
ReviewHistoryItem,
|
||||||
ReviewHistoryResponse,
|
ReviewHistoryResponse,
|
||||||
ReviewRequest,
|
ReviewRequest,
|
||||||
|
RewriteClarifyRequest,
|
||||||
|
RewriteStreamRequest,
|
||||||
)
|
)
|
||||||
from ww_api.services.accept_service import (
|
from ww_api.services.accept_service import (
|
||||||
AcceptOutcome,
|
AcceptOutcome,
|
||||||
@@ -74,6 +80,7 @@ from ww_api.services.digest_extraction import extract_digest_facts
|
|||||||
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
|
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
|
||||||
from ww_api.services.project_deps import (
|
from ww_api.services.project_deps import (
|
||||||
get_chapter_repo,
|
get_chapter_repo,
|
||||||
|
get_clarify_gateway,
|
||||||
get_digest_append_repo,
|
get_digest_append_repo,
|
||||||
get_digest_gateway,
|
get_digest_gateway,
|
||||||
get_injection_repo,
|
get_injection_repo,
|
||||||
@@ -98,12 +105,20 @@ _ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
|
|||||||
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
|
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
|
||||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
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)]
|
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||||
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
||||||
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||||
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
||||||
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
||||||
|
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
|
||||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||||
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
|
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
|
||||||
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
|
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
|
||||||
@@ -137,8 +152,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
|
async def list_projects(repo: ProjectRepoDep, page: PageDep) -> ProjectListResponse:
|
||||||
views = await repo.list_for_owner(STUB_OWNER_ID)
|
"""作品列表(按 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])
|
return ProjectListResponse(projects=[_to_response(v) for v in views])
|
||||||
|
|
||||||
|
|
||||||
@@ -247,7 +263,7 @@ def _encode_sse(event: SseEvent) -> str:
|
|||||||
return f"event: {event.event}\ndata: {payload}\n\n"
|
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(
|
async def stream_draft(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
chapter_no: int,
|
chapter_no: int,
|
||||||
@@ -310,6 +326,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")
|
@router.put("/{project_id}/chapters/{chapter_no}/draft")
|
||||||
async def save_draft(
|
async def save_draft(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
@@ -391,7 +539,7 @@ async def _resolve_review_draft(
|
|||||||
return saved.content
|
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(
|
async def review_chapter(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
chapter_no: int,
|
chapter_no: int,
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ from typing import Annotated, Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
|
||||||
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo
|
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_db import get_session
|
||||||
from ww_llm_gateway import Gateway
|
from ww_llm_gateway import Gateway
|
||||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
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.logging_config import get_logger
|
||||||
from ww_api.schemas.style import (
|
from ww_api.schemas.style import (
|
||||||
DimensionEntry,
|
DimensionEntry,
|
||||||
|
RefineClarifyRequest,
|
||||||
RefineRequest,
|
RefineRequest,
|
||||||
RefineResponse,
|
RefineResponse,
|
||||||
StyleFingerprintResponse,
|
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.job_runner import run_job
|
||||||
from ww_api.services.project_deps import (
|
from ww_api.services.project_deps import (
|
||||||
build_gateway_for_tier,
|
build_gateway_for_tier,
|
||||||
|
get_clarify_gateway,
|
||||||
get_job_repo,
|
get_job_repo,
|
||||||
get_project_repo,
|
get_project_repo,
|
||||||
get_refine_gateway,
|
get_refine_gateway,
|
||||||
@@ -62,6 +64,7 @@ JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
|
|||||||
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
|
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
|
||||||
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
|
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
|
||||||
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
|
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
|
||||||
|
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
|
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
|
||||||
|
|
||||||
@@ -238,3 +241,59 @@ async def refine_segment(
|
|||||||
has_instruction=body.instruction is not None,
|
has_instruction=body.instruction is not None,
|
||||||
)
|
)
|
||||||
return RefineResponse(original=body.segment, refined=resp.text)
|
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_shared import AppError, ErrorCode
|
||||||
|
|
||||||
from ww_api.logging_config import get_logger
|
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.schemas.templates import TemplateCreateRequest, TemplateResponse
|
||||||
from ww_api.services.credentials import STUB_OWNER_ID
|
from ww_api.services.credentials import STUB_OWNER_ID
|
||||||
from ww_api.services.project_deps import get_template_repo
|
from ww_api.services.project_deps import get_template_repo
|
||||||
@@ -36,9 +37,9 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]:
|
async def list_templates(repo: TemplateRepoDep, page: PageDep) -> list[TemplateResponse]:
|
||||||
"""列出当前用户(stub)的模板。"""
|
"""列出当前用户(stub)的模板(分页;`?limit=&offset=`,默认第一页)。"""
|
||||||
views = await repo.list_for_owner(STUB_OWNER_ID)
|
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
|
||||||
return [
|
return [
|
||||||
TemplateResponse(
|
TemplateResponse(
|
||||||
id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key
|
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.project_repo import ProjectRepo
|
||||||
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
|
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
|
||||||
from ww_core.domain.rule_repo import RuleWriteRepo
|
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_core.orchestrator import run_generator
|
||||||
from ww_db import get_session
|
from ww_db import get_session
|
||||||
from ww_llm_gateway import Gateway
|
from ww_llm_gateway import Gateway
|
||||||
@@ -427,12 +427,12 @@ async def _ingest_world_entities(
|
|||||||
rejected_tables=rejected,
|
rejected_tables=rejected,
|
||||||
)
|
)
|
||||||
|
|
||||||
created: list[str] = []
|
fields = [
|
||||||
for entity in allowed.get("world_entities", []):
|
WorldEntityFields(type=entity.type, name=entity.name, rules=list(entity.rules))
|
||||||
view = await world_write_repo.create(
|
for entity in allowed.get("world_entities", [])
|
||||||
project_id, type=entity.type, name=entity.name, rules=list(entity.rules)
|
]
|
||||||
)
|
# 批量插入(一次 flush 落全部),保序返回逐条 View。
|
||||||
created.append(view.name)
|
created = [view.name for view in await world_write_repo.create_many(project_id, fields)]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
log.info(
|
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
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。代号=短标识;标题=一句话。
|
||||||
|
_CODE_MAX = 100
|
||||||
|
_TITLE_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
class ForeshadowRegisterRequest(BaseModel):
|
class ForeshadowRegisterRequest(BaseModel):
|
||||||
"""POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。"""
|
"""POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。"""
|
||||||
|
|
||||||
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一")
|
code: str = Field(
|
||||||
title: str = Field(min_length=1, description="伏笔标题/一句话描述")
|
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="埋设章号")
|
planted_at: int | None = Field(default=None, description="埋设章号")
|
||||||
content: str | None = Field(default=None, description="伏笔正文/线索")
|
content: str | None = Field(default=None, description="伏笔正文/线索")
|
||||||
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")
|
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ from ww_api.schemas.rules import RuleView
|
|||||||
# 批量生成数量上限(防一次性巨量调用;原型保守值)。
|
# 批量生成数量上限(防一次性巨量调用;原型保守值)。
|
||||||
MAX_GENERATE_COUNT = 12
|
MAX_GENERATE_COUNT = 12
|
||||||
|
|
||||||
|
# 一句话需求文本上界(CR-H9:防超长入参撑爆上下文/成本)。
|
||||||
|
_BRIEF_MAX = 10_000
|
||||||
|
|
||||||
|
|
||||||
# ---- 世界观生成(预览)----
|
# ---- 世界观生成(预览)----
|
||||||
|
|
||||||
@@ -26,7 +29,9 @@ MAX_GENERATE_COUNT = 12
|
|||||||
class WorldGenerateRequest(BaseModel):
|
class WorldGenerateRequest(BaseModel):
|
||||||
"""POST /projects/:id/world/generate:据需求生成世界观实体(预览)。"""
|
"""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):
|
class WorldEntityCardView(BaseModel):
|
||||||
@@ -54,7 +59,7 @@ class CharacterGenerateRequest(BaseModel):
|
|||||||
单一 `role` + `count` 语义。
|
单一 `role` + `count` 语义。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
brief: str = Field(min_length=1, description="角色生成需求")
|
brief: str = Field(min_length=1, max_length=_BRIEF_MAX, description="角色生成需求")
|
||||||
count: int = Field(
|
count: int = Field(
|
||||||
default=1,
|
default=1,
|
||||||
ge=1,
|
ge=1,
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
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):
|
class ProjectCreateRequest(BaseModel):
|
||||||
"""POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。"""
|
"""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
|
genre: str | None = None
|
||||||
logline: str | None = None
|
logline: str | None = None
|
||||||
premise: str | None = None
|
premise: str | None = None
|
||||||
@@ -117,13 +122,44 @@ class DraftStreamRequest(BaseModel):
|
|||||||
后端只入 volatile(守缓存前缀不变量 #9)。无 body 时退化为纯按大纲生成(向后兼容)。
|
后端只入 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):
|
class DraftSaveRequest(BaseModel):
|
||||||
"""PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""
|
"""PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""
|
||||||
|
|
||||||
text: str
|
text: str = Field(max_length=_CHAPTER_TEXT_MAX)
|
||||||
|
|
||||||
|
|
||||||
class DraftResponse(BaseModel):
|
class DraftResponse(BaseModel):
|
||||||
@@ -162,7 +198,7 @@ class ReviewRequest(BaseModel):
|
|||||||
不传 `draft` 时端点回退到已保存的草稿(chapter_repo)。
|
不传 `draft` 时端点回退到已保存的草稿(chapter_repo)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
draft: str | None = None
|
draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX)
|
||||||
|
|
||||||
|
|
||||||
class ReviewConflictView(BaseModel):
|
class ReviewConflictView(BaseModel):
|
||||||
@@ -220,7 +256,9 @@ class ConflictDecision(BaseModel):
|
|||||||
class AcceptRequest(BaseModel):
|
class AcceptRequest(BaseModel):
|
||||||
"""POST /projects/:id/chapters/:no/accept:裁决清单 + 可能改过的终稿。"""
|
"""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(
|
decisions: list[ConflictDecision] = Field(
|
||||||
default_factory=list, description="对最近审稿每个冲突的裁决(每冲突必有其一,R5)"
|
default_factory=list, description="对最近审稿每个冲突的裁决(每冲突必有其一,R5)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ from __future__ import annotations
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from ww_llm_gateway.types import Tier
|
from ww_llm_gateway.types import Tier
|
||||||
|
|
||||||
|
# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。
|
||||||
|
_PROVIDER_MAX = 64
|
||||||
|
_MODEL_MAX = 128
|
||||||
|
_API_KEY_MAX = 512
|
||||||
|
|
||||||
|
|
||||||
class ProviderView(BaseModel):
|
class ProviderView(BaseModel):
|
||||||
"""已配置提供商(掩码视图)。"""
|
"""已配置提供商(掩码视图)。"""
|
||||||
@@ -35,8 +40,8 @@ class ProvidersResponse(BaseModel):
|
|||||||
class ProviderCredentialInput(BaseModel):
|
class ProviderCredentialInput(BaseModel):
|
||||||
"""单条提供商凭据写入。"""
|
"""单条提供商凭据写入。"""
|
||||||
|
|
||||||
provider: str = Field(min_length=1)
|
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||||
api_key: str = Field(min_length=1) # 明文入站,加密入库,绝不回显
|
api_key: str = Field(min_length=1, max_length=_API_KEY_MAX) # 明文入站,加密入库,绝不回显
|
||||||
|
|
||||||
|
|
||||||
class TierRoutingInput(BaseModel):
|
class TierRoutingInput(BaseModel):
|
||||||
@@ -44,8 +49,8 @@ class TierRoutingInput(BaseModel):
|
|||||||
|
|
||||||
# tier 限定已知档位 writer/analyst/light;未知档位 → 422(QA MEDIUM:原接受任意字符串)。
|
# tier 限定已知档位 writer/analyst/light;未知档位 → 422(QA MEDIUM:原接受任意字符串)。
|
||||||
tier: Tier
|
tier: Tier
|
||||||
provider: str = Field(min_length=1)
|
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||||
model: str = Field(min_length=1)
|
model: str = Field(min_length=1, max_length=_MODEL_MAX)
|
||||||
fallback: list[str] = Field(default_factory=list)
|
fallback: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +64,7 @@ class ProvidersUpsertRequest(BaseModel):
|
|||||||
class TestConnectionRequest(BaseModel):
|
class TestConnectionRequest(BaseModel):
|
||||||
"""POST /test:最小探测请求。"""
|
"""POST /test:最小探测请求。"""
|
||||||
|
|
||||||
provider: str = Field(min_length=1)
|
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||||
|
|
||||||
|
|
||||||
class CapabilitiesView(BaseModel):
|
class CapabilitiesView(BaseModel):
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ from pydantic import BaseModel, Field, StringConstraints
|
|||||||
|
|
||||||
RuleLevel = Literal["global", "genre", "style", "project"]
|
RuleLevel = Literal["global", "genre", "style", "project"]
|
||||||
|
|
||||||
|
# 规则正文长度上界(CR-H9:防超长入参 DoS/成本)。
|
||||||
|
_RULE_CONTENT_MAX = 10_000
|
||||||
|
|
||||||
# 先 strip 再校验长度:纯空白内容(" ")strip 后为空 → 422(QA MEDIUM:此前被接受入库)。
|
# 先 strip 再校验长度:纯空白内容(" ")strip 后为空 → 422(QA MEDIUM:此前被接受入库)。
|
||||||
RuleContent = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
RuleContent = Annotated[
|
||||||
|
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_RULE_CONTENT_MAX)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class RuleCreateRequest(BaseModel):
|
class RuleCreateRequest(BaseModel):
|
||||||
|
|||||||
@@ -7,15 +7,26 @@ snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
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):
|
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"
|
mode: Literal["create", "update"] = "create"
|
||||||
|
|
||||||
|
|
||||||
@@ -47,8 +58,9 @@ class StyleFingerprintResponse(BaseModel):
|
|||||||
class RefineRequest(BaseModel):
|
class RefineRequest(BaseModel):
|
||||||
"""回炉:重写选中段(可选改写指令)。"""
|
"""回炉:重写选中段(可选改写指令)。"""
|
||||||
|
|
||||||
segment: str = Field(min_length=1)
|
segment: str = Field(min_length=1, max_length=_SEGMENT_MAX)
|
||||||
instruction: str | None = None
|
# CR-H9 收尾:再沟通意见喂 LLM,加上界(与 clarify instruction 同口径 2000)。
|
||||||
|
instruction: str | None = Field(default=None, max_length=_MAX_CLARIFY_INSTRUCTION_LEN)
|
||||||
|
|
||||||
|
|
||||||
class RefineResponse(BaseModel):
|
class RefineResponse(BaseModel):
|
||||||
@@ -56,3 +68,15 @@ class RefineResponse(BaseModel):
|
|||||||
|
|
||||||
original: str
|
original: str
|
||||||
refined: 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
|
from pydantic import BaseModel, Field, StringConstraints
|
||||||
|
|
||||||
|
# 长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;正文=段落级。
|
||||||
|
_TITLE_MAX = 200
|
||||||
|
_BODY_MAX = 20_000
|
||||||
|
|
||||||
# 先去首尾空白再校验长度:纯空白(" ")strip 后为空 → min_length=1 不满足 → 422。
|
# 先去首尾空白再校验长度:纯空白(" ")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):
|
class TemplateCreateRequest(BaseModel):
|
||||||
"""POST /templates:新建一条提示词模板。"""
|
"""POST /templates:新建一条提示词模板。"""
|
||||||
|
|
||||||
title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422)")
|
title: NonBlankTitle = Field(description="模板标题(非空,纯空白 → 422)")
|
||||||
body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text)")
|
body: NonBlankBody = Field(description="模板正文(非空,一键填入生成器的 brief/text)")
|
||||||
category: str | None = Field(default=None, description="可选分类")
|
category: str | None = Field(default=None, description="可选分类")
|
||||||
tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)")
|
tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)")
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ log = structlog.get_logger(__name__)
|
|||||||
# 链 job 的 kind(jobs.kind 自由 Text 列,零迁移复用;§7)。
|
# 链 job 的 kind(jobs.kind 自由 Text 列,零迁移复用;§7)。
|
||||||
JOB_KIND_CHAIN = "chain"
|
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 档网关跑终稿提炼。
|
# 「按 session 建 digest 网关」缝:accept 节点在自建短事务里需 light 档网关跑终稿提炼。
|
||||||
DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]]
|
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:
|
def _extract_awaiting_chapter(interrupts: Sequence[Any]) -> int | None:
|
||||||
"""从图返回值判 interrupt:有 `__interrupt__` → 取暂停章号;否则 None(跑完)。
|
"""从 pending interrupts 判暂停章号:空 → None(跑完);否则取首个 interrupt 载荷的章号。
|
||||||
|
|
||||||
interrupt 载荷 = `{"chapter_no": n}`(见链图 `_accept` 节点)。LangGraph 把它放在
|
interrupts 经**公开** `graph.aget_state(config).interrupts`(`StateSnapshot`)拿到,不再
|
||||||
`final["__interrupt__"]`(Interrupt 对象列表)。容错读 `.value`/dict 两种形。
|
反手 ainvoke 返回值里的私有 `__interrupt__` 键。载荷 = `{"chapter_no": n}`(见链图 `_accept`
|
||||||
|
节点)。容错读 `.value`/dict 两种形。
|
||||||
"""
|
"""
|
||||||
interrupts = final.get("__interrupt__")
|
|
||||||
if not interrupts:
|
if not interrupts:
|
||||||
return None
|
return None
|
||||||
first = interrupts[0]
|
first = interrupts[0]
|
||||||
@@ -207,7 +212,14 @@ async def run_chain_job(
|
|||||||
try:
|
try:
|
||||||
async with checkpointer_ctx() as checkpointer:
|
async with checkpointer_ctx() as checkpointer:
|
||||||
await _set_running(session_factory, job_id)
|
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(
|
graph = build_chain_graph(
|
||||||
chain_gateway_builder,
|
chain_gateway_builder,
|
||||||
session_factory=session_factory,
|
session_factory=session_factory,
|
||||||
@@ -232,9 +244,11 @@ async def run_chain_job(
|
|||||||
resume_value = [d.model_dump() for d in resume_decisions]
|
resume_value = [d.model_dump() for d in resume_decisions]
|
||||||
raw_final = await graph.ainvoke(Command(resume=resume_value), config=config)
|
raw_final = await graph.ainvoke(Command(resume=resume_value), config=config)
|
||||||
|
|
||||||
final: dict[str, Any] = dict(raw_final)
|
# 经公开 `StateSnapshot.interrupts` 判 pending interrupt(不反手私有 `__interrupt__`);
|
||||||
awaiting_chapter = _extract_awaiting_chapter(final)
|
# checkpointer 仍在 `checkpointer_ctx()` 上下文内,aget_state 可读同一 thread 检查点。
|
||||||
result = _chain_result(final, awaiting_chapter=awaiting_chapter)
|
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)
|
await _finish(session_factory, job_id, result, awaiting=awaiting_chapter is not None)
|
||||||
log.info(
|
log.info(
|
||||||
"chain_job_settled",
|
"chain_job_settled",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from ww_config import get_settings
|
from ww_config import get_settings
|
||||||
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
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.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||||
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
|
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
|
||||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||||
@@ -160,6 +161,16 @@ def get_rule_write_repo(
|
|||||||
return SqlRuleWriteRepo(session)
|
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(
|
async def get_template_repo(
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
) -> TemplateRepo:
|
) -> TemplateRepo:
|
||||||
@@ -519,6 +530,18 @@ async def get_refine_gateway(
|
|||||||
return await build_gateway_for_tier(session, store, "writer")
|
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(
|
async def get_chain_gateway(
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
) -> Gateway:
|
) -> Gateway:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from ww_config import get_settings
|
from ww_config import get_settings
|
||||||
from ww_db import get_session
|
from ww_db import get_session
|
||||||
from ww_llm_gateway.adapters.base import Capabilities
|
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_llm_gateway.factory import build_adapter
|
||||||
from ww_shared import AppError, ErrorCode
|
from ww_shared import AppError, ErrorCode
|
||||||
|
|
||||||
@@ -85,10 +86,17 @@ class GatewayProviderProbe:
|
|||||||
|
|
||||||
# 经网关工厂构造适配器(base_url 单点归网关 build_adapter,不在此本地构造 AsyncOpenAI)。
|
# 经网关工厂构造适配器(base_url 单点归网关 build_adapter,不在此本地构造 AsyncOpenAI)。
|
||||||
adapter = build_adapter(provider, api_key=api_key, base_url=base_url)
|
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:
|
try:
|
||||||
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。底层 AsyncOpenAI 由适配器持有
|
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
|
||||||
# (已知 provider 为 OpenAI 兼容,见 _PROVIDER_BASE_URLS);读私有客户端属同仓既有约定。
|
await adapter.probe_connection()
|
||||||
await adapter._client.models.list() # type: ignore[attr-defined] # noqa: SLF001
|
|
||||||
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
|
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
|
||||||
raise AppError(
|
raise AppError(
|
||||||
ErrorCode.LLM_UNAVAILABLE,
|
ErrorCode.LLM_UNAVAILABLE,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookOpen, Plus } from "lucide-react";
|
import { BookOpen, PenLine, Plus } from "lucide-react";
|
||||||
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||||
@@ -28,13 +28,22 @@ export default async function DashboardPage() {
|
|||||||
title="我的作品"
|
title="我的作品"
|
||||||
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
||||||
actions={
|
actions={
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/projects/new"
|
href="/write"
|
||||||
className={buttonClass({ variant: "primary" })}
|
className={buttonClass({ variant: "primary" })}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||||
新建作品
|
直接开始写
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/projects/new"
|
||||||
|
className={buttonClass({ variant: "secondary" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
先立项
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -44,15 +53,24 @@ export default async function DashboardPage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title="还没有作品"
|
title="还没有作品"
|
||||||
description="从一句灵感开始,后续的设定库、大纲、写章和审稿都会围绕这本书展开。"
|
description="直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。"
|
||||||
action={
|
action={
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/projects/new"
|
href="/write"
|
||||||
className={buttonClass({ variant: "primary" })}
|
className={buttonClass({ variant: "primary" })}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||||
新建作品
|
直接开始写
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/projects/new"
|
||||||
|
className={buttonClass({ variant: "secondary" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
先立项
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
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,93 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import {
|
|
||||||
BookOpen,
|
|
||||||
ClipboardCheck,
|
|
||||||
ListTree,
|
|
||||||
PenLine,
|
|
||||||
Sparkles,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { AiToolbarMoreMenu } from "@/components/AiToolbarMoreMenu";
|
|
||||||
import {
|
|
||||||
aiToolItems,
|
|
||||||
primaryAiToolItems,
|
|
||||||
secondaryAiToolItems,
|
|
||||||
} from "@/lib/nav/ai-tools";
|
|
||||||
import type { ActiveNav } from "@/lib/nav/items";
|
|
||||||
import { buttonClass } from "@/lib/ui/variants";
|
|
||||||
|
|
||||||
interface AiToolbarProps {
|
|
||||||
projectId: string;
|
|
||||||
activeNav?: ActiveNav;
|
|
||||||
}
|
|
||||||
|
|
||||||
// T4-a · 项目内页常驻 AI 工具条(顶栏下,UX §3/§5)。
|
|
||||||
// 桌面展示完整工具条;窄屏只保留写本章/审稿,把低频入口收进更多菜单。
|
|
||||||
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
|
|
||||||
const primaryItems = primaryAiToolItems(projectId);
|
|
||||||
const secondaryItems = secondaryAiToolItems(projectId);
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label="AI 工具条"
|
|
||||||
className="sticky top-16 z-20 flex h-12 items-center gap-2 border-b border-line bg-panel px-4 sm:px-6"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden lg:hidden">
|
|
||||||
{primaryItems.map((item) => {
|
|
||||||
const isActive = item.key === activeNav;
|
|
||||||
const className = toolClassName(item.primary === true, isActive);
|
|
||||||
const Icon = toolIcon(item.key);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<AiToolbarMoreMenu items={secondaryItems} activeNav={activeNav} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden items-center gap-2 overflow-x-auto whitespace-nowrap lg:flex">
|
|
||||||
{aiToolItems(projectId).map((item) => {
|
|
||||||
const isActive = item.key === activeNav;
|
|
||||||
const className = toolClassName(item.primary === true, isActive);
|
|
||||||
const Icon = toolIcon(item.key);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolIcon(key: ActiveNav): LucideIcon {
|
|
||||||
if (key === "write") return PenLine;
|
|
||||||
if (key === "review") return ClipboardCheck;
|
|
||||||
if (key === "outline") return ListTree;
|
|
||||||
if (key === "codex") return BookOpen;
|
|
||||||
return Sparkles;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolClassName(isPrimary: boolean, isActive: boolean): string {
|
|
||||||
if (isActive) {
|
|
||||||
return buttonClass({ variant: "outline", size: "sm" });
|
|
||||||
}
|
|
||||||
if (isPrimary) {
|
|
||||||
return buttonClass({ variant: "primary", size: "sm" });
|
|
||||||
}
|
|
||||||
return buttonClass({ variant: "secondary", size: "sm" });
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { MoreHorizontal } from "lucide-react";
|
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import type { AiToolItem } from "@/lib/nav/ai-tools";
|
|
||||||
import type { ActiveNav } from "@/lib/nav/items";
|
|
||||||
import { buttonClass, cn, focusRing } from "@/lib/ui/variants";
|
|
||||||
|
|
||||||
interface AiToolbarMoreMenuProps {
|
|
||||||
items: AiToolItem[];
|
|
||||||
activeNav?: ActiveNav;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiToolbarMoreMenu({
|
|
||||||
items,
|
|
||||||
activeNav,
|
|
||||||
}: AiToolbarMoreMenuProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const menuId = useId();
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent): void => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
setOpen(false);
|
|
||||||
buttonRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerDown = (event: PointerEvent): void => {
|
|
||||||
const target = event.target;
|
|
||||||
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
window.addEventListener("pointerdown", onPointerDown);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
window.removeEventListener("pointerdown", onPointerDown);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={rootRef} className="relative">
|
|
||||||
<button
|
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={menuId}
|
|
||||||
onClick={() => setOpen((value) => !value)}
|
|
||||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
|
||||||
更多
|
|
||||||
</button>
|
|
||||||
{open ? (
|
|
||||||
// 诚实的 Tab-only 折叠面板:普通导航链接,不冒充 ARIA menu(无 roving tabindex/方向键)。
|
|
||||||
<nav
|
|
||||||
id={menuId}
|
|
||||||
aria-label="更多工具"
|
|
||||||
className="absolute right-0 z-30 mt-2 min-w-36 rounded border border-line bg-panel p-1 shadow-paper"
|
|
||||||
>
|
|
||||||
{items.map((item) => {
|
|
||||||
const active = item.key === activeNav;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className={cn(
|
|
||||||
"block rounded px-3 py-2 text-sm transition-colors",
|
|
||||||
focusRing,
|
|
||||||
active
|
|
||||||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
|
||||||
: "text-ink hover:bg-bg hover:text-cinnabar",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { Settings } from "lucide-react";
|
|||||||
|
|
||||||
import type { ActiveNav } from "@/lib/nav/items";
|
import type { ActiveNav } from "@/lib/nav/items";
|
||||||
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
||||||
import { AiToolbar } from "./AiToolbar";
|
|
||||||
import { CommandSearchButton } from "./command/CommandPaletteMount";
|
import { CommandSearchButton } from "./command/CommandPaletteMount";
|
||||||
import { LeftNav } from "./LeftNav";
|
import { LeftNav } from "./LeftNav";
|
||||||
import { NavDrawer } from "./NavDrawer";
|
import { NavDrawer } from "./NavDrawer";
|
||||||
@@ -32,7 +31,7 @@ export function AppShell({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen"
|
className="min-h-screen"
|
||||||
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
|
style={{ ["--chrome" as string]: "4rem" }}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#main"
|
href="#main"
|
||||||
@@ -80,9 +79,6 @@ export function AppShell({
|
|||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
{projectId ? (
|
|
||||||
<AiToolbar projectId={projectId} activeNav={activeNav} />
|
|
||||||
) : null}
|
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<LeftNav projectId={projectId} activeNav={activeNav} />
|
<LeftNav projectId={projectId} activeNav={activeNav} />
|
||||||
<main
|
<main
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { X } from "lucide-react";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||||
|
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
|
||||||
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||||
import { overlayScrim } from "@/lib/ui/variants";
|
import { overlayScrim } from "@/lib/ui/variants";
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ interface DrawerProps {
|
|||||||
side?: "left" | "right";
|
side?: "left" | "right";
|
||||||
// 无障碍标签(role=dialog)。
|
// 无障碍标签(role=dialog)。
|
||||||
label: string;
|
label: string;
|
||||||
|
// 抽屉面板 id:供触发按钮 aria-controls 指向(无障碍关联)。
|
||||||
|
id?: string;
|
||||||
triggerRef?: RefObject<HTMLElement | null>;
|
triggerRef?: RefObject<HTMLElement | null>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -26,24 +29,18 @@ export function Drawer({
|
|||||||
onClose,
|
onClose,
|
||||||
side = "left",
|
side = "left",
|
||||||
label,
|
label,
|
||||||
|
id,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
children,
|
children,
|
||||||
}: DrawerProps) {
|
}: DrawerProps) {
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
const wasOpenRef = useRef(false);
|
|
||||||
|
|
||||||
useBodyScrollLock(open);
|
useBodyScrollLock(open);
|
||||||
|
useRestoreFocus(open, triggerRef);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) return;
|
||||||
if (wasOpenRef.current) {
|
|
||||||
wasOpenRef.current = false;
|
|
||||||
triggerRef?.current?.focus();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
wasOpenRef.current = true;
|
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -54,7 +51,7 @@ export function Drawer({
|
|||||||
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
||||||
closeRef.current?.focus();
|
closeRef.current?.focus();
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [open, onClose, triggerRef]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -64,6 +61,7 @@ export function Drawer({
|
|||||||
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
|
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
|
id={id}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ interface NavDrawerProps {
|
|||||||
activeNav?: ActiveNav;
|
activeNav?: ActiveNav;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 抽屉面板 id:供汉堡按钮 aria-controls 关联(无障碍)。
|
||||||
|
const DRAWER_ID = "nav-drawer";
|
||||||
|
|
||||||
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg;桌面用 LeftNav 静态侧栏)。UX §5.1。
|
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg;桌面用 LeftNav 静态侧栏)。UX §5.1。
|
||||||
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -32,6 +35,7 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
|||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
aria-label="打开导航"
|
aria-label="打开导航"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-controls={DRAWER_ID}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="-ml-2 lg:hidden"
|
className="-ml-2 lg:hidden"
|
||||||
@@ -43,6 +47,7 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
|||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
side="left"
|
side="left"
|
||||||
label="主导航"
|
label="主导航"
|
||||||
|
id={DRAWER_ID}
|
||||||
triggerRef={triggerRef}
|
triggerRef={triggerRef}
|
||||||
>
|
>
|
||||||
<div className="px-2 pb-2">
|
<div className="px-2 pb-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Fragment } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Blocks,
|
Blocks,
|
||||||
@@ -19,9 +20,8 @@ import {
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GLOBAL_NAV_ITEMS,
|
|
||||||
isNavItemActive,
|
isNavItemActive,
|
||||||
projectNavItems,
|
navSections,
|
||||||
type ActiveNav,
|
type ActiveNav,
|
||||||
type NavItem,
|
type NavItem,
|
||||||
} from "@/lib/nav/items";
|
} from "@/lib/nav/items";
|
||||||
@@ -36,13 +36,24 @@ interface NavItemsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导航条目列表(桌面静态侧栏与移动抽屉共用,避免渲染漂移)。
|
// 导航条目列表(桌面静态侧栏与移动抽屉共用,避免渲染漂移)。
|
||||||
|
// 项目内页把 10 项按 写作/资料库/审阅/更多 分组(P2-1),每组一个小标题;全局组无标题直显于顶部。
|
||||||
export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
||||||
const pathname = usePathname() ?? "/";
|
const pathname = usePathname() ?? "/";
|
||||||
const scoped = projectId ? projectNavItems(projectId) : [];
|
const sections = navSections(projectId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col gap-1 px-2">
|
<ul className="flex flex-col gap-1 px-2">
|
||||||
{GLOBAL_NAV_ITEMS.map((item) => (
|
{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}
|
||||||
|
{section.items.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.label}
|
key={item.label}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -50,21 +61,7 @@ export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
|||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{scoped.length > 0 ? (
|
</Fragment>
|
||||||
<li
|
|
||||||
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
本作
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
{scoped.map((item) => (
|
|
||||||
<NavLink
|
|
||||||
key={item.label}
|
|
||||||
item={item}
|
|
||||||
active={isNavItemActive(item, { pathname, activeNav })}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,10 +58,23 @@ export function ForeshadowCard({
|
|||||||
|
|
||||||
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
|
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
|
||||||
{typeof item.planted_at === "number" ? (
|
{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}
|
) : null}
|
||||||
{windowText ? <div>窗口 {windowText}</div> : null}
|
|
||||||
{item.importance ? <div>{item.importance}</div> : null}
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{item.progress && item.progress.length > 0 ? (
|
{item.progress && item.progress.length > 0 ? (
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ import { ReviewSummaryRail } from "./ReviewSummaryRail";
|
|||||||
import { StylePanel } from "@/components/style/StylePanel";
|
import { StylePanel } from "@/components/style/StylePanel";
|
||||||
import { RefineView } from "@/components/style/RefineView";
|
import { RefineView } from "@/components/style/RefineView";
|
||||||
|
|
||||||
|
// 命中区闪烁高亮持续时长(朱砂脉冲)。
|
||||||
|
const FLASH_DURATION_MS = 900;
|
||||||
|
|
||||||
interface ReviewReportProps {
|
interface ReviewReportProps {
|
||||||
project: ProjectResponse;
|
project: ProjectResponse;
|
||||||
chapterNo: number;
|
chapterNo: number;
|
||||||
@@ -112,6 +115,16 @@ export function ReviewReport({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const seededRef = useRef(false);
|
const seededRef = useRef(false);
|
||||||
|
// flashMark 的收尾定时器句柄:组件卸载或重复触发时清除,避免定时器泄漏。
|
||||||
|
const flashTimerRef = useRef<number | null>(null);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (flashTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(flashTimerRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
|
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
|
||||||
const seededConflicts = useMemo(
|
const seededConflicts = useMemo(
|
||||||
@@ -333,7 +346,13 @@ export function ReviewReport({
|
|||||||
const mark = document.getElementById(`draft-mark-${index}`);
|
const mark = document.getElementById(`draft-mark-${index}`);
|
||||||
if (!mark) return;
|
if (!mark) return;
|
||||||
mark.classList.add("draft-mark-flash");
|
mark.classList.add("draft-mark-flash");
|
||||||
window.setTimeout(() => mark.classList.remove("draft-mark-flash"), 900);
|
if (flashTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(flashTimerRef.current);
|
||||||
|
}
|
||||||
|
flashTimerRef.current = window.setTimeout(() => {
|
||||||
|
mark.classList.remove("draft-mark-flash");
|
||||||
|
flashTimerRef.current = null;
|
||||||
|
}, FLASH_DURATION_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 跳转/锚点:在可编辑正文里定位、选中并滚动到该冲突对应区域 + 闪烁;定位不到则提示手动查找。
|
// 跳转/锚点:在可编辑正文里定位、选中并滚动到该冲突对应区域 + 闪烁;定位不到则提示手动查找。
|
||||||
|
|||||||
58
apps/web/components/start/DraftComposer.tsx
Normal file
58
apps/web/components/start/DraftComposer.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { TextArea } from "@/components/ui/TextArea";
|
||||||
|
import { useDeferredDraft } from "@/lib/start/useDeferredDraft";
|
||||||
|
|
||||||
|
// 敲入正文后停顿多久才懒建项目(落库)——在停顿点落库,快照即所见,避免打断连续输入。
|
||||||
|
const CREATE_DEBOUNCE_MS = 700;
|
||||||
|
|
||||||
|
// 进来即写:空白编辑器直达。敲入实质正文并停顿后懒建占位项目 + 落首章草稿,随即换入完整工作台。
|
||||||
|
// 空进空出不落库(从不敲字 → 从不 POST)。落库逻辑在 useDeferredDraft(已单测),此处只管交互。
|
||||||
|
export function DraftComposer() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { ensureCreated } = useDeferredDraft();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const textRef = useRef("");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const redirectingRef = useRef(false);
|
||||||
|
|
||||||
|
const tryCreate = async (): Promise<void> => {
|
||||||
|
if (redirectingRef.current) return;
|
||||||
|
const snapshot = textRef.current;
|
||||||
|
if (snapshot.trim().length === 0) return;
|
||||||
|
const id = await ensureCreated(snapshot);
|
||||||
|
if (id && !redirectingRef.current) {
|
||||||
|
redirectingRef.current = true;
|
||||||
|
router.replace(`/projects/${id}/write`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChange = (value: string): void => {
|
||||||
|
setText(value);
|
||||||
|
textRef.current = value;
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => void tryCreate(), CREATE_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="mx-auto flex min-h-[calc(100vh-var(--chrome,4rem))] max-w-3xl flex-col px-6 py-10 sm:px-8">
|
||||||
|
<p className="mb-4 text-sm text-ink-soft">
|
||||||
|
直接开始写——落笔即为你建立作品,正文自动保存。书名、设定、大纲都可在写作中随时补齐。
|
||||||
|
</p>
|
||||||
|
<TextArea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="min-h-[60vh] flex-1 resize-none text-lg leading-8"
|
||||||
|
placeholder="从这里开始写你的第一章……"
|
||||||
|
autoFocus
|
||||||
|
aria-label="正文"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,15 +28,17 @@ export function RefineView({
|
|||||||
onClose,
|
onClose,
|
||||||
}: RefineViewProps) {
|
}: RefineViewProps) {
|
||||||
const refiner = useRefine();
|
const refiner = useRefine();
|
||||||
|
const { refine, abort } = refiner;
|
||||||
|
|
||||||
// 进入即触发回炉(段非空)。
|
// 进入即触发回炉(段非空);清理时取消在途请求,避免旧段迟到响应覆盖新段(CR-H12)。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (segment.trim().length > 0) {
|
if (segment.trim().length > 0) {
|
||||||
void refiner.refine(projectId, chapterNo, segment);
|
void refine(projectId, chapterNo, segment);
|
||||||
}
|
}
|
||||||
// 仅在段变化时触发。
|
return () => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
abort();
|
||||||
}, [segment, projectId, chapterNo]);
|
};
|
||||||
|
}, [segment, projectId, chapterNo, refine, abort]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowLeft, Database, Sparkles } from "lucide-react";
|
import { ArrowLeft, Database, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
@@ -23,13 +23,25 @@ import {
|
|||||||
type PreviewItem,
|
type PreviewItem,
|
||||||
} from "@/lib/toolbox/toolbox";
|
} from "@/lib/toolbox/toolbox";
|
||||||
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
|
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
|
||||||
|
import { fieldSummary, renderPreviewText } from "@/lib/toolbox/aiSummary";
|
||||||
|
import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
|
||||||
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
||||||
|
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
|
||||||
import { TemplateFiller } from "./TemplateFiller";
|
import { TemplateFiller } from "./TemplateFiller";
|
||||||
|
|
||||||
interface GeneratorRunnerProps {
|
interface GeneratorRunnerProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
tool: ToolDescriptorView;
|
tool: ToolDescriptorView;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
|
||||||
|
onInsertText?: (text: string) => void;
|
||||||
|
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
|
||||||
|
// 点击把正文摘录填进 brief,让内容感知型生成器(如书名)据正文反推。工具箱页不传 → 不显示。
|
||||||
|
manuscriptText?: string;
|
||||||
|
// 可选留痕:生成成功后 append 一批 [author(字段摘要), ai(预览文本)]。
|
||||||
|
// 内联工具箱传 conversation.appendMessages + 当前章号;工具箱页传 silent 直投 + chapterNo=null。
|
||||||
|
appendMessages?: (turn: AiTurnInput) => void;
|
||||||
|
logChapterNo?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
||||||
@@ -39,6 +51,10 @@ export function GeneratorRunner({
|
|||||||
projectId,
|
projectId,
|
||||||
tool,
|
tool,
|
||||||
onClose,
|
onClose,
|
||||||
|
onInsertText,
|
||||||
|
manuscriptText,
|
||||||
|
appendMessages,
|
||||||
|
logChapterNo,
|
||||||
}: GeneratorRunnerProps) {
|
}: GeneratorRunnerProps) {
|
||||||
const gen = useGenerator();
|
const gen = useGenerator();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -47,6 +63,8 @@ export function GeneratorRunner({
|
|||||||
);
|
);
|
||||||
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
|
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
// 本 runner 一次会话 = 一个 thread;多次生成归此组。
|
||||||
|
const threadId = useRef(crypto.randomUUID()).current;
|
||||||
|
|
||||||
const setField = useCallback((name: string, value: string): void => {
|
const setField = useCallback((name: string, value: string): void => {
|
||||||
setValues((prev) => ({ ...prev, [name]: value }));
|
setValues((prev) => ({ ...prev, [name]: value }));
|
||||||
@@ -64,6 +82,26 @@ export function GeneratorRunner({
|
|||||||
[tool.input_fields, fillTarget],
|
[tool.input_fields, fillTarget],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 当前工具是否有名为 brief 的输入字段——只有它才承接「据正文反推」。
|
||||||
|
const hasBriefField = useMemo(
|
||||||
|
() => (tool.input_fields ?? []).some((f) => f.name === "brief"),
|
||||||
|
[tool.input_fields],
|
||||||
|
);
|
||||||
|
// 有 brief 字段且拿到非空正文时,才提供「用当前正文」。
|
||||||
|
const canUseManuscript =
|
||||||
|
hasBriefField && (manuscriptText?.trim().length ?? 0) > 0;
|
||||||
|
|
||||||
|
const onUseManuscript = useCallback((): void => {
|
||||||
|
if (!manuscriptText) return;
|
||||||
|
const excerpt = buildManuscriptExcerpt(manuscriptText);
|
||||||
|
if (excerpt.length === 0) {
|
||||||
|
toast("当前还没有正文可供反推。", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setField("brief", excerpt);
|
||||||
|
toast("已按当前正文填入,可继续编辑后生成。", "success");
|
||||||
|
}, [manuscriptText, setField, toast]);
|
||||||
|
|
||||||
const onGenerate = useCallback(async (): Promise<void> => {
|
const onGenerate = useCallback(async (): Promise<void> => {
|
||||||
const missing = missingRequiredFields(tool.input_fields, values);
|
const missing = missingRequiredFields(tool.input_fields, values);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
@@ -71,8 +109,36 @@ export function GeneratorRunner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
await gen.generate(projectId, tool.key, buildGenerateRequest(values));
|
const artifact = await gen.generate(
|
||||||
}, [gen, projectId, tool, values, toast]);
|
projectId,
|
||||||
|
tool.key,
|
||||||
|
buildGenerateRequest(values),
|
||||||
|
);
|
||||||
|
// 留痕(成功且有可读预览时):author(字段摘要) + ai(渲染预览);空产物/错误路径不记。
|
||||||
|
if (artifact && appendMessages) {
|
||||||
|
const aiText = renderPreviewText(artifact.preview);
|
||||||
|
if (aiText.length > 0) {
|
||||||
|
appendMessages({
|
||||||
|
threadId,
|
||||||
|
kind: "generator",
|
||||||
|
toolKey: tool.key,
|
||||||
|
chapterNo: logChapterNo ?? null,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "author",
|
||||||
|
content: fieldSummary(tool.input_fields ?? [], values) || tool.title,
|
||||||
|
meta: { tool_key: tool.key, input_fields: values },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "ai",
|
||||||
|
content: aiText,
|
||||||
|
meta: { tool_key: tool.key, output_kind: artifact.outputKind },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [gen, projectId, tool, values, toast, appendMessages, logChapterNo, threadId]);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => previewRows(gen.rawPreview),
|
() => previewRows(gen.rawPreview),
|
||||||
@@ -163,6 +229,22 @@ export function GeneratorRunner({
|
|||||||
targetLabel={fillLabel}
|
targetLabel={fillLabel}
|
||||||
onFill={setField}
|
onFill={setField}
|
||||||
/>
|
/>
|
||||||
|
{canUseManuscript ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded border border-line bg-bg/50 p-2">
|
||||||
|
<span className="text-xs text-ink-soft">
|
||||||
|
据当前正文反推(自动摘录首尾,控制长度)
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onUseManuscript}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
|
用当前正文
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{(tool.input_fields ?? []).map((field) => (
|
{(tool.input_fields ?? []).map((field) => (
|
||||||
<FormField
|
<FormField
|
||||||
key={field.name}
|
key={field.name}
|
||||||
@@ -202,6 +284,11 @@ export function GeneratorRunner({
|
|||||||
selectable={canIngest && !singleObject}
|
selectable={canIngest && !singleObject}
|
||||||
checked={selected.has(i)}
|
checked={selected.has(i)}
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
|
onInsert={
|
||||||
|
onInsertText
|
||||||
|
? () => onInsertText(item.body ?? item.heading ?? "")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -276,6 +363,8 @@ interface PreviewRowProps {
|
|||||||
selectable: boolean;
|
selectable: boolean;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onToggle: (index: number) => void;
|
onToggle: (index: number) => void;
|
||||||
|
// 可选:把本条插入正文(编辑器内联工具箱)。有可插入文本时才渲染按钮。
|
||||||
|
onInsert?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一预览行渲染(heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
|
// 统一预览行渲染(heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
|
||||||
@@ -285,7 +374,9 @@ function PreviewRow({
|
|||||||
selectable,
|
selectable,
|
||||||
checked,
|
checked,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onInsert,
|
||||||
}: PreviewRowProps) {
|
}: PreviewRowProps) {
|
||||||
|
const canInsert = onInsert && (item.body || item.heading);
|
||||||
return (
|
return (
|
||||||
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
|
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
|
||||||
{selectable ? (
|
{selectable ? (
|
||||||
@@ -310,6 +401,17 @@ function PreviewRow({
|
|||||||
{item.body ? (
|
{item.body ? (
|
||||||
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
|
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canInsert ? (
|
||||||
|
<Button
|
||||||
|
onClick={onInsert}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 rotate-90" aria-hidden="true" />
|
||||||
|
插入正文
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { EmptyState } from "@/components/ui/EmptyState";
|
|||||||
import { PageHeader } from "@/components/ui/PageHeader";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
||||||
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
||||||
|
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
||||||
import { ToolCard } from "./ToolCard";
|
import { ToolCard } from "./ToolCard";
|
||||||
import { GeneratorRunner } from "./GeneratorRunner";
|
import { GeneratorRunner } from "./GeneratorRunner";
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ export function ToolboxPage({
|
|||||||
initialOpenKey,
|
initialOpenKey,
|
||||||
}: ToolboxPageProps) {
|
}: ToolboxPageProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
// 工具箱页无抽屉:silent 直投——项目级(chapter_no=null)留痕,不 GET、失败不 toast。
|
||||||
|
const conversation = useAiConversation(project.id, 0, { silent: true });
|
||||||
|
|
||||||
const findOpenable = useCallback(
|
const findOpenable = useCallback(
|
||||||
(key: string | undefined): ToolDescriptorView | null => {
|
(key: string | undefined): ToolDescriptorView | null => {
|
||||||
@@ -76,6 +79,8 @@ export function ToolboxPage({
|
|||||||
<GeneratorRunner
|
<GeneratorRunner
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
tool={active}
|
tool={active}
|
||||||
|
appendMessages={conversation.appendMessages}
|
||||||
|
logChapterNo={null}
|
||||||
onClose={() => setActive(null)}
|
onClose={() => setActive(null)}
|
||||||
/>
|
/>
|
||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
|
|||||||
284
apps/web/components/workbench/AiConversationDrawer.tsx
Normal file
284
apps/web/components/workbench/AiConversationDrawer.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
|
import { MessagesSquare, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||||
|
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
|
||||||
|
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||||
|
import { overlayScrim } from "@/lib/ui/variants";
|
||||||
|
import type { AiMessageView } from "@/lib/api/types";
|
||||||
|
import {
|
||||||
|
acceptActionFor,
|
||||||
|
groupByThread,
|
||||||
|
kindBadge,
|
||||||
|
kindLabel,
|
||||||
|
partitionByScope,
|
||||||
|
type AcceptAction,
|
||||||
|
type AiThread,
|
||||||
|
} from "@/lib/workbench/aiConversation";
|
||||||
|
import { readSegment } from "@/lib/workbench/inlineConversation";
|
||||||
|
import type { UseAiConversation } from "@/lib/workbench/useAiConversation";
|
||||||
|
import { ConversationThread } from "./ConversationThread";
|
||||||
|
|
||||||
|
// AI 对话总开关:一键关闭即回滚(触发按钮据此隐藏),仿 CONTEXT_DRAWER_ENABLED。
|
||||||
|
export const AI_CONVERSATION_ENABLED = true;
|
||||||
|
|
||||||
|
interface AiConversationDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
conversation: UseAiConversation;
|
||||||
|
currentChapterNo: number;
|
||||||
|
// 从历史再接受(复用编辑器既有 DRAFT-only HITL 回调);返回 true=成功则关抽屉。
|
||||||
|
onReplaceChapter: (content: string) => boolean;
|
||||||
|
onAppendDraft: (content: string) => boolean;
|
||||||
|
onRefillSegment: (segment: string, refined: string) => boolean;
|
||||||
|
triggerRef?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每章「对话记录」抽屉(只读往复日志,气泡视图):本章对话 + 项目级生成两段,作者右 / AI 左,kind 徽标 + 线程归组。
|
||||||
|
// 结构克隆 ContextDrawer:右侧 slide-over + overlayScrim + role=dialog + Esc + focus trap + scroll lock + 还原焦点。
|
||||||
|
// append-only 只读展示;对 ai 产出可「从历史再接受」——只回填编辑器草稿,不绕开验收事务(守不变量 #3)。
|
||||||
|
export function AiConversationDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
conversation,
|
||||||
|
currentChapterNo,
|
||||||
|
onReplaceChapter,
|
||||||
|
onAppendDraft,
|
||||||
|
onRefillSegment,
|
||||||
|
triggerRef,
|
||||||
|
}: AiConversationDrawerProps) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useBodyScrollLock(open);
|
||||||
|
useRestoreFocus(open, triggerRef);
|
||||||
|
|
||||||
|
// 打开即懒加载(Workbench 级 hook,append 早已可能触发过——已加载则空操作)。
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) conversation.ensureLoaded();
|
||||||
|
}, [open, conversation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
closeRef.current?.focus();
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const threads = groupByThread(conversation.messages);
|
||||||
|
const { chapterThreads, projectThreads } = partitionByScope(
|
||||||
|
threads,
|
||||||
|
currentChapterNo,
|
||||||
|
);
|
||||||
|
const summary = `本章 ${chapterThreads.length} 段对话,项目级 ${projectThreads.length} 项生成`;
|
||||||
|
|
||||||
|
// 再接受成功则关抽屉揭示改动后的编辑器;失败(如原选段漂移)保留抽屉与漂移提示。
|
||||||
|
const accept = (action: AcceptAction, m: AiMessageView): void => {
|
||||||
|
let ok = false;
|
||||||
|
if (action === "replace_chapter") ok = onReplaceChapter(m.content);
|
||||||
|
else if (action === "append_draft") ok = onAppendDraft(m.content);
|
||||||
|
else if (action === "refill_segment")
|
||||||
|
ok = onRefillSegment(readSegment(m.meta), m.content);
|
||||||
|
if (ok) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={overlayScrim} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="对话记录"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (panelRef.current) handleTabTrap(panelRef.current, e);
|
||||||
|
}}
|
||||||
|
className="absolute right-0 top-0 flex h-full w-96 max-w-[90vw] flex-col border-l border-line bg-panel shadow-paper outline-none motion-safe:transition-transform"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-4 py-3">
|
||||||
|
<span className="flex items-center gap-2 font-serif text-base text-ink">
|
||||||
|
<MessagesSquare
|
||||||
|
className="h-4 w-4 text-cinnabar"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
对话记录
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
ref={closeRef}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭对话记录"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-relevant="additions"
|
||||||
|
aria-label={summary}
|
||||||
|
className="flex-1 overflow-auto overscroll-contain px-4 py-4"
|
||||||
|
>
|
||||||
|
<DrawerBody
|
||||||
|
status={conversation.status}
|
||||||
|
chapterThreads={chapterThreads}
|
||||||
|
projectThreads={projectThreads}
|
||||||
|
onRetry={conversation.reload}
|
||||||
|
onAccept={accept}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DrawerBodyProps {
|
||||||
|
status: UseAiConversation["status"];
|
||||||
|
chapterThreads: AiThread[];
|
||||||
|
projectThreads: AiThread[];
|
||||||
|
onRetry: () => void;
|
||||||
|
onAccept: (action: AcceptAction, m: AiMessageView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerBody({
|
||||||
|
status,
|
||||||
|
chapterThreads,
|
||||||
|
projectThreads,
|
||||||
|
onRetry,
|
||||||
|
onAccept,
|
||||||
|
}: DrawerBodyProps) {
|
||||||
|
if (status === "loading" || status === "idle") {
|
||||||
|
return (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<ThinkingIndicator label="加载中" className="text-sm text-ink-soft" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "error") {
|
||||||
|
return (
|
||||||
|
<StatusNote variant="danger" title="加载失败">
|
||||||
|
<p>AI 对话暂不可用。</p>
|
||||||
|
<Button
|
||||||
|
onClick={onRetry}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</StatusNote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (chapterThreads.length === 0 && projectThreads.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={MessagesSquare}
|
||||||
|
title="这里留存你和 AI 的往复记录"
|
||||||
|
description="润色、整章重写、续写、工具箱生成的往复会自动留痕,重开也不丢。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<ThreadSection
|
||||||
|
heading="本章对话"
|
||||||
|
threads={chapterThreads}
|
||||||
|
onAccept={onAccept}
|
||||||
|
emptyText="本章还没有对话。"
|
||||||
|
/>
|
||||||
|
<ThreadSection
|
||||||
|
heading="项目级生成"
|
||||||
|
threads={projectThreads}
|
||||||
|
onAccept={onAccept}
|
||||||
|
emptyText="还没有项目级(工具箱)生成。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadSectionProps {
|
||||||
|
heading: string;
|
||||||
|
threads: AiThread[];
|
||||||
|
onAccept: (action: AcceptAction, m: AiMessageView) => void;
|
||||||
|
emptyText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadSection({
|
||||||
|
heading,
|
||||||
|
threads,
|
||||||
|
onAccept,
|
||||||
|
emptyText,
|
||||||
|
}: ThreadSectionProps) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-ink-soft">
|
||||||
|
{heading}
|
||||||
|
</h3>
|
||||||
|
{threads.length === 0 ? (
|
||||||
|
<p className="text-xs text-ink-soft">{emptyText}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{threads.map((thread) => (
|
||||||
|
<li key={thread.threadId}>
|
||||||
|
<ThreadBlock thread={thread} onAccept={onAccept} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadBlockProps {
|
||||||
|
thread: AiThread;
|
||||||
|
onAccept: (action: AcceptAction, m: AiMessageView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadBlock({ thread, onAccept }: ThreadBlockProps) {
|
||||||
|
const action = acceptActionFor(thread);
|
||||||
|
const headAt = thread.messages[0]?.created_at ?? "";
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-line bg-bg/50 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<Badge variant={kindBadge(thread.kind)}>{kindLabel(thread.kind)}</Badge>
|
||||||
|
{thread.toolKey ? (
|
||||||
|
<span className="font-mono text-2xs text-ink-soft">
|
||||||
|
{thread.toolKey}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-auto font-mono text-2xs text-ink-soft">
|
||||||
|
{formatBubbleTime(headAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ConversationThread
|
||||||
|
messages={thread.messages}
|
||||||
|
action={action}
|
||||||
|
onAccept={onAccept}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 气泡时间戳:本地 HH:MM(显示用,与自动保存「保存于 HH:MM」风格一致)。
|
||||||
|
function formatBubbleTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
||||||
|
|
||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
@@ -45,11 +44,11 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
return (
|
return (
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="本章助手"
|
title="本章参考"
|
||||||
action={
|
action={
|
||||||
injection.saving ? (
|
injection.saving ? (
|
||||||
<ThinkingIndicator
|
<ThinkingIndicator
|
||||||
label="助手保存中"
|
label="保存中"
|
||||||
className="text-xs text-ink-soft"
|
className="text-xs text-ink-soft"
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
@@ -58,7 +57,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
|
|
||||||
<section className="mt-4">
|
<section className="mt-4">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="本章注入(透明)"
|
title="AI 写这章会参考"
|
||||||
action={
|
action={
|
||||||
data ? (
|
data ? (
|
||||||
<RecentStepper
|
<RecentStepper
|
||||||
@@ -71,10 +70,10 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{injection.loading ? (
|
{injection.loading ? (
|
||||||
<p className="mt-2 text-xs text-ink-soft">加载注入信息…</p>
|
<p className="mt-2 text-xs text-ink-soft">加载参考信息…</p>
|
||||||
) : !data ? (
|
) : !data ? (
|
||||||
// 首次加载失败:data 仍为空 → 满屏错误框 + 重试。
|
// 首次加载失败:data 仍为空 → 满屏错误框 + 重试。
|
||||||
<StatusNote variant="danger" className="mt-2 text-xs" title="注入信息暂不可用">
|
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
|
||||||
<p>请检查后端连接后重试。</p>
|
<p>请检查后端连接后重试。</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -115,7 +114,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
|
|
||||||
{selected.length === 0 ? (
|
{selected.length === 0 ? (
|
||||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
||||||
本章暂无注入实体(先在大纲点名设定/角色,或从下方排除项恢复)。
|
这一章 AI 还没有要参考的设定/角色。可在大纲里点名,或从下方「已排除」里恢复。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 space-y-2">
|
<ul className="mt-2 space-y-2">
|
||||||
@@ -139,7 +138,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
|
|
||||||
{excluded.length > 0 ? (
|
{excluded.length > 0 ? (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<SectionHeader title="已排除(不注入)" />
|
<SectionHeader title="已排除(本章不参考)" />
|
||||||
<ul className="mt-1 space-y-1">
|
<ul className="mt-1 space-y-1">
|
||||||
{excluded.map((ref) => (
|
{excluded.map((ref) => (
|
||||||
<ExcludedRow
|
<ExcludedRow
|
||||||
@@ -156,15 +155,9 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
|||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="四审"
|
title="写完后检查"
|
||||||
description="一致性 / 伏笔 / 文风 / 节奏,在审稿页逐条裁决。"
|
description="写完这章,AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。"
|
||||||
/>
|
/>
|
||||||
<Link
|
|
||||||
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
|
||||||
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
|
|
||||||
>
|
|
||||||
去审稿 →
|
|
||||||
</Link>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -194,7 +187,7 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-pressed={pinned}
|
aria-pressed={pinned}
|
||||||
aria-label={
|
aria-label={
|
||||||
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}(强制注入)`
|
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}(一定参考)`
|
||||||
}
|
}
|
||||||
onClick={() => onTogglePin(ref)}
|
onClick={() => onTogglePin(ref)}
|
||||||
className={pinned ? "text-cinnabar" : undefined}
|
className={pinned ? "text-cinnabar" : undefined}
|
||||||
@@ -209,7 +202,7 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={`排除 ${entity.name}(不注入本章)`}
|
aria-label={`排除 ${entity.name}(本章不参考)`}
|
||||||
onClick={() => onExclude(ref)}
|
onClick={() => onExclude(ref)}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" aria-hidden="true" />
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
|
|
||||||
import { buildChapterEntries, type ChapterEntry } from "@/lib/workbench/chapter";
|
import { buildChapterEntries, type ChapterEntry } from "@/lib/workbench/chapter";
|
||||||
|
import { focusRing } from "@/lib/ui/variants";
|
||||||
|
|
||||||
interface ChapterListProps {
|
interface ChapterListProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -9,36 +11,83 @@ interface ChapterListProps {
|
|||||||
currentChapterNo: number;
|
currentChapterNo: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 左栏目录(UX §6.3):列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。
|
interface DesktopChapterListProps extends ChapterListProps {
|
||||||
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 ChapterListContent。
|
// 目录列收起态(P2-2):收起时只留一条带「展开」按钮的窄轨,让宽给正文。
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左栏目录(UX §6.3 / P2-2):列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。
|
||||||
|
// 桌面 aside 包裹,可收起为窄轨;移动端经 Workbench 抽屉复用 ChapterListContent(不带折叠)。
|
||||||
export function ChapterList({
|
export function ChapterList({
|
||||||
projectId,
|
projectId,
|
||||||
chapters,
|
chapters,
|
||||||
currentChapterNo,
|
currentChapterNo,
|
||||||
}: ChapterListProps) {
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
}: DesktopChapterListProps) {
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="hidden shrink-0 border-r border-line bg-panel py-4 xl:block"
|
||||||
|
aria-label="目录(已收起)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={false}
|
||||||
|
aria-label="展开目录"
|
||||||
|
title="展开目录"
|
||||||
|
className={`mx-auto flex h-8 w-8 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<aside className="hidden border-r border-line bg-panel py-4 xl:block">
|
<aside className="hidden border-r border-line bg-panel py-4 xl:block">
|
||||||
<ChapterListContent
|
<ChapterListContent
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
currentChapterNo={currentChapterNo}
|
currentChapterNo={currentChapterNo}
|
||||||
|
onCollapse={onToggle}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChapterListContentProps extends ChapterListProps {
|
||||||
|
// 桌面列传入 → 目录标题旁显示「收起」按钮;移动抽屉不传(抽屉自带关闭)。
|
||||||
|
onCollapse?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function ChapterListContent({
|
export function ChapterListContent({
|
||||||
projectId,
|
projectId,
|
||||||
chapters,
|
chapters,
|
||||||
currentChapterNo,
|
currentChapterNo,
|
||||||
}: ChapterListProps) {
|
onCollapse,
|
||||||
|
}: ChapterListContentProps) {
|
||||||
const entries = buildChapterEntries(chapters, currentChapterNo);
|
const entries = buildChapterEntries(chapters, currentChapterNo);
|
||||||
const hasOutline = chapters.length > 0;
|
const hasOutline = chapters.length > 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="px-4 text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
<div className="flex items-center justify-between px-4">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||||
目录
|
目录
|
||||||
</h2>
|
</h2>
|
||||||
|
{onCollapse ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCollapse}
|
||||||
|
aria-label="收起目录"
|
||||||
|
title="收起目录"
|
||||||
|
className={`flex h-6 w-6 items-center justify-center rounded text-ink-soft transition-colors hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${focusRing}`}
|
||||||
|
>
|
||||||
|
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<ul className="mt-2">
|
<ul className="mt-2">
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const active = entry.no === currentChapterNo;
|
const active = entry.no === currentChapterNo;
|
||||||
|
|||||||
312
apps/web/components/workbench/ChapterRewritePanel.tsx
Normal file
312
apps/web/components/workbench/ChapterRewritePanel.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Check, MessagesSquare, RotateCcw, Square, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { TextArea } from "@/components/ui/TextArea";
|
||||||
|
import type { AiMessageView } from "@/lib/api/types";
|
||||||
|
import { friendlyError } from "@/lib/errors/messages";
|
||||||
|
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
|
||||||
|
import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
|
||||||
|
import { useRewriteClarify } from "@/lib/workbench/useRewriteClarify";
|
||||||
|
import { acceptActionFor, type AcceptAction } from "@/lib/workbench/aiConversation";
|
||||||
|
import { mergeInlineThreads, toPendingView } from "@/lib/workbench/inlineConversation";
|
||||||
|
import type { AiTurnMessage, UseAiConversation } from "@/lib/workbench/useAiConversation";
|
||||||
|
import { ChoiceChips } from "./ChoiceChips";
|
||||||
|
import { ConversationThread } from "./ConversationThread";
|
||||||
|
|
||||||
|
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
|
||||||
|
const CLARIFY_MIN_CHARS = 10;
|
||||||
|
// 本面板内联气泡流展示的 kinds(本轮 rewrite 往复 + 其 clarify 反问;后者持久化时并入 rewrite 批)。
|
||||||
|
const REWRITE_KINDS = ["rewrite", "clarify"] as const;
|
||||||
|
|
||||||
|
interface ChapterRewritePanelProps {
|
||||||
|
projectId: string;
|
||||||
|
chapterNo: number;
|
||||||
|
// 当前整章正文(首轮重写的 prior_draft)。
|
||||||
|
currentDraft: string;
|
||||||
|
// 接受某一版 → 替换整章正文(HITL,作者显式点选才落草稿);气泡「再接受」亦复用此回调。
|
||||||
|
onAccept: (text: string) => void;
|
||||||
|
// Workbench 级持久化对话(本章留痕数据源 + fail-soft append)。
|
||||||
|
conversation: UseAiConversation;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → 意见含糊/极短时 AI 先反问给方向选项 →
|
||||||
|
// 就着当前整章 + 完整记忆注入流式重写一版。面板正文是一条内联对话气泡流(本章已落库 rewrite 往复 +
|
||||||
|
// 本轮进行中气泡实时汇入,流式 token 逐字进气泡),输入框在底部。接受某版才替换整章正文(不变量 #3)。
|
||||||
|
export function ChapterRewritePanel({
|
||||||
|
projectId,
|
||||||
|
chapterNo,
|
||||||
|
currentDraft,
|
||||||
|
onAccept,
|
||||||
|
conversation,
|
||||||
|
onClose,
|
||||||
|
}: ChapterRewritePanelProps) {
|
||||||
|
const { messages, status, ensureLoaded, appendMessages } = conversation;
|
||||||
|
const rewrite = useChapterRewrite();
|
||||||
|
const clarify = useRewriteClarify();
|
||||||
|
const [feedback, setFeedback] = useState("");
|
||||||
|
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
|
||||||
|
// 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。
|
||||||
|
const [liveTurn, setLiveTurn] = useState<AiTurnMessage[] | null>(null);
|
||||||
|
// 本面板一次会话 = 一个 thread;多版迭代 + 澄清往复归此组,版本号本地累加。
|
||||||
|
const threadId = useRef(crypto.randomUUID()).current;
|
||||||
|
const versionNoRef = useRef(0);
|
||||||
|
const streamRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 打开即懒加载本章留痕(Workbench 级 hook;append 也会触发——已加载则空操作)。
|
||||||
|
useEffect(() => {
|
||||||
|
ensureLoaded();
|
||||||
|
}, [ensureLoaded]);
|
||||||
|
|
||||||
|
const hasVersions = rewrite.versions.length > 0;
|
||||||
|
const clarifying = clarify.status === "checking";
|
||||||
|
const canSend =
|
||||||
|
feedback.trim().length > 0 && !rewrite.isStreaming && !clarifying;
|
||||||
|
const firstQuestion = clarify.decision?.questions[0] ?? null;
|
||||||
|
const asking = clarify.status === "asking" && firstQuestion !== null;
|
||||||
|
// 下一轮以最新版正文为基础(无版本则用当前整章正文)迭代。
|
||||||
|
const priorDraft = rewrite.latest?.text ?? currentDraft;
|
||||||
|
const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
|
||||||
|
|
||||||
|
// 折入澄清答案(或原意见)后流式重写,结束后留痕;clarifyTurns 非空时把缓冲 Q&A 一并落库。
|
||||||
|
const runSend = (fb: string, clarifyTurns: AiTurnMessage[] | null): void => {
|
||||||
|
clarify.reset();
|
||||||
|
setPendingFeedback(null);
|
||||||
|
setFeedback("");
|
||||||
|
// clarify 分支:作者答复 IS 本轮 author 输入;否则记 author(意见)。
|
||||||
|
const authorTurn: AiTurnMessage = { role: "author", content: fb };
|
||||||
|
setLiveTurn(clarifyTurns ?? [authorTurn]);
|
||||||
|
void (async () => {
|
||||||
|
const v = await rewrite.send(projectId, chapterNo, fb, priorDraft);
|
||||||
|
if (v) {
|
||||||
|
versionNoRef.current += 1;
|
||||||
|
const versionNo = versionNoRef.current;
|
||||||
|
const aiRow: AiTurnMessage = {
|
||||||
|
role: "ai",
|
||||||
|
content: v.text,
|
||||||
|
meta: { version_no: versionNo },
|
||||||
|
};
|
||||||
|
const rows = clarifyTurns
|
||||||
|
? [...clarifyTurns, aiRow]
|
||||||
|
: [
|
||||||
|
{ role: "author" as const, content: fb, meta: { version_no: versionNo } },
|
||||||
|
aiRow,
|
||||||
|
];
|
||||||
|
appendMessages({ threadId, kind: "rewrite", chapterNo, messages: rows });
|
||||||
|
}
|
||||||
|
setLiveTurn(null);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 「重写整章 / 再改一版」:意见含糊/极短(或 force)先预检反问;否则直接流式重写。
|
||||||
|
const onSend = async (force: boolean): Promise<void> => {
|
||||||
|
const fb = feedback.trim();
|
||||||
|
if (!fb || rewrite.isStreaming || clarifying) return;
|
||||||
|
if (force || needsClarifyGate(fb, CLARIFY_MIN_CHARS)) {
|
||||||
|
const vm = await clarify.check(projectId, chapterNo, fb, priorDraft);
|
||||||
|
if (vm.needClarification && vm.questions.length > 0) {
|
||||||
|
setPendingFeedback(fb); // 记住原意见,等作者答完折进去
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runSend(fb, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A(原意见→反问→答复)+ 折进意见 → 流式重写。
|
||||||
|
const onClarifyAnswer = (answer: string): void => {
|
||||||
|
const q = clarify.decision?.questions[0];
|
||||||
|
const question = q?.question ?? "";
|
||||||
|
const base = pendingFeedback ?? feedback.trim();
|
||||||
|
const clarifyTurns: AiTurnMessage[] = [
|
||||||
|
{ role: "author", content: base },
|
||||||
|
{
|
||||||
|
role: "ai",
|
||||||
|
content: question,
|
||||||
|
meta: {
|
||||||
|
parent_kind: "rewrite",
|
||||||
|
options: (q?.options ?? []).map((o) => ({
|
||||||
|
label: o.label,
|
||||||
|
value: o.value,
|
||||||
|
})),
|
||||||
|
allow_free_text: q?.allowFreeText ?? false,
|
||||||
|
verification: clarify.decision?.verification ?? "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ role: "author", content: answer },
|
||||||
|
];
|
||||||
|
runSend(foldClarifications(base, [{ question, answer }]), clarifyTurns);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 本轮进行中气泡:liveTurn(已定的作者/反问)或反问待答时的作者意见 + 流式产出的 ai 气泡(逐字更新)。
|
||||||
|
const pending: AiMessageView[] = [];
|
||||||
|
{
|
||||||
|
let seq = 0;
|
||||||
|
const add = (seed: Parameters<typeof toPendingView>[0]): void => {
|
||||||
|
pending.push(toPendingView(seed, { threadId, chapterNo, kind: "rewrite", seq: seq++ }));
|
||||||
|
};
|
||||||
|
if (liveTurn) {
|
||||||
|
liveTurn.forEach((m, i) =>
|
||||||
|
add({ localId: `pt-${threadId}-${i}`, role: m.role, content: m.content }),
|
||||||
|
);
|
||||||
|
} else if (asking) {
|
||||||
|
add({
|
||||||
|
localId: `pt-${threadId}-ask`,
|
||||||
|
role: "author",
|
||||||
|
content: pendingFeedback ?? feedback.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 生成中(含 token 已到但流未结束)显示流式 ai 气泡;正文空 → 「生成中」指示。
|
||||||
|
const answerActive =
|
||||||
|
liveTurn !== null && (rewrite.isStreaming || rewrite.state.text.length > 0);
|
||||||
|
if (answerActive) {
|
||||||
|
add({
|
||||||
|
localId: `pt-${threadId}-answer`,
|
||||||
|
role: "ai",
|
||||||
|
content: rewrite.state.text,
|
||||||
|
streaming: rewrite.isStreaming,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const threads = mergeInlineThreads(messages, chapterNo, REWRITE_KINDS, pending);
|
||||||
|
const flatCount = threads.reduce((n, t) => n + t.messages.length, 0);
|
||||||
|
|
||||||
|
// 新气泡/流式 token 出现即滚到底(聊天视图)。
|
||||||
|
useEffect(() => {
|
||||||
|
const el = streamRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [flatCount, rewrite.state.text]);
|
||||||
|
|
||||||
|
const onBubbleAccept = (action: AcceptAction, m: AiMessageView): void => {
|
||||||
|
if (action === "replace_chapter") onAccept(m.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[75vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<SectionHeader
|
||||||
|
title="对整章再沟通 / 重写"
|
||||||
|
description="给意见 → AI 就着当前整章 + 完整设定重写一版;意见太笼统时会先反问给方向选项,满意再替换正文。"
|
||||||
|
/>
|
||||||
|
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭整章重写">
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内联对话气泡流:本章 rewrite 往复(已落库)+ 本轮进行中气泡(流式逐字),作者右 / AI 左,滚动区。 */}
|
||||||
|
{flatCount > 0 || status === "error" ? (
|
||||||
|
<div
|
||||||
|
ref={streamRef}
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-relevant="additions"
|
||||||
|
aria-label="整章重写对话"
|
||||||
|
className="mt-3 max-h-[45vh] space-y-3 overflow-auto overscroll-contain rounded border border-line bg-bg/40 p-3"
|
||||||
|
>
|
||||||
|
{status === "error" ? (
|
||||||
|
<StatusNote variant="danger" className="text-xs" title="历史对话加载失败">
|
||||||
|
<Button
|
||||||
|
onClick={conversation.reload}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
|
{threads.map((thread) => (
|
||||||
|
<ConversationThread
|
||||||
|
key={thread.threadId}
|
||||||
|
messages={thread.messages}
|
||||||
|
action={acceptActionFor(thread)}
|
||||||
|
onAccept={onBubbleAccept}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{asking && firstQuestion ? (
|
||||||
|
// role="status":AI 反问动态出现时向读屏播报(WCAG 4.1.3 Status Messages)。
|
||||||
|
<div role="status" className="rounded border border-cinnabar/40 bg-bg p-3">
|
||||||
|
<p className="mb-2 text-2xs text-ink-soft">AI 想先跟你确认一下:</p>
|
||||||
|
<ChoiceChips
|
||||||
|
question={firstQuestion.question}
|
||||||
|
options={firstQuestion.options}
|
||||||
|
allowFreeText={firstQuestion.allowFreeText}
|
||||||
|
onPick={onClarifyAnswer}
|
||||||
|
onFreeText={onClarifyAnswer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<label className="block">
|
||||||
|
<span className="sr-only">修改意见</span>
|
||||||
|
<TextArea
|
||||||
|
value={feedback}
|
||||||
|
onChange={(e) => setFeedback(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder={
|
||||||
|
hasVersions
|
||||||
|
? "对这一版还想怎么改?(如「高潮再拉满」「删掉第 2 段闪回」)"
|
||||||
|
: "这一章哪里不满意?想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{rewrite.isStreaming ? (
|
||||||
|
<Button onClick={rewrite.stop} variant="danger" size="sm">
|
||||||
|
<Square className="h-4 w-4" aria-hidden="true" />
|
||||||
|
停
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => void onSend(false)}
|
||||||
|
disabled={!canSend}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{hasVersions ? "再改一版" : "重写整章"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => void onSend(true)}
|
||||||
|
disabled={rewrite.isStreaming || clarifying || feedback.trim().length === 0}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="让 AI 先反问、给方向选项再改"
|
||||||
|
>
|
||||||
|
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
|
||||||
|
帮我理清方向
|
||||||
|
</Button>
|
||||||
|
{hasVersions && !rewrite.isStreaming ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => rewrite.latest && onAccept(rewrite.latest.text)}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" aria-hidden="true" />
|
||||||
|
采用(替换整章)
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{rewrite.isStreaming ? (
|
||||||
|
<ThinkingIndicator label="重写中" className="text-xs text-cinnabar" />
|
||||||
|
) : clarifying ? (
|
||||||
|
<ThinkingIndicator label="琢磨要不要反问" className="text-xs text-cinnabar" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{err ? (
|
||||||
|
<StatusNote variant="danger" className="text-xs" title="整章重写失败">
|
||||||
|
<p>{friendlyError(err.code, err.message).text}</p>
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/web/components/workbench/ChoiceChips.tsx
Normal file
106
apps/web/components/workbench/ChoiceChips.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId, useState, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { TextInput } from "@/components/ui/TextInput";
|
||||||
|
import type { ClarifyOptionVM } from "@/lib/workbench/clarify";
|
||||||
|
|
||||||
|
interface ChoiceChipsProps {
|
||||||
|
// 反问的澄清问题。
|
||||||
|
question: string;
|
||||||
|
// 2–4 个具体选项;为空时只渲染自由输入。
|
||||||
|
options: ClarifyOptionVM[];
|
||||||
|
// 是否提供常驻自由输入兜底。
|
||||||
|
allowFreeText: boolean;
|
||||||
|
// 点选某选项:回传其 value(供上层折进 instruction)。
|
||||||
|
onPick: (value: string) => void;
|
||||||
|
// 提交自由输入文本(allowFreeText 且提供此回调时可用)。
|
||||||
|
onFreeText?: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 反问澄清的选项芯片组(WFW-9 M1):渲染一个问题 + 一组选项芯片(复用 Button),
|
||||||
|
// 作者点选一项即高亮并回传 value;可选自由输入兜底。纯展示、无副作用、无 API 调用。
|
||||||
|
export function ChoiceChips({
|
||||||
|
question,
|
||||||
|
options,
|
||||||
|
allowFreeText,
|
||||||
|
onPick,
|
||||||
|
onFreeText,
|
||||||
|
}: ChoiceChipsProps) {
|
||||||
|
const [picked, setPicked] = useState<string | null>(null);
|
||||||
|
const [freeText, setFreeText] = useState("");
|
||||||
|
const inputId = useId();
|
||||||
|
const hasOptions = options.length > 0;
|
||||||
|
const canSubmit = Boolean(onFreeText) && freeText.trim().length > 0;
|
||||||
|
|
||||||
|
const handlePick = (value: string): void => {
|
||||||
|
setPicked(value);
|
||||||
|
onPick(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFreeSubmit = (): void => {
|
||||||
|
const trimmed = freeText.trim();
|
||||||
|
if (!trimmed || !onFreeText) return;
|
||||||
|
onFreeText(trimmed);
|
||||||
|
setFreeText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
handleFreeSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-ink">{question}</p>
|
||||||
|
|
||||||
|
{hasOptions ? (
|
||||||
|
<div className="flex flex-wrap gap-2" role="group" aria-label="澄清选项">
|
||||||
|
{options.map((option, index) => {
|
||||||
|
const active = option.value === picked;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={`${index}-${option.value}`}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => handlePick(option.value)}
|
||||||
|
variant={active ? "outline" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{allowFreeText ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor={inputId} className="sr-only">
|
||||||
|
自由回答
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
id={inputId}
|
||||||
|
value={freeText}
|
||||||
|
onChange={(event) => setFreeText(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
controlSize="sm"
|
||||||
|
placeholder="或直接说你的想法…"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFreeSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
apps/web/components/workbench/ContextDrawer.tsx
Normal file
330
apps/web/components/workbench/ContextDrawer.tsx
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
|
import { ArrowUpRight, BookMarked, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||||
|
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
|
||||||
|
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||||
|
import { badgeClass, cn, overlayScrim } from "@/lib/ui/variants";
|
||||||
|
import {
|
||||||
|
CONTEXT_TABS,
|
||||||
|
contextTabHref,
|
||||||
|
type CodexGroup,
|
||||||
|
type ContextTabKey,
|
||||||
|
type OutlineRow,
|
||||||
|
} from "@/lib/workbench/contextDrawer";
|
||||||
|
import {
|
||||||
|
useContextDrawerData,
|
||||||
|
type ContextResource,
|
||||||
|
} from "@/lib/workbench/useContextDrawer";
|
||||||
|
|
||||||
|
// 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。
|
||||||
|
export const CONTEXT_DRAWER_ENABLED = true;
|
||||||
|
|
||||||
|
interface ContextDrawerProps {
|
||||||
|
projectId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
// 关闭时把焦点还给触发按钮(a11y)。
|
||||||
|
triggerRef?: RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从编辑器右侧滑出的【只读】上下文速查抽屉:不离开写作界面即可查设定库/大纲/伏笔/规则/文风。
|
||||||
|
// 视口无关(不同于 components/Drawer 的 lg:hidden 移动端抽屉),全只读、绝不写库。
|
||||||
|
// a11y 复用命令面板/Drawer 范式:role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。
|
||||||
|
export function ContextDrawer({
|
||||||
|
projectId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
triggerRef,
|
||||||
|
}: ContextDrawerProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<ContextTabKey>("codex");
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const data = useContextDrawerData(projectId, activeTab, open);
|
||||||
|
|
||||||
|
useBodyScrollLock(open);
|
||||||
|
useRestoreFocus(open, triggerRef);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
closeRef.current?.focus();
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={overlayScrim} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="上下文速查"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (panelRef.current) handleTabTrap(panelRef.current, e);
|
||||||
|
}}
|
||||||
|
className="absolute right-0 top-0 flex h-full w-96 max-w-[90vw] flex-col border-l border-line bg-panel shadow-paper outline-none"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-4 py-3">
|
||||||
|
<span className="flex items-center gap-2 font-serif text-base text-ink">
|
||||||
|
<BookMarked className="h-4 w-4 text-cinnabar" aria-hidden="true" />
|
||||||
|
上下文速查
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
ref={closeRef}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭上下文速查"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<TabBar activeTab={activeTab} onSelect={setActiveTab} />
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto overscroll-contain px-4 py-4">
|
||||||
|
<TabPanel projectId={projectId} activeTab={activeTab} data={data} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
activeTab: ContextTabKey;
|
||||||
|
onSelect: (key: ContextTabKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab 选择条(role=tablist):设定库/大纲做完整速查,伏笔/规则/文风为摘要+跳链。
|
||||||
|
function TabBar({ activeTab, onSelect }: TabBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="上下文分类"
|
||||||
|
className="flex gap-1 overflow-x-auto border-b border-line px-2 py-2"
|
||||||
|
>
|
||||||
|
{CONTEXT_TABS.map((tab) => {
|
||||||
|
const active = tab.key === activeTab;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => onSelect(tab.key)}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
||||||
|
active
|
||||||
|
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||||
|
: "text-ink-soft hover:text-cinnabar",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabPanelProps {
|
||||||
|
projectId: string;
|
||||||
|
activeTab: ContextTabKey;
|
||||||
|
data: ReturnType<typeof useContextDrawerData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
|
||||||
|
function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
|
||||||
|
const href = contextTabHref(projectId, activeTab);
|
||||||
|
return (
|
||||||
|
<div role="tabpanel" className="space-y-3">
|
||||||
|
{activeTab === "codex" ? (
|
||||||
|
<CodexPanel resource={data.codex} onRetry={data.reloadCodex} />
|
||||||
|
) : null}
|
||||||
|
{activeTab === "outline" ? (
|
||||||
|
<OutlinePanel resource={data.outline} onRetry={data.reloadOutline} />
|
||||||
|
) : null}
|
||||||
|
{activeTab === "foreshadow" ? (
|
||||||
|
<PlaceholderPanel summary="伏笔账本:埋设/回收窗口与逾期告警在完整看板逐条管理。" />
|
||||||
|
) : null}
|
||||||
|
{activeTab === "rules" ? (
|
||||||
|
<PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" />
|
||||||
|
) : null}
|
||||||
|
{activeTab === "style" ? (
|
||||||
|
<PlaceholderPanel summary="文风指纹:16 维文风画像,在文风页查看维度与证据。" />
|
||||||
|
) : null}
|
||||||
|
<FullPageLink href={href} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载中骨架(统一给设定库/大纲复用)。
|
||||||
|
function LoadingNote() {
|
||||||
|
return (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<ThinkingIndicator label="加载中" className="text-sm text-ink-soft" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorNoteProps {
|
||||||
|
message: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorNote({ message, onRetry }: ErrorNoteProps) {
|
||||||
|
return (
|
||||||
|
<StatusNote variant="danger" title="加载失败">
|
||||||
|
<p>{message}</p>
|
||||||
|
<Button onClick={onRetry} variant="secondary" size="sm" className="mt-2">
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</StatusNote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyNote({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<StatusNote variant="info">
|
||||||
|
<p>{text}</p>
|
||||||
|
</StatusNote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexPanelProps {
|
||||||
|
resource: ContextResource<CodexGroup[]>;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设定库速查:按类型分组的实体名单(含硬规则条目),完整只读列表。
|
||||||
|
function CodexPanel({ resource, onRetry }: CodexPanelProps) {
|
||||||
|
if (resource.status === "loading" || resource.status === "idle") {
|
||||||
|
return <LoadingNote />;
|
||||||
|
}
|
||||||
|
if (resource.status === "error") {
|
||||||
|
return <ErrorNote message="设定库暂不可用。" onRetry={onRetry} />;
|
||||||
|
}
|
||||||
|
if (resource.data.length === 0) {
|
||||||
|
return <EmptyNote text="还没有世界观实体,去设定库添加后回来速查。" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{resource.data.map((group) => (
|
||||||
|
<section key={group.type}>
|
||||||
|
<h3 className="mb-1.5 flex items-center gap-2 text-sm font-medium text-ink">
|
||||||
|
{group.type}
|
||||||
|
<span className={badgeClass({ variant: "neutral" })}>
|
||||||
|
{group.entities.length}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{group.entities.map((entity) => (
|
||||||
|
<li
|
||||||
|
key={`${group.type}-${entity.name}`}
|
||||||
|
className="rounded border border-line bg-bg px-3 py-2"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-ink">{entity.name}</p>
|
||||||
|
{entity.rules && entity.rules.length > 0 ? (
|
||||||
|
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-xs text-ink-soft">
|
||||||
|
{entity.rules.map((rule, i) => (
|
||||||
|
<li key={i}>{rule}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutlinePanelProps {
|
||||||
|
resource: ContextResource<OutlineRow[]>;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大纲速查:逐章节拍紧凑列表 + 该章伏笔窗口代号徽标,完整只读。
|
||||||
|
function OutlinePanel({ resource, onRetry }: OutlinePanelProps) {
|
||||||
|
if (resource.status === "loading" || resource.status === "idle") {
|
||||||
|
return <LoadingNote />;
|
||||||
|
}
|
||||||
|
if (resource.status === "error") {
|
||||||
|
return <ErrorNote message="大纲暂不可用。" onRetry={onRetry} />;
|
||||||
|
}
|
||||||
|
if (resource.data.length === 0) {
|
||||||
|
return <EmptyNote text="还没有大纲,去大纲页生成后回来速查。" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{resource.data.map((row) => (
|
||||||
|
<li
|
||||||
|
key={row.no}
|
||||||
|
className="rounded border border-line bg-bg px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-mono text-xs text-ink-soft">
|
||||||
|
第 {row.no} 章 · 卷 {row.volume}
|
||||||
|
</span>
|
||||||
|
{row.foreshadowCodes.length > 0 ? (
|
||||||
|
<span className="flex flex-wrap gap-1">
|
||||||
|
{row.foreshadowCodes.map((code) => (
|
||||||
|
<span key={code} className={badgeClass({ variant: "accent" })}>
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{row.beats.length > 0 ? (
|
||||||
|
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-sm text-ink">
|
||||||
|
{row.beats.map((beat, i) => (
|
||||||
|
<li key={i}>{beat}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-ink-soft">(本章暂无节拍)</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 占位面板(伏笔/规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
|
||||||
|
function PlaceholderPanel({ summary }: { summary: string }) {
|
||||||
|
return (
|
||||||
|
<StatusNote variant="info">
|
||||||
|
<p>{summary}</p>
|
||||||
|
</StatusNote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「去完整页编辑 →」链接:抽屉只读速查 → 跳全宽页编辑。
|
||||||
|
function FullPageLink({ href }: { href: string }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-cinnabar hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"
|
||||||
|
>
|
||||||
|
去完整页编辑
|
||||||
|
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/web/components/workbench/ContinuePanel.tsx
Normal file
128
apps/web/components/workbench/ContinuePanel.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { Check, Plus, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { useContinue } from "@/lib/workbench/useContinue";
|
||||||
|
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
|
||||||
|
|
||||||
|
interface ContinuePanelProps {
|
||||||
|
projectId: string;
|
||||||
|
chapterNo: number;
|
||||||
|
// 插入选中候选到正文(追加到章末)。
|
||||||
|
onInsert: (text: string) => void;
|
||||||
|
// 留痕:每条候选记一条 ai 气泡(续写无作者输入,故无 author 气泡)。
|
||||||
|
appendMessages: (turn: AiTurnInput) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 续写结果卡(内联,非模态):打开即读该章前文续写一条候选;可「再来一个」累加多候选,
|
||||||
|
// 作者点某条「插入正文」才落回草稿(HITL,不静默写入)。
|
||||||
|
export function ContinuePanel({
|
||||||
|
projectId,
|
||||||
|
chapterNo,
|
||||||
|
onInsert,
|
||||||
|
appendMessages,
|
||||||
|
onClose,
|
||||||
|
}: ContinuePanelProps) {
|
||||||
|
const { status, candidates, generate } = useContinue();
|
||||||
|
const ranRef = useRef(false);
|
||||||
|
// 本面板一次会话 = 一个 thread;多条候选归此组,candidate_index 本地累加。
|
||||||
|
const threadId = useRef(crypto.randomUUID()).current;
|
||||||
|
const candidateIdxRef = useRef(0);
|
||||||
|
|
||||||
|
// 生成一条候选并(成功时)留痕:ai-only,meta 存候选序号。
|
||||||
|
const runGenerate = useCallback(async (): Promise<void> => {
|
||||||
|
const text = await generate(projectId, chapterNo);
|
||||||
|
if (!text) return;
|
||||||
|
const candidateIndex = candidateIdxRef.current;
|
||||||
|
candidateIdxRef.current += 1;
|
||||||
|
appendMessages({
|
||||||
|
threadId,
|
||||||
|
kind: "continue",
|
||||||
|
toolKey: "continue",
|
||||||
|
chapterNo,
|
||||||
|
messages: [
|
||||||
|
{ role: "ai", content: text, meta: { candidate_index: candidateIndex } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, [generate, projectId, chapterNo, appendMessages, threadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ranRef.current) return;
|
||||||
|
ranRef.current = true;
|
||||||
|
void runGenerate();
|
||||||
|
}, [runGenerate]);
|
||||||
|
|
||||||
|
const busy = status === "generating";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<SectionHeader
|
||||||
|
title="续写候选"
|
||||||
|
description="承接本章前文续写;点「再来一个」多生成几版,选中意的加到章末。"
|
||||||
|
/>
|
||||||
|
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭续写">
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{candidates.length === 0 && busy ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<ThinkingIndicator label="续写中" className="text-xs text-cinnabar" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{candidates.length === 0 && status === "error" ? (
|
||||||
|
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void runGenerate()}
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{candidates.map((text, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="rounded border border-line bg-bg p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-2xs text-ink-soft">候选 {i + 1}</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => onInsert(text)}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" aria-hidden="true" />
|
||||||
|
采用(加到章末)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-ink">{text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => void runGenerate()}
|
||||||
|
disabled={busy}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
再来一个候选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
apps/web/components/workbench/ConversationThread.tsx
Normal file
120
apps/web/components/workbench/ConversationThread.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { cn } from "@/lib/ui/variants";
|
||||||
|
import type { AiMessageView } from "@/lib/api/types";
|
||||||
|
import { bubbleSide, type AcceptAction } from "@/lib/workbench/aiConversation";
|
||||||
|
import {
|
||||||
|
isPendingView,
|
||||||
|
isStreamingView,
|
||||||
|
} from "@/lib/workbench/inlineConversation";
|
||||||
|
|
||||||
|
// 一组对话气泡(作者右 / AI 左)。抽屉与内联面板共用同一渲染,外观完全一致(DRY)。
|
||||||
|
// 对已落库的 ai 产出气泡挂「再接受」(复用编辑器既有 DRAFT-only HITL 回调,不绕验收事务);
|
||||||
|
// clarify 气泡与进行中(未落库/流式)气泡不挂。
|
||||||
|
export const ACCEPT_LABEL: Record<AcceptAction, string> = {
|
||||||
|
replace_chapter: "采用(替换整章)",
|
||||||
|
append_draft: "采用(加到章末)",
|
||||||
|
refill_segment: "采用(替换这段)",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConversationThreadProps {
|
||||||
|
messages: readonly AiMessageView[];
|
||||||
|
// 本线程可执行的接受动作(据 thread.kind 推导);null 时全部气泡不挂接受。
|
||||||
|
action: AcceptAction | null;
|
||||||
|
onAccept: (action: AcceptAction, m: AiMessageView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationThread({
|
||||||
|
messages,
|
||||||
|
action,
|
||||||
|
onAccept,
|
||||||
|
}: ConversationThreadProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<Bubble key={m.id} message={m} action={action} onAccept={onAccept} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BubbleProps {
|
||||||
|
message: AiMessageView;
|
||||||
|
action: AcceptAction | null;
|
||||||
|
onAccept: (action: AcceptAction, m: AiMessageView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单条气泡:作者右(cinnabar-wash)/ AI 左(border+bg);clarify ai 气泡额外渲染只读方向选项。
|
||||||
|
// 进行中气泡(meta._pending)不挂再接受;流式且空正文时渲染「生成中」指示(meta._streaming)。
|
||||||
|
function Bubble({ message, action, onAccept }: BubbleProps) {
|
||||||
|
const side = bubbleSide(message.role);
|
||||||
|
const options = message.kind === "clarify" ? readOptions(message.meta) : [];
|
||||||
|
const pending = isPendingView(message);
|
||||||
|
const showThinking = isStreamingView(message) && message.content.length === 0;
|
||||||
|
// 只有已落库 ai 产出气泡(左侧、非 clarify)挂再接受;进行中气泡(仍在生成)不挂。
|
||||||
|
const showAccept =
|
||||||
|
action !== null &&
|
||||||
|
side === "left" &&
|
||||||
|
message.kind !== "clarify" &&
|
||||||
|
!pending;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex", side === "right" ? "justify-end" : "justify-start")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[85%] rounded border px-3 py-2",
|
||||||
|
side === "right"
|
||||||
|
? "border-cinnabar/30 bg-[var(--color-cinnabar-wash)]"
|
||||||
|
: "border-line bg-bg",
|
||||||
|
pending && "opacity-90",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showThinking ? (
|
||||||
|
<ThinkingIndicator label="生成中" className="text-sm text-cinnabar" />
|
||||||
|
) : (
|
||||||
|
<p className="max-h-56 overflow-y-auto whitespace-pre-wrap text-sm text-ink">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{options.length > 0 ? (
|
||||||
|
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{options.map((opt, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="rounded border border-line bg-panel px-2 py-0.5 text-2xs text-ink-soft"
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
{showAccept && action ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => onAccept(action, message)}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
{ACCEPT_LABEL[action]}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarify ai 行 meta.options → 只读选项 label 列表(外部数据,逐项显式取值,不信任形状)。
|
||||||
|
function readOptions(meta: AiMessageView["meta"]): string[] {
|
||||||
|
const raw = (meta ?? {})["options"];
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw
|
||||||
|
.map((o) =>
|
||||||
|
o && typeof o === "object" && "label" in o
|
||||||
|
? String((o as { label: unknown }).label ?? "")
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
.filter((label) => label.length > 0);
|
||||||
|
}
|
||||||
@@ -4,17 +4,37 @@ import { useEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
|
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
|
||||||
|
|
||||||
|
export interface EditorSelection {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
|
// 选区变化上报(供「润色选段」等选区级 AI 动作取材)。空选区也会上报(start===end)。
|
||||||
|
onSelectionChange?: (selection: EditorSelection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 中栏正文编辑器:宋体、720px 居中、行高 1.9(UX §2.3 / §6.3)。
|
// 中栏正文编辑器:宋体、720px 居中、行高 1.9(UX §2.3 / §6.3)。
|
||||||
// 流式中显示朱砂光标提示(prefers-reduced-motion 下不闪烁,见 globals.css)。
|
// 流式中显示朱砂光标提示(prefers-reduced-motion 下不闪烁,见 globals.css)。
|
||||||
export function Editor({ value, onChange, streaming }: EditorProps) {
|
export function Editor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
streaming,
|
||||||
|
onSelectionChange,
|
||||||
|
}: EditorProps) {
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const reportSelection = (el: HTMLTextAreaElement): void => {
|
||||||
|
if (!onSelectionChange) return;
|
||||||
|
const start = el.selectionStart;
|
||||||
|
const end = el.selectionEnd;
|
||||||
|
onSelectionChange({ start, end, text: value.slice(start, end) });
|
||||||
|
};
|
||||||
|
|
||||||
// 自适应高度:textarea 高度=内容高度(overflow-hidden 不内部滚动),由外层写作区
|
// 自适应高度:textarea 高度=内容高度(overflow-hidden 不内部滚动),由外层写作区
|
||||||
// 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。
|
// 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。
|
||||||
// min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。
|
// min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。
|
||||||
@@ -35,6 +55,7 @@ export function Editor({ value, onChange, streaming }: EditorProps) {
|
|||||||
id="chapter-editor"
|
id="chapter-editor"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onSelect={(e) => reportSelection(e.currentTarget)}
|
||||||
readOnly={streaming}
|
readOnly={streaming}
|
||||||
aria-busy={streaming}
|
aria-busy={streaming}
|
||||||
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"
|
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"
|
||||||
|
|||||||
62
apps/web/components/workbench/GenrePicker.tsx
Normal file
62
apps/web/components/workbench/GenrePicker.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
interface GenrePickerProps {
|
||||||
|
// 备选题材(来自 lib/wizard/wizard.ts 的 GENRES)。
|
||||||
|
genres: readonly string[];
|
||||||
|
// 当前点选题材(未选 = null)。
|
||||||
|
value: string | null;
|
||||||
|
// 点选某题材(点已选项亦回传该值,交由上层决定是否切换)。
|
||||||
|
onPick: (genre: string) => void;
|
||||||
|
// 可选:关闭/跳过采集卡。
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 极轻 genre 采集卡(WFW-7):一组 chips 让作者点本次写作题材,
|
||||||
|
// 杜绝无题材 slop。纯展示,选中态用 aria-pressed 标注。
|
||||||
|
export function GenrePicker({
|
||||||
|
genres,
|
||||||
|
value,
|
||||||
|
onPick,
|
||||||
|
onDismiss,
|
||||||
|
}: GenrePickerProps) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-serif text-base text-ink">先选个题材</p>
|
||||||
|
<p className="mt-0.5 text-sm text-ink-soft">
|
||||||
|
本项目还没定题材,点一个题材再写,本次生成会更贴合、少套话。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onDismiss ? (
|
||||||
|
<Button onClick={onDismiss} variant="secondary" size="sm">
|
||||||
|
跳过
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-3 flex flex-wrap gap-2"
|
||||||
|
role="group"
|
||||||
|
aria-label="本章题材"
|
||||||
|
>
|
||||||
|
{genres.map((genre) => {
|
||||||
|
const active = genre === value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={genre}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onPick(genre)}
|
||||||
|
variant={active ? "outline" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{genre}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/web/components/workbench/InlineToolbox.tsx
Normal file
98
apps/web/components/workbench/InlineToolbox.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { GeneratorRunner } from "@/components/toolbox/GeneratorRunner";
|
||||||
|
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||||
|
import { isLegacyTool } from "@/lib/toolbox/toolbox";
|
||||||
|
import { useToolboxTools } from "@/lib/toolbox/useToolboxTools";
|
||||||
|
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
|
||||||
|
|
||||||
|
interface InlineToolboxProps {
|
||||||
|
projectId: string;
|
||||||
|
// 当前章号:内联工具箱生成留痕挂本章(chapter_no=current)。
|
||||||
|
chapterNo: number;
|
||||||
|
// 把生成结果插入正文(追加到章末)。
|
||||||
|
onInsertText: (text: string) => void;
|
||||||
|
// 留痕:生成成功后 append 一批(透传给 GeneratorRunner)。
|
||||||
|
appendMessages: (turn: AiTurnInput) => void;
|
||||||
|
// 当前正文(编辑器 text)。透传给 GeneratorRunner,供 book-title 等据正文反推 brief。
|
||||||
|
manuscriptText?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器内联工具箱(不离开写作界面):列出生成器(含金手指),就地生成结构化预览,
|
||||||
|
// 文本类产物点「插入正文」直接填入写作处,可入库类走既有 ingest。legacy 生成器(世界观/
|
||||||
|
// 人设/大纲)仍走各自更丰富的独立页,不塞进内联面板。
|
||||||
|
export function InlineToolbox({
|
||||||
|
projectId,
|
||||||
|
chapterNo,
|
||||||
|
onInsertText,
|
||||||
|
appendMessages,
|
||||||
|
manuscriptText,
|
||||||
|
onClose,
|
||||||
|
}: InlineToolboxProps) {
|
||||||
|
const { status, tools } = useToolboxTools();
|
||||||
|
const [active, setActive] = useState<ToolDescriptorView | null>(null);
|
||||||
|
const openable = tools.filter((tool) => !isLegacyTool(tool));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[70vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<SectionHeader
|
||||||
|
title="工具箱"
|
||||||
|
description="就地生成,点「插入正文」直接填入写作处;可入库内容经一致性预检。"
|
||||||
|
/>
|
||||||
|
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭工具箱">
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<GeneratorRunner
|
||||||
|
projectId={projectId}
|
||||||
|
tool={active}
|
||||||
|
manuscriptText={manuscriptText}
|
||||||
|
appendMessages={appendMessages}
|
||||||
|
logChapterNo={chapterNo}
|
||||||
|
onClose={() => setActive(null)}
|
||||||
|
onInsertText={(text) => {
|
||||||
|
if (text.trim().length > 0) onInsertText(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : status === "loading" ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<ThinkingIndicator label="加载工具箱" className="text-xs text-cinnabar" />
|
||||||
|
</div>
|
||||||
|
) : status === "error" ? (
|
||||||
|
<StatusNote variant="danger" className="mt-3 text-xs" title="工具箱加载失败">
|
||||||
|
<p>请检查后端连接后重开。</p>
|
||||||
|
</StatusNote>
|
||||||
|
) : openable.length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-ink-soft">暂无可内联调用的生成器。</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{openable.map((tool) => (
|
||||||
|
<li key={tool.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(tool)}
|
||||||
|
className="w-full rounded border border-line bg-bg p-3 text-left transition-colors hover:border-cinnabar"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-ink">{tool.title}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-soft">{tool.subtitle}</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
apps/web/components/workbench/RefinePanel.tsx
Normal file
327
apps/web/components/workbench/RefinePanel.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Check, MessageSquarePlus, MessagesSquare, RotateCcw, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { TextArea } from "@/components/ui/TextArea";
|
||||||
|
import type { AiMessageView } from "@/lib/api/types";
|
||||||
|
import { useRefine } from "@/lib/workbench/useRefine";
|
||||||
|
import { useClarify } from "@/lib/workbench/useClarify";
|
||||||
|
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
|
||||||
|
import { acceptActionFor, type AcceptAction } from "@/lib/workbench/aiConversation";
|
||||||
|
import {
|
||||||
|
mergeInlineThreads,
|
||||||
|
readSegment,
|
||||||
|
toPendingView,
|
||||||
|
} from "@/lib/workbench/inlineConversation";
|
||||||
|
import type { AiTurnMessage, UseAiConversation } from "@/lib/workbench/useAiConversation";
|
||||||
|
import { ChoiceChips } from "./ChoiceChips";
|
||||||
|
import { ConversationThread } from "./ConversationThread";
|
||||||
|
|
||||||
|
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
|
||||||
|
const CLARIFY_MIN_CHARS = 10;
|
||||||
|
// 本面板内联气泡流展示的 kinds(本轮 refine 往复 + 其 clarify 反问;后者持久化时并入 refine 批)。
|
||||||
|
const REFINE_KINDS = ["refine", "clarify"] as const;
|
||||||
|
|
||||||
|
interface RefinePanelProps {
|
||||||
|
projectId: string;
|
||||||
|
chapterNo: number;
|
||||||
|
// 选中原文;打开即对其回炉一次。
|
||||||
|
original: string;
|
||||||
|
// 接受回填:把「当前选段」替换为该产出(调用方按选区坐标重锚落回草稿)。
|
||||||
|
onAccept: (refined: string) => void;
|
||||||
|
// 从气泡「回填选段」:按该版存档的原选段(meta.segment)内容重锚回填,drift-safe;返回是否成功。
|
||||||
|
onReaccept: (segment: string, refined: string) => boolean;
|
||||||
|
// Workbench 级持久化对话(本章留痕数据源 + fail-soft append)。
|
||||||
|
conversation: UseAiConversation;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版。
|
||||||
|
// 面板正文是一条内联对话气泡流(本章已落库往复 + 本轮进行中气泡实时汇入),输入框在底部,读起来像聊天。
|
||||||
|
// 原文只读、生成不写库(不变量 #3),accept 才动草稿。
|
||||||
|
export function RefinePanel({
|
||||||
|
projectId,
|
||||||
|
chapterNo,
|
||||||
|
original,
|
||||||
|
onAccept,
|
||||||
|
onReaccept,
|
||||||
|
conversation,
|
||||||
|
onClose,
|
||||||
|
}: RefinePanelProps) {
|
||||||
|
const { messages, status, ensureLoaded, appendMessages } = conversation;
|
||||||
|
const { status: refineStatus, versions, latest, refine, recommunicate } = useRefine();
|
||||||
|
const clarify = useClarify();
|
||||||
|
const [instruction, setInstruction] = useState("");
|
||||||
|
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
|
||||||
|
// 本轮进行中(尚未落库)的作者/反问气泡;生成开始时置入、结束或失败时清空 → 交由已落库气泡接管。
|
||||||
|
const [liveTurn, setLiveTurn] = useState<AiTurnMessage[] | null>(null);
|
||||||
|
const ranRef = useRef(false);
|
||||||
|
// 本面板一次会话 = 一个 thread;所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。
|
||||||
|
const threadId = useRef(crypto.randomUUID()).current;
|
||||||
|
const versionNoRef = useRef(0);
|
||||||
|
const streamRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 一版 ai 产出气泡:content=产出,meta 存版本号 + 原选段(每版都存,供任意版 re-accept 重锚)。
|
||||||
|
const aiRow = (refined: string): AiTurnMessage => {
|
||||||
|
versionNoRef.current += 1;
|
||||||
|
return {
|
||||||
|
role: "ai",
|
||||||
|
content: refined,
|
||||||
|
meta: { version_no: versionNoRef.current, segment: original },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开即对选段回炉一次(仅一次,避免重复请求);成功后留痕 [author(原选段), ai(润色)]。
|
||||||
|
useEffect(() => {
|
||||||
|
if (ranRef.current) return;
|
||||||
|
ranRef.current = true;
|
||||||
|
ensureLoaded();
|
||||||
|
setLiveTurn([{ role: "author", content: original }]);
|
||||||
|
void (async () => {
|
||||||
|
const v = await refine(projectId, chapterNo, original);
|
||||||
|
if (v) {
|
||||||
|
appendMessages({
|
||||||
|
threadId,
|
||||||
|
kind: "refine",
|
||||||
|
chapterNo,
|
||||||
|
messages: [{ role: "author", content: original }, aiRow(v.refined)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLiveTurn(null);
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectId, chapterNo, original, refine, ensureLoaded, appendMessages]);
|
||||||
|
|
||||||
|
const busy = refineStatus === "refining";
|
||||||
|
const clarifying = clarify.status === "checking";
|
||||||
|
const firstQuestion = clarify.decision?.questions[0] ?? null;
|
||||||
|
const asking = clarify.status === "asking" && firstQuestion !== null;
|
||||||
|
|
||||||
|
// 折入澄清答案(或原意见)后回炉,结束后留痕;clarifyTurns 非空时把缓冲的 Q&A 一并落库。
|
||||||
|
const runRecommunicate = (
|
||||||
|
finalInstruction: string,
|
||||||
|
clarifyTurns: AiTurnMessage[] | null,
|
||||||
|
): void => {
|
||||||
|
clarify.reset();
|
||||||
|
setPendingInstruction(null);
|
||||||
|
setInstruction("");
|
||||||
|
// clarify 分支:作者答复 IS 本轮 author 输入,不再另加 author(意见) 气泡;否则记 author(意见)。
|
||||||
|
const authorTurn: AiTurnMessage = { role: "author", content: finalInstruction };
|
||||||
|
setLiveTurn(clarifyTurns ?? [authorTurn]);
|
||||||
|
void (async () => {
|
||||||
|
const v = await recommunicate(projectId, chapterNo, finalInstruction);
|
||||||
|
if (v) {
|
||||||
|
const rows = clarifyTurns
|
||||||
|
? [...clarifyTurns, aiRow(v.refined)]
|
||||||
|
: [authorTurn, aiRow(v.refined)];
|
||||||
|
appendMessages({ threadId, kind: "refine", chapterNo, messages: rows });
|
||||||
|
}
|
||||||
|
setLiveTurn(null);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 「再改一版」:意见含糊/极短(或 force)先预检反问;否则直接回炉。
|
||||||
|
const onRecommunicate = async (force: boolean): Promise<void> => {
|
||||||
|
const trimmed = instruction.trim();
|
||||||
|
if (!trimmed || busy || clarifying) return;
|
||||||
|
if (force || needsClarifyGate(trimmed, CLARIFY_MIN_CHARS)) {
|
||||||
|
const segment = latest?.refined ?? original;
|
||||||
|
const vm = await clarify.check(projectId, chapterNo, segment, trimmed);
|
||||||
|
if (vm.needClarification && vm.questions.length > 0) {
|
||||||
|
setPendingInstruction(trimmed); // 记住原意见,等作者答完折进去
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runRecommunicate(trimmed, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A(原意见→反问→答复)+ 折进意见 → 回炉。
|
||||||
|
const onClarifyAnswer = (answer: string): void => {
|
||||||
|
const q = clarify.decision?.questions[0];
|
||||||
|
const question = q?.question ?? "";
|
||||||
|
const base = pendingInstruction ?? instruction.trim();
|
||||||
|
// 缓冲三条:原含糊意见(author) → AI 反问(ai, 选项进 meta) → 作者答复(author),随本轮产出一起落库。
|
||||||
|
const clarifyTurns: AiTurnMessage[] = [
|
||||||
|
{ role: "author", content: base },
|
||||||
|
{
|
||||||
|
role: "ai",
|
||||||
|
content: question,
|
||||||
|
meta: {
|
||||||
|
parent_kind: "refine",
|
||||||
|
options: (q?.options ?? []).map((o) => ({
|
||||||
|
label: o.label,
|
||||||
|
value: o.value,
|
||||||
|
})),
|
||||||
|
allow_free_text: q?.allowFreeText ?? false,
|
||||||
|
verification: clarify.decision?.verification ?? "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ role: "author", content: answer },
|
||||||
|
];
|
||||||
|
runRecommunicate(foldClarifications(base, [{ question, answer }]), clarifyTurns);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 本轮进行中气泡:liveTurn(已定的作者/反问)+ 反问待答时的作者意见 + 生成中的 ai 气泡(空正文=思考)。
|
||||||
|
const pending: AiMessageView[] = [];
|
||||||
|
{
|
||||||
|
let seq = 0;
|
||||||
|
const add = (
|
||||||
|
seed: Parameters<typeof toPendingView>[0],
|
||||||
|
): void => {
|
||||||
|
pending.push(toPendingView(seed, { threadId, chapterNo, kind: "refine", seq: seq++ }));
|
||||||
|
};
|
||||||
|
if (liveTurn) {
|
||||||
|
liveTurn.forEach((m, i) =>
|
||||||
|
add({ localId: `pt-${threadId}-${i}`, role: m.role, content: m.content }),
|
||||||
|
);
|
||||||
|
} else if (asking) {
|
||||||
|
add({
|
||||||
|
localId: `pt-${threadId}-ask`,
|
||||||
|
role: "author",
|
||||||
|
content: pendingInstruction ?? instruction.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (busy && liveTurn) {
|
||||||
|
add({ localId: `pt-${threadId}-answer`, role: "ai", content: "", streaming: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const threads = mergeInlineThreads(messages, chapterNo, REFINE_KINDS, pending);
|
||||||
|
const flatCount = threads.reduce((n, t) => n + t.messages.length, 0);
|
||||||
|
|
||||||
|
// 新气泡出现即滚到底(聊天视图);reduced-motion 下瞬时滚动即可,无动画。
|
||||||
|
useEffect(() => {
|
||||||
|
const el = streamRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [flatCount]);
|
||||||
|
|
||||||
|
const onBubbleAccept = (action: AcceptAction, m: AiMessageView): void => {
|
||||||
|
if (action === "refill_segment") onReaccept(readSegment(m.meta), m.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<SectionHeader
|
||||||
|
title="润色 / 再沟通"
|
||||||
|
description="对选中段回炉;不满意可追加意见让 AI 再改一版,满意后采用替换这段。"
|
||||||
|
/>
|
||||||
|
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭润色">
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内联对话气泡流:本章 refine 往复(已落库)+ 本轮进行中气泡,作者右 / AI 左,滚动区。 */}
|
||||||
|
<div
|
||||||
|
ref={streamRef}
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-relevant="additions"
|
||||||
|
aria-label="润色对话"
|
||||||
|
className="mt-3 max-h-[40vh] space-y-3 overflow-auto overscroll-contain rounded border border-line bg-bg/40 p-3"
|
||||||
|
>
|
||||||
|
{status === "error" ? (
|
||||||
|
<StatusNote variant="danger" className="text-xs" title="历史对话加载失败">
|
||||||
|
<Button onClick={conversation.reload} variant="secondary" size="sm" className="mt-1">
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
|
{flatCount === 0 && status !== "error" && refineStatus !== "error" ? (
|
||||||
|
<p className="py-2 text-center text-xs text-ink-soft">正在润色所选段落…</p>
|
||||||
|
) : (
|
||||||
|
threads.map((thread) => (
|
||||||
|
<ConversationThread
|
||||||
|
key={thread.threadId}
|
||||||
|
messages={thread.messages}
|
||||||
|
action={acceptActionFor(thread)}
|
||||||
|
onAccept={onBubbleAccept}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{refineStatus === "error" ? (
|
||||||
|
<StatusNote variant="danger" className="text-xs" title="回炉失败">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refine(projectId, chapterNo, original)}
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{asking && firstQuestion ? (
|
||||||
|
// role="status":AI 反问动态出现时向读屏播报(WCAG 4.1.3 Status Messages)。
|
||||||
|
<div role="status" className="rounded border border-cinnabar/40 bg-bg p-3">
|
||||||
|
<p className="mb-2 text-2xs text-ink-soft">AI 想先跟你确认一下:</p>
|
||||||
|
<ChoiceChips
|
||||||
|
question={firstQuestion.question}
|
||||||
|
options={firstQuestion.options}
|
||||||
|
allowFreeText={firstQuestion.allowFreeText}
|
||||||
|
onPick={onClarifyAnswer}
|
||||||
|
onFreeText={onClarifyAnswer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<label className="block">
|
||||||
|
<span className="sr-only">再沟通意见</span>
|
||||||
|
<TextArea
|
||||||
|
value={instruction}
|
||||||
|
onChange={(e) => setInstruction(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => void onRecommunicate(false)}
|
||||||
|
disabled={busy || clarifying || instruction.trim().length === 0}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
再改一版
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void onRecommunicate(true)}
|
||||||
|
disabled={busy || clarifying || instruction.trim().length === 0}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="让 AI 先反问、给方向选项再改"
|
||||||
|
>
|
||||||
|
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
|
||||||
|
帮我理清方向
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => latest && onAccept(latest.refined)}
|
||||||
|
disabled={busy || !latest}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" aria-hidden="true" />
|
||||||
|
采用(替换这段)
|
||||||
|
</Button>
|
||||||
|
{busy || clarifying ? (
|
||||||
|
<ThinkingIndicator
|
||||||
|
label={clarifying ? "琢磨要不要反问" : "生成中"}
|
||||||
|
className="text-xs text-cinnabar"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{versions.length > 0 ? (
|
||||||
|
<span className="ml-auto rounded bg-bg px-1.5 py-0.5 text-2xs tabular-nums text-ink-soft">
|
||||||
|
已 {versions.length} 版
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,20 +3,26 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useId, useRef, useState, type RefObject } from "react";
|
import { useEffect, useId, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookMarked,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
FileText,
|
FileText,
|
||||||
Library,
|
HelpCircle,
|
||||||
|
MessagesSquare,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
PenLine,
|
PenLine,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
|
Wand2,
|
||||||
|
WrapText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { Drawer } from "@/components/Drawer";
|
import { Drawer } from "@/components/Drawer";
|
||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { useToast } from "@/components/Toast";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
import { StatusNote } from "@/components/ui/StatusNote";
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
@@ -29,11 +35,31 @@ import {
|
|||||||
WORKBENCH_CHAPTER_NO,
|
WORKBENCH_CHAPTER_NO,
|
||||||
type ChapterEntry,
|
type ChapterEntry,
|
||||||
} from "@/lib/workbench/chapter";
|
} from "@/lib/workbench/chapter";
|
||||||
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
|
import {
|
||||||
import { buttonClass } from "@/lib/ui/variants";
|
composeDirective,
|
||||||
|
PLOT_PRESETS,
|
||||||
|
STYLE_PRESETS,
|
||||||
|
type Preset,
|
||||||
|
} from "@/lib/workbench/directive";
|
||||||
|
import { applyRefinement } from "@/lib/workbench/refineApply";
|
||||||
|
import { buttonClass, focusRing } from "@/lib/ui/variants";
|
||||||
import { ChapterList, ChapterListContent } from "./ChapterList";
|
import { ChapterList, ChapterListContent } from "./ChapterList";
|
||||||
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
||||||
import { Editor } from "./Editor";
|
import { Editor, type EditorSelection } from "./Editor";
|
||||||
|
import { RefinePanel } from "./RefinePanel";
|
||||||
|
import { ContinuePanel } from "./ContinuePanel";
|
||||||
|
import { InlineToolbox } from "./InlineToolbox";
|
||||||
|
import { ChapterRewritePanel } from "./ChapterRewritePanel";
|
||||||
|
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
|
||||||
|
import {
|
||||||
|
AiConversationDrawer,
|
||||||
|
AI_CONVERSATION_ENABLED,
|
||||||
|
} from "./AiConversationDrawer";
|
||||||
|
import { GenrePicker } from "./GenrePicker";
|
||||||
|
import { GENRES } from "@/lib/wizard/wizard";
|
||||||
|
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
|
||||||
|
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
||||||
|
import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse";
|
||||||
|
|
||||||
interface WorkbenchProps {
|
interface WorkbenchProps {
|
||||||
project: ProjectResponse;
|
project: ProjectResponse;
|
||||||
@@ -64,11 +90,32 @@ export function Workbench({
|
|||||||
// 本章指令(T4-b):自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
|
// 本章指令(T4-b):自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
|
||||||
const [directive, setDirective] = useState("");
|
const [directive, setDirective] = useState("");
|
||||||
const [presetIds, setPresetIds] = useState<string[]>([]);
|
const [presetIds, setPresetIds] = useState<string[]>([]);
|
||||||
|
// 选区级 AI 动作(润色/再沟通):编辑器上报的最新选区 + 结果卡开合。
|
||||||
|
const [selection, setSelection] = useState<EditorSelection | null>(null);
|
||||||
|
const [refineOpen, setRefineOpen] = useState(false);
|
||||||
|
const [continueOpen, setContinueOpen] = useState(false);
|
||||||
|
const [toolboxOpen, setToolboxOpen] = useState(false);
|
||||||
|
// WFW-8:整章「再沟通/重写」结果卡开合(意见 → 流式重写一版 → 版本栈 → 接受替换整章)。
|
||||||
|
const [rewriteOpen, setRewriteOpen] = useState(false);
|
||||||
|
// WFW-7 / P1-5:首次写章的极轻 genre 采集门。项目无题材 → 正文上方常驻一条内联「题材」选择条
|
||||||
|
// (非弹窗),作者点一个题材再写;仅临时并入本次指令、不落库,避免无题材 slop,且第一次发送不被打断。
|
||||||
|
const genreGate = useGenreGate(project.genre ?? null);
|
||||||
|
// WFW-6 上下文速查抽屉:加法式、flag 灰度;与三张 AI 结果卡互不干扰。
|
||||||
|
const [contextOpen, setContextOpen] = useState(false);
|
||||||
|
// AC-3 AI 对话抽屉:Workbench 级单实例(append 即使抽屉关着也能触发);flag 灰度。
|
||||||
|
const [conversationOpen, setConversationOpen] = useState(false);
|
||||||
|
const conversation = useAiConversation(project.id, chapterNo);
|
||||||
|
// P2-2:桌面「目录」列可收起(记忆状态);收起时把宽让给正文,窄轨留一个展开入口。
|
||||||
|
const chapterList = useChapterListCollapse();
|
||||||
|
const conversationTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const toast = useToast();
|
||||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||||
const stream = useDraftStream();
|
const stream = useDraftStream();
|
||||||
const lastStreamText = useRef("");
|
const lastStreamText = useRef("");
|
||||||
|
const canRefine = selection !== null && selection.text.trim().length > 0;
|
||||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const contextTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
|
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,12 +132,23 @@ export function Workbench({
|
|||||||
}
|
}
|
||||||
}, [stream.state.phase, stream.state.text, autosave]);
|
}, [stream.state.phase, stream.state.text, autosave]);
|
||||||
|
|
||||||
|
// 题材 + 三槽指令合并为一条写章指令(题材前置);effectiveGenre 无 → 仅原指令。
|
||||||
|
const startWrite = (effectiveGenre: string | null): void => {
|
||||||
|
const base = composeDirective(directive, presetIds);
|
||||||
|
const merged = [toGenreDirective(effectiveGenre), base]
|
||||||
|
.filter((part) => part.length > 0)
|
||||||
|
.join(";");
|
||||||
|
void stream.start(project.id, chapterNo, merged);
|
||||||
|
};
|
||||||
|
|
||||||
const onWrite = (): void => {
|
const onWrite = (): void => {
|
||||||
void stream.start(
|
// 无题材且未点选 → 引导先点上方常驻「题材」条再写(HITL:不静默生成无题材 slop)。
|
||||||
project.id,
|
// 选择条一直可见,理想路径是发送前已选好,第一次「写本章」即顺滑流式、不被弹窗打断。
|
||||||
chapterNo,
|
if (genreGate.needsGenre && genreGate.chosenGenre === null) {
|
||||||
composeDirective(directive, presetIds),
|
toast("先在上方选个题材,AI 写得更贴合、少套话。", "info");
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
startWrite(genreGate.effectiveGenre);
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePreset = (id: string): void => {
|
const togglePreset = (id: string): void => {
|
||||||
@@ -104,6 +162,111 @@ export function Workbench({
|
|||||||
autosave.onChange(value);
|
autosave.onChange(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 接受润色/再沟通产出:按内容重锚把选段替换为产出并落草稿;原文已变则提示重选(不盲替换)。
|
||||||
|
const onAcceptRefine = (refined: string): void => {
|
||||||
|
if (selection) {
|
||||||
|
const next = applyRefinement(text, selection.text, refined, {
|
||||||
|
start: selection.start,
|
||||||
|
end: selection.end,
|
||||||
|
});
|
||||||
|
if (next === null) {
|
||||||
|
toast("正文已改动,找不到原选段,请重新选择后再润色。", "error");
|
||||||
|
} else {
|
||||||
|
setText(next);
|
||||||
|
autosave.onChange(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRefineOpen(false);
|
||||||
|
setSelection(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。
|
||||||
|
// 润色以选区为对象:无选区时不静默失败,给出可见提示引导先选段(P0-4,替代 hover-only title)。
|
||||||
|
const openRefine = (): void => {
|
||||||
|
if (!canRefine) {
|
||||||
|
toast("先在正文里选中一段再润色", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setContinueOpen(false);
|
||||||
|
setToolboxOpen(false);
|
||||||
|
setRewriteOpen(false);
|
||||||
|
setRefineOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 续写前先 flush 自动保存,确保 continue 读到的是最新草稿(with_prior_chapter 读库)。
|
||||||
|
const openContinue = (): void => {
|
||||||
|
autosave.flush();
|
||||||
|
setRefineOpen(false);
|
||||||
|
setToolboxOpen(false);
|
||||||
|
setRewriteOpen(false);
|
||||||
|
setContinueOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openToolbox = (): void => {
|
||||||
|
setRefineOpen(false);
|
||||||
|
setContinueOpen(false);
|
||||||
|
setRewriteOpen(false);
|
||||||
|
setToolboxOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRewrite = (): void => {
|
||||||
|
setRefineOpen(false);
|
||||||
|
setContinueOpen(false);
|
||||||
|
setToolboxOpen(false);
|
||||||
|
setRewriteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 接受某一版整章重写 → 替换整章正文并落草稿(HITL,作者点选才落)。
|
||||||
|
const onAcceptRewrite = (next: string): void => {
|
||||||
|
setText(next);
|
||||||
|
autosave.onChange(next);
|
||||||
|
setRewriteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 追加一段文本到章末并落草稿(续写候选 / 工具箱产物共用)。
|
||||||
|
const appendToDraft = (chunk: string): void => {
|
||||||
|
const base = text.trimEnd();
|
||||||
|
const next = base.length > 0 ? `${base}\n\n${chunk}` : chunk;
|
||||||
|
setText(next);
|
||||||
|
autosave.onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInsertContinuation = (candidate: string): void => {
|
||||||
|
appendToDraft(candidate);
|
||||||
|
setContinueOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从「AI 对话」历史再接受(复用 DRAFT-only HITL:只回填编辑器草稿,不绕开验收事务——不变量 #3)。
|
||||||
|
// 返回 true=成功则抽屉自关,露出改动后的编辑器 + 成功 toast;refine 找不到原选段返回 false + 漂移提示。
|
||||||
|
const onReplaceChapterFromHistory = (content: string): boolean => {
|
||||||
|
setText(content);
|
||||||
|
autosave.onChange(content);
|
||||||
|
toast("已替换整章。", "success");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAppendDraftFromHistory = (content: string): boolean => {
|
||||||
|
appendToDraft(content);
|
||||||
|
toast("已加到章末。", "success");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRefillSegmentFromHistory = (
|
||||||
|
segment: string,
|
||||||
|
refined: string,
|
||||||
|
): boolean => {
|
||||||
|
// 合成零区间强制 slice 校验失败 → 走 indexOf 按存档的原选段重锚;找不到则不盲替换。
|
||||||
|
const next = applyRefinement(text, segment, refined, { start: 0, end: 0 });
|
||||||
|
if (next === null) {
|
||||||
|
toast("正文已改动,找不到原选段,无法替换。", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setText(next);
|
||||||
|
autosave.onChange(next);
|
||||||
|
toast("已替换这段。", "success");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
|
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
|
||||||
const wordCount = text.replace(/\s+/g, "").length;
|
const wordCount = text.replace(/\s+/g, "").length;
|
||||||
const closePanel = (): void => setMobilePanel(null);
|
const closePanel = (): void => setMobilePanel(null);
|
||||||
@@ -132,11 +295,19 @@ export function Workbench({
|
|||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
activeNav="write"
|
activeNav="write"
|
||||||
>
|
>
|
||||||
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] xl:grid-cols-[12rem_1fr_18rem]">
|
<div
|
||||||
|
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${
|
||||||
|
chapterList.collapsed
|
||||||
|
? "xl:grid-cols-[2.75rem_1fr_18rem]"
|
||||||
|
: "xl:grid-cols-[12rem_1fr_18rem]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<ChapterList
|
<ChapterList
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
chapters={chapters}
|
chapters={chapters}
|
||||||
currentChapterNo={chapterNo}
|
currentChapterNo={chapterNo}
|
||||||
|
collapsed={chapterList.collapsed}
|
||||||
|
onToggle={chapterList.toggle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="flex min-w-0 flex-col bg-bg">
|
<section className="flex min-w-0 flex-col bg-bg">
|
||||||
@@ -147,17 +318,85 @@ export function Workbench({
|
|||||||
chapterTriggerRef={chapterTriggerRef}
|
chapterTriggerRef={chapterTriggerRef}
|
||||||
assistantTriggerRef={assistantTriggerRef}
|
assistantTriggerRef={assistantTriggerRef}
|
||||||
/>
|
/>
|
||||||
|
{genreGate.needsGenre ? (
|
||||||
|
// 项目无题材:正文上方常驻内联题材条(非弹窗),发送前选好即顺滑写章(P1-5)。
|
||||||
|
<GenrePicker
|
||||||
|
genres={GENRES}
|
||||||
|
value={genreGate.chosenGenre}
|
||||||
|
onPick={genreGate.setChosenGenre}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<DirectivePanel
|
<DirectivePanel
|
||||||
directive={directive}
|
directive={directive}
|
||||||
onDirectiveChange={setDirective}
|
onDirectiveChange={setDirective}
|
||||||
presetIds={presetIds}
|
presetIds={presetIds}
|
||||||
onTogglePreset={togglePreset}
|
onTogglePreset={togglePreset}
|
||||||
|
defaultOpen={initialText.trim().length === 0}
|
||||||
|
streaming={stream.isStreaming}
|
||||||
|
onWrite={onWrite}
|
||||||
|
onStop={stream.stop}
|
||||||
/>
|
/>
|
||||||
|
<AiVerbsRow
|
||||||
|
streaming={stream.isStreaming}
|
||||||
|
onContinue={openContinue}
|
||||||
|
onRefineSelection={openRefine}
|
||||||
|
onRewrite={openRewrite}
|
||||||
|
onToolbox={openToolbox}
|
||||||
|
/>
|
||||||
|
{refineOpen && canRefine && selection ? (
|
||||||
|
<RefinePanel
|
||||||
|
projectId={project.id}
|
||||||
|
chapterNo={chapterNo}
|
||||||
|
original={selection.text}
|
||||||
|
onAccept={onAcceptRefine}
|
||||||
|
onReaccept={onRefillSegmentFromHistory}
|
||||||
|
conversation={conversation}
|
||||||
|
onClose={() => setRefineOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{continueOpen ? (
|
||||||
|
<ContinuePanel
|
||||||
|
projectId={project.id}
|
||||||
|
chapterNo={chapterNo}
|
||||||
|
onInsert={onInsertContinuation}
|
||||||
|
appendMessages={conversation.appendMessages}
|
||||||
|
onClose={() => setContinueOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{toolboxOpen ? (
|
||||||
|
<InlineToolbox
|
||||||
|
projectId={project.id}
|
||||||
|
chapterNo={chapterNo}
|
||||||
|
manuscriptText={text}
|
||||||
|
appendMessages={conversation.appendMessages}
|
||||||
|
onInsertText={(chunk) => {
|
||||||
|
appendToDraft(chunk);
|
||||||
|
setToolboxOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setToolboxOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{rewriteOpen ? (
|
||||||
|
<ChapterRewritePanel
|
||||||
|
projectId={project.id}
|
||||||
|
chapterNo={chapterNo}
|
||||||
|
currentDraft={text}
|
||||||
|
onAccept={onAcceptRewrite}
|
||||||
|
conversation={conversation}
|
||||||
|
onClose={() => setRewriteOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="flex-1 overflow-auto px-6 py-8">
|
<div className="flex-1 overflow-auto px-6 py-8">
|
||||||
|
{chapters.length === 0 ? (
|
||||||
|
<StatusNote variant="info" className="mb-4">
|
||||||
|
尚无大纲,将按设定自由生成。
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
<Editor
|
<Editor
|
||||||
value={text}
|
value={text}
|
||||||
onChange={onEditorChange}
|
onChange={onEditorChange}
|
||||||
streaming={stream.isStreaming}
|
streaming={stream.isStreaming}
|
||||||
|
onSelectionChange={setSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -169,8 +408,11 @@ export function Workbench({
|
|||||||
streaming={stream.isStreaming}
|
streaming={stream.isStreaming}
|
||||||
streamError={stream.state.error}
|
streamError={stream.state.error}
|
||||||
liveMessage={liveMessage}
|
liveMessage={liveMessage}
|
||||||
onWrite={onWrite}
|
|
||||||
onStop={stream.stop}
|
onStop={stream.stop}
|
||||||
|
onOpenContext={() => setContextOpen(true)}
|
||||||
|
contextTriggerRef={contextTriggerRef}
|
||||||
|
onOpenConversation={() => setConversationOpen(true)}
|
||||||
|
conversationTriggerRef={conversationTriggerRef}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -195,11 +437,33 @@ export function Workbench({
|
|||||||
open={mobilePanel === "assistant"}
|
open={mobilePanel === "assistant"}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
side="right"
|
side="right"
|
||||||
label="本章助手"
|
label="本章参考"
|
||||||
triggerRef={assistantTriggerRef}
|
triggerRef={assistantTriggerRef}
|
||||||
>
|
>
|
||||||
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
|
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
{/* WFW-6 上下文速查:视口无关的右侧 slide-over;旧侧栏/全宽页路由保留、可回滚。 */}
|
||||||
|
<ContextDrawer
|
||||||
|
projectId={project.id}
|
||||||
|
open={contextOpen}
|
||||||
|
onClose={() => setContextOpen(false)}
|
||||||
|
triggerRef={contextTriggerRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* AC-3 AI 对话:右侧 slide-over 气泡视图;从历史再接受只回填草稿,不绕验收事务。flag 灰度。 */}
|
||||||
|
{AI_CONVERSATION_ENABLED ? (
|
||||||
|
<AiConversationDrawer
|
||||||
|
open={conversationOpen}
|
||||||
|
onClose={() => setConversationOpen(false)}
|
||||||
|
conversation={conversation}
|
||||||
|
currentChapterNo={chapterNo}
|
||||||
|
onReplaceChapter={onReplaceChapterFromHistory}
|
||||||
|
onAppendDraft={onAppendDraftFromHistory}
|
||||||
|
onRefillSegment={onRefillSegmentFromHistory}
|
||||||
|
triggerRef={conversationTriggerRef}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,58 +515,271 @@ interface DirectivePanelProps {
|
|||||||
onDirectiveChange: (value: string) => void;
|
onDirectiveChange: (value: string) => void;
|
||||||
presetIds: readonly string[];
|
presetIds: readonly string[];
|
||||||
onTogglePreset: (id: string) => void;
|
onTogglePreset: (id: string) => void;
|
||||||
|
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
|
||||||
|
defaultOpen: boolean;
|
||||||
|
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
|
||||||
|
streaming: boolean;
|
||||||
|
onWrite: () => void;
|
||||||
|
onStop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本章指令面板(T4-b):可折叠的 textarea + 风格快捷预设 chips。
|
// 本章写作指令面板(P0-2 提升):正文正上方常驻的「和 AI 说话」输入条。
|
||||||
// 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成。
|
// 标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;空章默认展开露出输入框。
|
||||||
|
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
|
||||||
function DirectivePanel({
|
function DirectivePanel({
|
||||||
directive,
|
directive,
|
||||||
onDirectiveChange,
|
onDirectiveChange,
|
||||||
presetIds,
|
presetIds,
|
||||||
onTogglePreset,
|
onTogglePreset,
|
||||||
|
defaultOpen,
|
||||||
|
streaming,
|
||||||
|
onWrite,
|
||||||
|
onStop,
|
||||||
}: DirectivePanelProps) {
|
}: DirectivePanelProps) {
|
||||||
const panelId = useId();
|
const panelId = useId();
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
|
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
|
||||||
return (
|
return (
|
||||||
<details className="group border-b border-line bg-panel px-4 py-3 sm:px-6">
|
<section className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||||
<summary
|
<div className="flex items-center justify-between gap-3">
|
||||||
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
aria-controls={panelId}
|
aria-controls={panelId}
|
||||||
|
className={`flex min-w-0 cursor-pointer items-center gap-2 rounded font-serif text-base text-ink hover:text-cinnabar ${focusRing}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 font-serif text-base text-ink">
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180"
|
className={`h-4 w-4 shrink-0 text-ink-soft transition-transform ${
|
||||||
|
open ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
本章写作指令
|
<span className="truncate">告诉 AI 这章想怎么写</span>
|
||||||
</span>
|
</button>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{activeCount > 0 ? (
|
||||||
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||||
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
|
{activeCount} 项指令
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
) : null}
|
||||||
|
{/* 常驻「发送键」= 写本章:始终可见(折叠时也在),逐字流式写进正文。带可见范围提示(P1-4)。 */}
|
||||||
|
{streaming ? (
|
||||||
|
<Button onClick={onStop} variant="danger" size="sm">
|
||||||
|
<Square className="h-4 w-4" aria-hidden="true" />
|
||||||
|
停
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onWrite} variant="primary" size="sm">
|
||||||
|
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||||
|
写本章
|
||||||
|
<span className="text-2xs font-normal text-panel/75">
|
||||||
|
整章·从头写
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<HitlHelp />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-ink-soft">
|
||||||
|
AI 给草稿,你决定采不采用,原文永远你说了算。
|
||||||
|
</p>
|
||||||
|
{open ? (
|
||||||
<div id={panelId} className="mt-3 space-y-3">
|
<div id={panelId} className="mt-3 space-y-3">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="写作指令"
|
title="你的写作要求"
|
||||||
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
|
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
|
||||||
|
/>
|
||||||
|
<PresetGroup
|
||||||
|
label="文风"
|
||||||
|
presets={STYLE_PRESETS}
|
||||||
|
presetIds={presetIds}
|
||||||
|
onToggle={onTogglePreset}
|
||||||
|
/>
|
||||||
|
<PresetGroup
|
||||||
|
label="剧情需求"
|
||||||
|
presets={PLOT_PRESETS}
|
||||||
|
presetIds={presetIds}
|
||||||
|
onToggle={onTogglePreset}
|
||||||
/>
|
/>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="sr-only">本章指令</span>
|
<span className="mb-1 block text-sm text-ink-soft">自己的要求</span>
|
||||||
<TextArea
|
<TextArea
|
||||||
value={directive}
|
value={directive}
|
||||||
onChange={(e) => onDirectiveChange(e.target.value)}
|
onChange={(e) => onDirectiveChange(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
|
placeholder="写点要求,AI 更懂你(留空则按大纲写)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
|
{activeCount > 0 ? (
|
||||||
{STYLE_PRESETS.map((preset) => {
|
<StatusNote variant="info">
|
||||||
|
写本章时会把已选文风/剧情预设和你的要求合并进本次生成请求。
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiVerbsRowProps {
|
||||||
|
streaming: boolean;
|
||||||
|
onContinue: () => void;
|
||||||
|
onRefineSelection: () => void;
|
||||||
|
onRewrite: () => void;
|
||||||
|
onToolbox: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「或让 AI:」次级动作行(P1-1):常驻贴在中央输入条下方,把续写/润色/整章重写/工具箱
|
||||||
|
// 归为「同一个 AI 的其它用法」(act-on-chapter 与导航分家)。每个动词带可见范围提示(P1-4)。
|
||||||
|
// 生成中禁用(避免与流式写章并发),触发逻辑沿用 Workbench 既有 open* 回调(仅搬家、不改行为)。
|
||||||
|
function AiVerbsRow({
|
||||||
|
streaming,
|
||||||
|
onContinue,
|
||||||
|
onRefineSelection,
|
||||||
|
onRewrite,
|
||||||
|
onToolbox,
|
||||||
|
}: AiVerbsRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line bg-panel px-4 py-2 sm:px-6">
|
||||||
|
<span className="text-xs text-ink-soft">或让 AI:</span>
|
||||||
|
<VerbButton
|
||||||
|
icon={WrapText}
|
||||||
|
label="续写"
|
||||||
|
hint="接着往下写"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={onContinue}
|
||||||
|
/>
|
||||||
|
<VerbButton
|
||||||
|
icon={Wand2}
|
||||||
|
label="润色选段"
|
||||||
|
hint="选中段·打磨"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={onRefineSelection}
|
||||||
|
/>
|
||||||
|
<VerbButton
|
||||||
|
icon={RefreshCw}
|
||||||
|
label="整章重写"
|
||||||
|
hint="整章·基于现有重写"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={onRewrite}
|
||||||
|
/>
|
||||||
|
<VerbButton
|
||||||
|
icon={Sparkles}
|
||||||
|
label="工具箱"
|
||||||
|
disabled={streaming}
|
||||||
|
onClick={onToolbox}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerbButtonProps {
|
||||||
|
icon: typeof WrapText;
|
||||||
|
label: string;
|
||||||
|
// 可见(非 hover-only)范围提示:让作者点前就能预判对草稿的影响(P1-4)。工具箱无范围提示。
|
||||||
|
hint?: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button onClick={onClick} disabled={disabled} variant="secondary" size="sm">
|
||||||
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{label}
|
||||||
|
{hint ? <span className="text-2xs text-ink-soft">{hint}</span> : null}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach)——解释「和 AI 说话」的闭环。
|
||||||
|
// 仿 AiToolbarMoreMenu:按钮 + 轻量弹层,Esc / 点击外部关闭,还原焦点。
|
||||||
|
function HitlHelp() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const popId = useId();
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPointerDown = (e: PointerEvent): void => {
|
||||||
|
const target = e.target;
|
||||||
|
if (target instanceof Node && !rootRef.current?.contains(target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={popId}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={buttonClass({
|
||||||
|
variant: "ghost",
|
||||||
|
size: "sm",
|
||||||
|
className: "gap-1 text-xs text-ink-soft",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" aria-hidden="true" />
|
||||||
|
这是什么
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
id={popId}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="怎么和 AI 说话"
|
||||||
|
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded border border-line bg-panel p-3 text-sm text-ink-soft shadow-paper"
|
||||||
|
>
|
||||||
|
<p className="mb-1 font-serif text-sm text-ink">怎么和 AI 说话</p>
|
||||||
|
<p>
|
||||||
|
在这条写下要求,点「写本章」——AI 会把草稿逐字写进正文。草稿随时能改;
|
||||||
|
满意了去「审稿」验收。原文永远你说了算。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetGroupProps {
|
||||||
|
label: string;
|
||||||
|
presets: readonly Preset[];
|
||||||
|
presetIds: readonly string[];
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一组预设 chips(文风 / 剧情需求):点选 toggle,选中高亮。id 统一进 presetIds。
|
||||||
|
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-sm text-ink-soft">{label}</p>
|
||||||
|
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
|
||||||
|
{presets.map((preset) => {
|
||||||
const active = presetIds.includes(preset.id);
|
const active = presetIds.includes(preset.id);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
onClick={() => onTogglePreset(preset.id)}
|
onClick={() => onToggle(preset.id)}
|
||||||
variant={active ? "outline" : "secondary"}
|
variant={active ? "outline" : "secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@@ -311,13 +788,7 @@ function DirectivePanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{activeCount > 0 ? (
|
|
||||||
<StatusNote variant="info">
|
|
||||||
写本章时会把已选预设和自由指令合并进本次生成请求。
|
|
||||||
</StatusNote>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +817,8 @@ function StreamErrorNote({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 底栏瘦身(P1-1):act-on-chapter 动词与纯导航都已上移/移除,底栏只余
|
||||||
|
// 状态(字数/保存/播报)+ 流式时的「停」+ 唯一强调的「审稿」前进 CTA + 速查/对话记录抽屉入口。
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
chapterNo: number;
|
chapterNo: number;
|
||||||
@@ -356,8 +829,14 @@ interface ToolbarProps {
|
|||||||
streamError: { code: string; message: string } | null;
|
streamError: { code: string; message: string } | null;
|
||||||
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
|
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
|
||||||
liveMessage: string;
|
liveMessage: string;
|
||||||
onWrite: () => void;
|
// 流式时的「停」:底栏 sticky,滚到长正文下方也够得着(中央条会滚出视野)。
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
|
// 打开上下文速查抽屉(WFW-6)。
|
||||||
|
onOpenContext: () => void;
|
||||||
|
contextTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
// 打开对话记录抽屉(AC-3)。
|
||||||
|
onOpenConversation: () => void;
|
||||||
|
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toolbar({
|
function Toolbar({
|
||||||
@@ -369,8 +848,11 @@ function Toolbar({
|
|||||||
streaming,
|
streaming,
|
||||||
streamError,
|
streamError,
|
||||||
liveMessage,
|
liveMessage,
|
||||||
onWrite,
|
|
||||||
onStop,
|
onStop,
|
||||||
|
onOpenContext,
|
||||||
|
contextTriggerRef,
|
||||||
|
onOpenConversation,
|
||||||
|
conversationTriggerRef,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
||||||
@@ -382,12 +864,7 @@ function Toolbar({
|
|||||||
<Square className="h-4 w-4" aria-hidden="true" />
|
<Square className="h-4 w-4" aria-hidden="true" />
|
||||||
停
|
停
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : null}
|
||||||
<Button onClick={onWrite} variant="primary" size="sm">
|
|
||||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
|
||||||
写本章
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
|
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
|
||||||
// 易变字数 aria-hidden,仅供视觉,不逐 token 打扰屏读。
|
// 易变字数 aria-hidden,仅供视觉,不逐 token 打扰屏读。
|
||||||
@@ -409,24 +886,31 @@ function Toolbar({
|
|||||||
<span className="sr-only" role="status" aria-live="polite">
|
<span className="sr-only" role="status" aria-live="polite">
|
||||||
{liveMessage}
|
{liveMessage}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
{CONTEXT_DRAWER_ENABLED ? (
|
||||||
href={`/projects/${projectId}/outline`}
|
<Button
|
||||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
ref={contextTriggerRef}
|
||||||
|
onClick={onOpenContext}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
|
||||||
>
|
>
|
||||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
<BookMarked className="h-4 w-4" aria-hidden="true" />
|
||||||
大纲
|
速查
|
||||||
</Link>
|
</Button>
|
||||||
<Link
|
) : null}
|
||||||
href={`/projects/${projectId}/foreshadow`}
|
{AI_CONVERSATION_ENABLED ? (
|
||||||
className={buttonClass({
|
<Button
|
||||||
variant: "secondary",
|
ref={conversationTriggerRef}
|
||||||
size: "sm",
|
onClick={onOpenConversation}
|
||||||
className: "hidden sm:inline-flex",
|
variant="secondary"
|
||||||
})}
|
size="sm"
|
||||||
|
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
|
||||||
>
|
>
|
||||||
<Library className="h-4 w-4" aria-hidden="true" />
|
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
|
||||||
伏笔
|
对话记录
|
||||||
</Link>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA(导航去重后仅此一处强调,左栏另有入口)。 */}
|
||||||
<Link
|
<Link
|
||||||
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
||||||
className={buttonClass({ variant: "outline", size: "sm" })}
|
className={buttonClass({ variant: "outline", size: "sm" })}
|
||||||
|
|||||||
65
apps/web/lib/a11y/useRestoreFocus.test.ts
Normal file
65
apps/web/lib/a11y/useRestoreFocus.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
|
||||||
|
import { useRestoreFocus } from "./useRestoreFocus";
|
||||||
|
|
||||||
|
const appended: HTMLElement[] = [];
|
||||||
|
|
||||||
|
function mountEl<T extends HTMLElement>(el: T): T {
|
||||||
|
document.body.appendChild(el);
|
||||||
|
appended.push(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const el of appended.splice(0)) el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useRestoreFocus(CR-H11)", () => {
|
||||||
|
it("关闭时把焦点还给 triggerRef", () => {
|
||||||
|
const trigger = mountEl(document.createElement("button"));
|
||||||
|
const other = mountEl(document.createElement("input"));
|
||||||
|
const ref: RefObject<HTMLElement | null> = { current: trigger };
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ open }) => useRestoreFocus(open, ref),
|
||||||
|
{ initialProps: { open: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
other.focus();
|
||||||
|
expect(document.activeElement).toBe(other);
|
||||||
|
|
||||||
|
rerender({ open: false });
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("从未打开过:关闭态不抢焦点(wasOpen 门闩)", () => {
|
||||||
|
const trigger = mountEl(document.createElement("button"));
|
||||||
|
const other = mountEl(document.createElement("input"));
|
||||||
|
const ref: RefObject<HTMLElement | null> = { current: trigger };
|
||||||
|
|
||||||
|
renderHook(({ open }) => useRestoreFocus(open, ref), {
|
||||||
|
initialProps: { open: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
other.focus();
|
||||||
|
expect(document.activeElement).toBe(other);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggerRef 为 null / undefined 时关闭不抛错", () => {
|
||||||
|
const nullRef: RefObject<HTMLElement | null> = { current: null };
|
||||||
|
const nullCase = renderHook(
|
||||||
|
({ open }) => useRestoreFocus(open, nullRef),
|
||||||
|
{ initialProps: { open: true } },
|
||||||
|
);
|
||||||
|
expect(() => nullCase.rerender({ open: false })).not.toThrow();
|
||||||
|
|
||||||
|
const undefCase = renderHook(
|
||||||
|
({ open }) => useRestoreFocus(open, undefined),
|
||||||
|
{ initialProps: { open: true } },
|
||||||
|
);
|
||||||
|
expect(() => undefCase.rerender({ open: false })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
24
apps/web/lib/a11y/useRestoreFocus.ts
Normal file
24
apps/web/lib/a11y/useRestoreFocus.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
|
|
||||||
|
// 弹层(Drawer / ContextDrawer)关闭后把焦点还给触发元素(a11y 焦点管理,CR-H11)。
|
||||||
|
// wasOpenRef 门闩:仅在「曾打开过」后的关闭才还原,避免从未打开时误抢焦点。
|
||||||
|
// null-safe:triggerRef 或其 current 为空时静默跳过。
|
||||||
|
export function useRestoreFocus(
|
||||||
|
open: boolean,
|
||||||
|
triggerRef?: RefObject<HTMLElement | null>,
|
||||||
|
): void {
|
||||||
|
const wasOpenRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
if (wasOpenRef.current) {
|
||||||
|
wasOpenRef.current = false;
|
||||||
|
triggerRef?.current?.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wasOpenRef.current = true;
|
||||||
|
}, [open, triggerRef]);
|
||||||
|
}
|
||||||
571
apps/web/lib/api/schema.d.ts
vendored
571
apps/web/lib/api/schema.d.ts
vendored
@@ -62,7 +62,10 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/** List Projects */
|
/**
|
||||||
|
* List Projects
|
||||||
|
* @description 作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。
|
||||||
|
*/
|
||||||
get: operations["list_projects_projects_get"];
|
get: operations["list_projects_projects_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Create Project */
|
/** Create Project */
|
||||||
@@ -157,6 +160,58 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/projects/{project_id}/chapters/{chapter_no}/rewrite": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Stream Rewrite
|
||||||
|
* @description 整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
|
||||||
|
*
|
||||||
|
* HITL:新版停前端/草稿,接受才落库(不变量 #3);rewrite 是工具非写节点,不破坏章
|
||||||
|
* 纯函数性(不变量 #7)。项目不存在 → 404(触网关前 fail-fast,同 draft,QA C1)。
|
||||||
|
*/
|
||||||
|
post: operations["stream_rewrite_projects__project_id__chapters__chapter_no__rewrite_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/projects/{project_id}/chapters/{chapter_no}/rewrite/clarify": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Clarify Rewrite
|
||||||
|
* @description 整章重写预检澄清(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`(不阻塞整章重写主链路)。
|
||||||
|
*/
|
||||||
|
post: operations["clarify_rewrite_projects__project_id__chapters__chapter_no__rewrite_clarify_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/projects/{project_id}/chapters/{chapter_no}/review": {
|
"/projects/{project_id}/chapters/{chapter_no}/review": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -406,6 +461,33 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/projects/{project_id}/chapters/{chapter_no}/refine/clarify": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Clarify Refine
|
||||||
|
* @description 润色预检澄清(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`(不阻塞润色)。
|
||||||
|
*/
|
||||||
|
post: operations["clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/templates": {
|
"/templates": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -415,7 +497,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* List Templates
|
* List Templates
|
||||||
* @description 列出当前用户(stub)的模板。
|
* @description 列出当前用户(stub)的模板(分页;`?limit=&offset=`,默认第一页)。
|
||||||
*/
|
*/
|
||||||
get: operations["list_templates_templates_get"];
|
get: operations["list_templates_templates_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
@@ -450,6 +532,30 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/projects/{project_id}/ai-messages": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List Ai Messages
|
||||||
|
* @description 列出 AI 对话(newest-first)。`chapter_no` → 本章 ∪ 项目级(NULL);缺省 → 仅项目级。
|
||||||
|
*/
|
||||||
|
get: operations["list_ai_messages_projects__project_id__ai_messages_get"];
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Append Ai Messages
|
||||||
|
* @description 一批往复 bubble append(201)。项目不存在 → 404;非法 kind/role/空/超上限 → 422。
|
||||||
|
*/
|
||||||
|
post: operations["append_ai_messages_projects__project_id__ai_messages_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/projects/{project_id}/world/generate": {
|
"/projects/{project_id}/world/generate": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -845,6 +951,95 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
review_id?: string | null;
|
review_id?: string | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* AiMessageAppendRequest
|
||||||
|
* @description POST /projects/{id}/ai-messages:一次交换的一批 bubble(批级字段提顶层)。
|
||||||
|
*/
|
||||||
|
AiMessageAppendRequest: {
|
||||||
|
/**
|
||||||
|
* Thread Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
thread_id: string;
|
||||||
|
/** Chapter No */
|
||||||
|
chapter_no?: number | null;
|
||||||
|
/**
|
||||||
|
* Kind
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
kind: "refine" | "rewrite" | "clarify" | "continue" | "generator";
|
||||||
|
/** Tool Key */
|
||||||
|
tool_key?: string | null;
|
||||||
|
/** Messages */
|
||||||
|
messages: components["schemas"]["AiMessageInput"][];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* AiMessageInput
|
||||||
|
* @description 一条待落库 bubble(角色 + 人读文本 + 结构化 meta)。
|
||||||
|
*/
|
||||||
|
AiMessageInput: {
|
||||||
|
/**
|
||||||
|
* Role
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
role: "author" | "ai";
|
||||||
|
/** Content */
|
||||||
|
content: string;
|
||||||
|
/** Meta */
|
||||||
|
meta?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* AiMessageListResponse
|
||||||
|
* @description append/list 信封:`messages` 复数键(repo 约定,非 `items`)。
|
||||||
|
*/
|
||||||
|
AiMessageListResponse: {
|
||||||
|
/** Messages */
|
||||||
|
messages: components["schemas"]["AiMessageView"][];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* AiMessageView
|
||||||
|
* @description 聊天留痕视图(append/list 回显;snake_case,镜像 repo view)。
|
||||||
|
*/
|
||||||
|
AiMessageView: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Project Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
project_id: string;
|
||||||
|
/** Chapter No */
|
||||||
|
chapter_no: number | null;
|
||||||
|
/**
|
||||||
|
* Thread Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
thread_id: string;
|
||||||
|
/** Seq */
|
||||||
|
seq: number;
|
||||||
|
/** Kind */
|
||||||
|
kind: string;
|
||||||
|
/** Tool Key */
|
||||||
|
tool_key: string | null;
|
||||||
|
/** Role */
|
||||||
|
role: string;
|
||||||
|
/** Content */
|
||||||
|
content: string;
|
||||||
|
/** Meta */
|
||||||
|
meta?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* CapabilitiesView
|
* CapabilitiesView
|
||||||
* @description 探测得到的能力矩阵(镜像网关 `Capabilities`)。
|
* @description 探测得到的能力矩阵(镜像网关 `Capabilities`)。
|
||||||
@@ -1086,6 +1281,81 @@ export interface components {
|
|||||||
/** Note */
|
/** Note */
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* ClarifyDecision
|
||||||
|
* @description 润色预检澄清决策(结构化 LLM 输出 + API 响应)。
|
||||||
|
*
|
||||||
|
* 路线A 两阶段之「问题」阶段:仅判定「要不要反问」并给选项,**不改正文**(正文仍走
|
||||||
|
* 既有 refine 端点)。纯只读(`clarify_refine_spec.writes=()`,不变量 #3)。
|
||||||
|
*
|
||||||
|
* **全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式):LLM 漏产/畸形时
|
||||||
|
* 降级为 `need_clarification=false`(不问、直接放行 refine),绝不阻塞润色主链路。
|
||||||
|
*
|
||||||
|
* - `need_clarification=false` → `questions` 为空、可给一句 `verification` 确认语;
|
||||||
|
* - `need_clarification=true` → `questions` 恰 1 问(v1 硬上限)、`verification` 可空。
|
||||||
|
*/
|
||||||
|
ClarifyDecision: {
|
||||||
|
/**
|
||||||
|
* Need Clarification
|
||||||
|
* @description 是否需要向作者反问澄清(含糊/多走法→true;明确→false)
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
need_clarification: boolean;
|
||||||
|
/**
|
||||||
|
* Questions
|
||||||
|
* @description 反问清单(need_clarification=false 时为空;v1 硬上限 1 问)
|
||||||
|
*/
|
||||||
|
questions?: components["schemas"]["ClarifyQuestion"][];
|
||||||
|
/**
|
||||||
|
* Verification
|
||||||
|
* @description 明确时的一句『我这样理解对吗』确认语(need_clarification=false 时给,可空)
|
||||||
|
*/
|
||||||
|
verification?: string | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ClarifyOption
|
||||||
|
* @description 单个澄清选项:展示文案 + 选中回填值(锚定本段/本章的一种具体走法)。
|
||||||
|
*
|
||||||
|
* `value` = 作者选中后折进 refine instruction 字符串的内容(零迁移,不落库)。
|
||||||
|
* 凑不出具体可区分选项时退化为空 options(`ClarifyQuestion.options=[]`),突出自由输入。
|
||||||
|
*/
|
||||||
|
ClarifyOption: {
|
||||||
|
/**
|
||||||
|
* Label
|
||||||
|
* @description 展示文案(作者看到的选项标签)
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* Value
|
||||||
|
* @description 选中后回填/折进 instruction 的具体走法内容
|
||||||
|
*/
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ClarifyQuestion
|
||||||
|
* @description 单条澄清反问:问题 + 2–4 个锚定选项 + 自由输入兜底。
|
||||||
|
*
|
||||||
|
* `options` 常规 2–4 个(锚定本段/本章的具体走法);凑不出具体可区分选项时留空列表,
|
||||||
|
* 退化为纯自由输入。`allow_free_text` 常驻 True——自由输入永远兜底(作者可不选任一项)。
|
||||||
|
*/
|
||||||
|
ClarifyQuestion: {
|
||||||
|
/**
|
||||||
|
* Question
|
||||||
|
* @description 反问的澄清问题(一句话,指向本段的分歧点)
|
||||||
|
*/
|
||||||
|
question: string;
|
||||||
|
/**
|
||||||
|
* Options
|
||||||
|
* @description 2–4 个锚定本段/本章的具体走法选项;凑不出具体选项时为空列表
|
||||||
|
*/
|
||||||
|
options?: components["schemas"]["ClarifyOption"][];
|
||||||
|
/**
|
||||||
|
* Allow Free Text
|
||||||
|
* @description 是否允许自由输入(常驻兜底)
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
allow_free_text: boolean;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* ConflictDecision
|
* ConflictDecision
|
||||||
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
|
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
|
||||||
@@ -1757,6 +2027,23 @@ export interface components {
|
|||||||
/** Tier Routing */
|
/** Tier Routing */
|
||||||
tier_routing?: components["schemas"]["TierRoutingInput"][];
|
tier_routing?: components["schemas"]["TierRoutingInput"][];
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* RefineClarifyRequest
|
||||||
|
* @description 润色预检澄清:选段 + 再沟通意见(WFW-9 M1 路线A 两阶段之「问题」阶段)。
|
||||||
|
*
|
||||||
|
* 与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的
|
||||||
|
* 再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision`
|
||||||
|
* (在 ww_agents,端点直接返回,供前端 gen:api 生成强类型客户端)。
|
||||||
|
*/
|
||||||
|
RefineClarifyRequest: {
|
||||||
|
/** Segment */
|
||||||
|
segment: string;
|
||||||
|
/**
|
||||||
|
* Instruction
|
||||||
|
* @default
|
||||||
|
*/
|
||||||
|
instruction: string;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* RefineRequest
|
* RefineRequest
|
||||||
* @description 回炉:重写选中段(可选改写指令)。
|
* @description 回炉:重写选中段(可选改写指令)。
|
||||||
@@ -1868,6 +2155,36 @@ export interface components {
|
|||||||
/** Draft */
|
/** Draft */
|
||||||
draft?: string | null;
|
draft?: string | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* RewriteClarifyRequest
|
||||||
|
* @description 整章重写预检澄清:章级意见 + 可选当前草稿(WFW-9 M2 路线A 两阶段之「问题」阶段)。
|
||||||
|
*
|
||||||
|
* 与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文**
|
||||||
|
* (整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景,
|
||||||
|
* 可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检,
|
||||||
|
* 不整章入 LLM)。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents,端点直接返回,
|
||||||
|
* 供前端 gen:api 生成强类型客户端)。
|
||||||
|
*/
|
||||||
|
RewriteClarifyRequest: {
|
||||||
|
/** Feedback */
|
||||||
|
feedback: string;
|
||||||
|
/** Prior Draft */
|
||||||
|
prior_draft?: string | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* RewriteStreamRequest
|
||||||
|
* @description POST /projects/:id/chapters/:no/rewrite:整章再沟通/重写。
|
||||||
|
*
|
||||||
|
* `feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆
|
||||||
|
* 注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile(守缓存前缀不变量 #9)。
|
||||||
|
* 新版停前端/草稿,接受才落库(HITL,不变量 #3)。
|
||||||
|
*/
|
||||||
|
RewriteStreamRequest: {
|
||||||
|
/** Feedback */
|
||||||
|
feedback: string;
|
||||||
|
/** Prior Draft */
|
||||||
|
prior_draft: string;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* RuleCreateRequest
|
* RuleCreateRequest
|
||||||
* @description POST /projects/:id/rules:新增一条规则。
|
* @description POST /projects/:id/rules:新增一条规则。
|
||||||
@@ -2437,7 +2754,10 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
list_projects_projects_get: {
|
list_projects_projects_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
@@ -2453,6 +2773,15 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["ProjectListResponse"];
|
"application/json": components["schemas"]["ProjectListResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
create_project_projects_post: {
|
create_project_projects_post: {
|
||||||
@@ -2707,13 +3036,14 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Successful Response */
|
/** @description SSE 事件流(`event: <name>\ndata: <json>`,见 memory/contracts.md) */
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": unknown;
|
"application/json": unknown;
|
||||||
|
"text/event-stream": unknown;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Validation Error */
|
/** @description Validation Error */
|
||||||
@@ -2727,6 +3057,97 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
stream_rewrite_projects__project_id__chapters__chapter_no__rewrite_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
project_id: string;
|
||||||
|
chapter_no: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RewriteStreamRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description SSE 事件流(`event: <name>\ndata: <json>`,见 memory/contracts.md) */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
"text/event-stream": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
clarify_rewrite_projects__project_id__chapters__chapter_no__rewrite_clarify_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
project_id: string;
|
||||||
|
chapter_no: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RewriteClarifyRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ClarifyDecision"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description 项目不存在 */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description LLM 不可用 */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
review_chapter_projects__project_id__chapters__chapter_no__review_post: {
|
review_chapter_projects__project_id__chapters__chapter_no__review_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2743,13 +3164,14 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Successful Response */
|
/** @description SSE 事件流(`event: <name>\ndata: <json>`,见 memory/contracts.md) */
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": unknown;
|
"application/json": unknown;
|
||||||
|
"text/event-stream": unknown;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Validation Error */
|
/** @description Validation Error */
|
||||||
@@ -3237,10 +3659,67 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
list_templates_templates_get: {
|
clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
|
path: {
|
||||||
|
project_id: string;
|
||||||
|
chapter_no: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RefineClarifyRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ClarifyDecision"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description 项目不存在 */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description LLM 不可用 */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_templates_templates_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
@@ -3255,6 +3734,15 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["TemplateResponse"][];
|
"application/json": components["schemas"]["TemplateResponse"][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
create_template_templates_post: {
|
create_template_templates_post: {
|
||||||
@@ -3319,6 +3807,77 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_ai_messages_projects__project_id__ai_messages_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
chapter_no?: number | null;
|
||||||
|
kind?: ("refine" | "rewrite" | "clarify" | "continue" | "generator") | null;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
project_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AiMessageListResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
append_ai_messages_projects__project_id__ai_messages_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
project_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AiMessageAppendRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AiMessageListResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
generate_world_projects__project_id__world_generate_post: {
|
generate_world_projects__project_id__world_generate_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -66,3 +66,21 @@ describe("fetchDraft", () => {
|
|||||||
expect(await fetchDraft("p1", 9)).toBeNull();
|
expect(await fetchDraft("p1", 9)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// parseJsonBody 边界校验:防御被代理/网关污染的非对象或非 JSON 响应。
|
||||||
|
describe("parseJsonBody structural validation", () => {
|
||||||
|
it("throws on a 200 body that is not a JSON object (primitive)", async () => {
|
||||||
|
mockFetch(200, "surprise-string");
|
||||||
|
|
||||||
|
await expect(fetchDraft("p1", 1)).rejects.toThrow("响应体形状异常");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the 200 body is not valid JSON", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response("<html>not json</html>", { status: 200 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fetchDraft("p1", 1)).rejects.toThrow("响应非 JSON");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -125,3 +125,11 @@ export type OutlineSceneIngestView =
|
|||||||
// F1:拆书入库产物(themes/archetypes/structure/hooks → 项目 rules 条目)。
|
// F1:拆书入库产物(themes/archetypes/structure/hooks → 项目 rules 条目)。
|
||||||
export type TeardownIngestView =
|
export type TeardownIngestView =
|
||||||
components["schemas"]["TeardownIngestView"];
|
components["schemas"]["TeardownIngestView"];
|
||||||
|
|
||||||
|
// AC-3 AI 对话(聊天记录):作者↔AI 五类往复留痕的 append/list 契约(AC-2 C3 扩,snake_case)。
|
||||||
|
export type AiMessageView = components["schemas"]["AiMessageView"];
|
||||||
|
export type AiMessageInput = components["schemas"]["AiMessageInput"];
|
||||||
|
export type AiMessageAppendRequest =
|
||||||
|
components["schemas"]["AiMessageAppendRequest"];
|
||||||
|
export type AiMessageListResponse =
|
||||||
|
components["schemas"]["AiMessageListResponse"];
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function narrowJob(raw: unknown): JobView {
|
|||||||
status: asStatus(dict["status"]),
|
status: asStatus(dict["status"]),
|
||||||
progress: asProgress(dict["progress"]),
|
progress: asProgress(dict["progress"]),
|
||||||
result: asResult(dict["result"]),
|
result: asResult(dict["result"]),
|
||||||
error: typeof dict["error"] === "string" ? (dict["error"] as string) : null,
|
error: typeof dict["error"] === "string" ? dict["error"] : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
aiToolItems,
|
|
||||||
primaryAiToolItems,
|
|
||||||
secondaryAiToolItems,
|
|
||||||
} from "./ai-tools";
|
|
||||||
|
|
||||||
describe("aiToolItems", () => {
|
|
||||||
it("returns 5 items", () => {
|
|
||||||
expect(aiToolItems("p1")).toHaveLength(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leads with 写本章 (primary, key write)", () => {
|
|
||||||
const [first] = aiToolItems("p1");
|
|
||||||
expect(first?.label).toBe("写本章");
|
|
||||||
expect(first?.primary).toBe(true);
|
|
||||||
expect(first?.key).toBe("write");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scopes every href under the project path", () => {
|
|
||||||
for (const item of aiToolItems("abc")) {
|
|
||||||
expect(item.href.startsWith("/projects/abc/")).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps 审稿 to the review page and key", () => {
|
|
||||||
const review = aiToolItems("p1").find((item) => item.label === "审稿");
|
|
||||||
expect(review?.href).toBe("/projects/p1/review");
|
|
||||||
expect(review?.key).toBe("review");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps high-frequency tools outside the mobile more menu", () => {
|
|
||||||
expect(primaryAiToolItems("p1").map((item) => item.key)).toEqual([
|
|
||||||
"write",
|
|
||||||
"review",
|
|
||||||
]);
|
|
||||||
expect(secondaryAiToolItems("p1").map((item) => item.key)).toEqual([
|
|
||||||
"outline",
|
|
||||||
"codex",
|
|
||||||
"toolbox",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// T4-a · 顶部 AI 工具条数据(纯逻辑,UX §3/§5)。
|
|
||||||
// 把高频 AI 动作(写本章/审稿/大纲/设定库/工具箱)做成常驻一排,根治「命令面板太隐蔽」。
|
|
||||||
// 只露已实现能力;新生成器(创作工具箱 B 组)落地后并入工具箱入口。
|
|
||||||
// 组件只渲染;条目与激活键集中在此,复用 ActiveNav 高亮规则。
|
|
||||||
|
|
||||||
import type { ActiveNav } from "./items";
|
|
||||||
|
|
||||||
export interface AiToolItem {
|
|
||||||
href: string;
|
|
||||||
label: string;
|
|
||||||
// 高亮键(与 AppShell activeNav 比对)。
|
|
||||||
key: ActiveNav;
|
|
||||||
// 主动作(写本章)朱砂强调,其余次级。
|
|
||||||
primary?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 项目内页 AI 工具条条目(按已实现能力露出)。
|
|
||||||
export function aiToolItems(projectId: string): AiToolItem[] {
|
|
||||||
const base = `/projects/${projectId}`;
|
|
||||||
return [
|
|
||||||
{ href: `${base}/write`, label: "写本章", key: "write", primary: true },
|
|
||||||
{ href: `${base}/review`, label: "审稿", key: "review" },
|
|
||||||
{ href: `${base}/outline`, label: "大纲", key: "outline" },
|
|
||||||
{ href: `${base}/codex`, label: "设定库", key: "codex" },
|
|
||||||
{ href: `${base}/toolbox`, label: "工具箱", key: "toolbox" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function primaryAiToolItems(projectId: string): AiToolItem[] {
|
|
||||||
return aiToolItems(projectId).slice(0, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function secondaryAiToolItems(projectId: string): AiToolItem[] {
|
|
||||||
return aiToolItems(projectId).slice(2);
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
GLOBAL_NAV_ITEMS,
|
GLOBAL_NAV_ITEMS,
|
||||||
isNavItemActive,
|
isNavItemActive,
|
||||||
|
navSections,
|
||||||
projectNavItems,
|
projectNavItems,
|
||||||
type NavItem,
|
type NavItem,
|
||||||
} from "./items";
|
} from "./items";
|
||||||
@@ -32,6 +33,53 @@ describe("projectNavItems", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("navSections", () => {
|
||||||
|
it("returns only the unlabeled global section outside a project", () => {
|
||||||
|
const sections = navSections();
|
||||||
|
|
||||||
|
expect(sections).toHaveLength(1);
|
||||||
|
expect(sections[0]?.label).toBeNull();
|
||||||
|
expect(sections[0]?.items).toEqual(GLOBAL_NAV_ITEMS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups project entries under the four labeled sections (P2-1)", () => {
|
||||||
|
const sections = navSections(PID);
|
||||||
|
|
||||||
|
// 全局组(无标题)+ 写作 / 资料库 / 审阅 / 更多。
|
||||||
|
expect(sections.map((s) => s.label)).toEqual([
|
||||||
|
null,
|
||||||
|
"写作",
|
||||||
|
"资料库",
|
||||||
|
"审阅",
|
||||||
|
"更多",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byLabel = (label: string): (string | undefined)[] =>
|
||||||
|
sections.find((s) => s.label === label)?.items.map((i) => i.key) ?? [];
|
||||||
|
|
||||||
|
expect(byLabel("写作")).toEqual(["write"]);
|
||||||
|
expect(byLabel("资料库")).toEqual([
|
||||||
|
"outline",
|
||||||
|
"codex",
|
||||||
|
"foreshadow",
|
||||||
|
"style",
|
||||||
|
]);
|
||||||
|
expect(byLabel("审阅")).toEqual(["review", "chains"]);
|
||||||
|
expect(byLabel("更多")).toEqual(["toolbox", "rules", "skills"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places every project entry exactly once (no dropped or duplicated links)", () => {
|
||||||
|
const sections = navSections(PID);
|
||||||
|
const grouped = sections
|
||||||
|
.filter((s) => s.label !== null)
|
||||||
|
.flatMap((s) => s.items.map((i) => i.key));
|
||||||
|
const flat = projectNavItems(PID).map((i) => i.key);
|
||||||
|
|
||||||
|
expect([...grouped].sort()).toEqual([...flat].sort());
|
||||||
|
expect(grouped).toHaveLength(flat.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("isNavItemActive", () => {
|
describe("isNavItemActive", () => {
|
||||||
const write: NavItem = {
|
const write: NavItem = {
|
||||||
href: `/projects/${PID}/write`,
|
href: `/projects/${PID}/write`,
|
||||||
|
|||||||
@@ -46,6 +46,43 @@ export function projectNavItems(projectId: string): NavItem[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 左栏一个分组:可选小标题 + 若干条目(P2-1)。全局组 label=null(顶部直显、无小标题);
|
||||||
|
// 项目组复用现有"本作"式小标题样式。分组只重排 + 加标题,链接/路由/激活键均不变(行为保持)。
|
||||||
|
export interface NavSection {
|
||||||
|
label: string | null;
|
||||||
|
items: readonly NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目级入口的语义分组(P2-1):把 10 个平铺项归为 写作/资料库/审阅/更多,便于扫读。
|
||||||
|
// 工具箱归入「更多」——原分组清单只列了 规则·技能库,收进"更多"以不丢任何入口
|
||||||
|
// (见 memory/decisions.md 2026-07-10 条)。
|
||||||
|
const PROJECT_GROUPS: readonly { label: string; keys: readonly ActiveNav[] }[] =
|
||||||
|
[
|
||||||
|
{ label: "写作", keys: ["write"] },
|
||||||
|
{ label: "资料库", keys: ["outline", "codex", "foreshadow", "style"] },
|
||||||
|
{ label: "审阅", keys: ["review", "chains"] },
|
||||||
|
{ label: "更多", keys: ["toolbox", "rules", "skills"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 左导航分组(桌面侧栏 / 移动抽屉共用):全局组恒在顶部,项目内页再展开四个项目组。
|
||||||
|
export function navSections(projectId?: string): NavSection[] {
|
||||||
|
const global: NavSection = { label: null, items: GLOBAL_NAV_ITEMS };
|
||||||
|
if (projectId === undefined) return [global];
|
||||||
|
|
||||||
|
const byKey = new Map(projectNavItems(projectId).map((item) => [item.key, item]));
|
||||||
|
const projectSections = PROJECT_GROUPS.map((group) => ({
|
||||||
|
label: group.label,
|
||||||
|
items: group.keys.map((key) => {
|
||||||
|
const item = byKey.get(key);
|
||||||
|
if (item === undefined) {
|
||||||
|
throw new Error(`navSections: 缺少项目入口 ${key}`);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return [global, ...projectSections];
|
||||||
|
}
|
||||||
|
|
||||||
export interface NavActiveContext {
|
export interface NavActiveContext {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
activeNav?: ActiveNav;
|
activeNav?: ActiveNav;
|
||||||
|
|||||||
@@ -4,12 +4,26 @@ import { applyConflictFix, hasApplicableFix } from "./applyFix";
|
|||||||
|
|
||||||
describe("hasApplicableFix", () => {
|
describe("hasApplicableFix", () => {
|
||||||
it("requires non-empty original and string replacement", () => {
|
it("requires non-empty original and string replacement", () => {
|
||||||
expect(hasApplicableFix("迈巴赫", "奔驰")).toBe(true);
|
expect(hasApplicableFix({ original: "迈巴赫", replacement: "奔驰" })).toBe(true);
|
||||||
expect(hasApplicableFix("删我", "")).toBe(true); // 空串=删除,仍可应用
|
// 空串=删除,仍可应用
|
||||||
expect(hasApplicableFix(null, "奔驰")).toBe(false);
|
expect(hasApplicableFix({ original: "删我", replacement: "" })).toBe(true);
|
||||||
expect(hasApplicableFix("", "奔驰")).toBe(false);
|
expect(hasApplicableFix({ original: null, replacement: "奔驰" })).toBe(false);
|
||||||
expect(hasApplicableFix("迈巴赫", null)).toBe(false);
|
expect(hasApplicableFix({ original: "", replacement: "奔驰" })).toBe(false);
|
||||||
expect(hasApplicableFix(undefined, undefined)).toBe(false);
|
expect(hasApplicableFix({ original: "迈巴赫", replacement: null })).toBe(false);
|
||||||
|
expect(hasApplicableFix({ original: undefined, replacement: undefined })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrows original/replacement to string when true (type predicate)", () => {
|
||||||
|
const patch: { original: string | null; replacement: string | null } = {
|
||||||
|
original: "迈巴赫",
|
||||||
|
replacement: "奔驰",
|
||||||
|
};
|
||||||
|
if (!hasApplicableFix(patch)) throw new Error("expected applicable");
|
||||||
|
// 编译期收窄:无需强转即可当 string 拼接(`as string` 已消除)。
|
||||||
|
const combined: string = patch.original + patch.replacement;
|
||||||
|
expect(combined).toBe("迈巴赫奔驰");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,25 @@ export type FixOutcome =
|
|||||||
| { status: "not-found"; text: string } // 有补丁但终稿里找不到原文(作者已改稿/漂移):text 原样
|
| { status: "not-found"; text: string } // 有补丁但终稿里找不到原文(作者已改稿/漂移):text 原样
|
||||||
| { status: "no-patch"; text: string }; // 该冲突无可自动应用的补丁:text 原样
|
| { status: "no-patch"; text: string }; // 该冲突无可自动应用的补丁:text 原样
|
||||||
|
|
||||||
|
// 已确认可应用的补丁(original/replacement 均为 string)。
|
||||||
|
export interface ApplicablePatch {
|
||||||
|
original: string;
|
||||||
|
replacement: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 松散补丁入参:original/replacement 可能缺失/为 null(审稿可局部修复时才带上)。
|
||||||
|
interface LoosePatch {
|
||||||
|
original: string | null | undefined;
|
||||||
|
replacement: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// 是否带可应用补丁:original 非空 + replacement 为字符串(容许空串=删除)。
|
// 是否带可应用补丁:original 非空 + replacement 为字符串(容许空串=删除)。
|
||||||
export function hasApplicableFix(
|
// 类型谓词:命中即把 original/replacement 收窄为 string,调用方无需强转。
|
||||||
original: string | null | undefined,
|
export function hasApplicableFix(patch: LoosePatch): patch is ApplicablePatch {
|
||||||
replacement: string | null | undefined,
|
|
||||||
): boolean {
|
|
||||||
return (
|
return (
|
||||||
typeof original === "string" &&
|
typeof patch.original === "string" &&
|
||||||
original.length > 0 &&
|
patch.original.length > 0 &&
|
||||||
typeof replacement === "string"
|
typeof patch.replacement === "string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,18 +35,20 @@ export function applyConflictFix(
|
|||||||
original: string | null | undefined,
|
original: string | null | undefined,
|
||||||
replacement: string | null | undefined,
|
replacement: string | null | undefined,
|
||||||
): FixOutcome {
|
): FixOutcome {
|
||||||
if (!hasApplicableFix(original, replacement)) {
|
const patch: LoosePatch = { original, replacement };
|
||||||
|
if (!hasApplicableFix(patch)) {
|
||||||
return { status: "no-patch", text };
|
return { status: "no-patch", text };
|
||||||
}
|
}
|
||||||
// hasApplicableFix 已收窄为 string。
|
// 谓词已把 patch 收窄为 ApplicablePatch,original/replacement 皆为 string。
|
||||||
const from = original as string;
|
const idx = text.indexOf(patch.original);
|
||||||
const to = replacement as string;
|
|
||||||
const idx = text.indexOf(from);
|
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
return { status: "not-found", text };
|
return { status: "not-found", text };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status: "applied",
|
status: "applied",
|
||||||
text: text.slice(0, idx) + to + text.slice(idx + from.length),
|
text:
|
||||||
|
text.slice(0, idx) +
|
||||||
|
patch.replacement +
|
||||||
|
text.slice(idx + patch.original.length),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
|
|||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
import type { AcceptResponse } from "@/lib/api/types";
|
import type { AcceptResponse } from "@/lib/api/types";
|
||||||
import { buildAcceptRequest, type DecisionDraft } from "./decisions";
|
import { buildAcceptRequest, type DecisionDraft } from "./decisions";
|
||||||
|
|
||||||
@@ -29,12 +30,13 @@ export interface UseAccept {
|
|||||||
) => Promise<AcceptOutcome>;
|
) => Promise<AcceptOutcome>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiErrorEnvelope {
|
// 错误信封形状从后端 OpenAPI 生成的 schema 派生,避免与契约漂移(不再手抄字段)。
|
||||||
error?: {
|
type ApiErrorEnvelope = components["schemas"]["ErrorEnvelope"];
|
||||||
code?: string;
|
|
||||||
message?: string;
|
// accept 端点 409 CONFLICT_UNRESOLVED 在 details 里携带缺判下标;
|
||||||
details?: { missing_conflict_indices?: number[] } | null;
|
// 生成 schema 的 details 为松散 `{[k]: unknown}`,此处就地收窄该端点专属字段。
|
||||||
};
|
interface ConflictErrorDetails {
|
||||||
|
missing_conflict_indices?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验收:乐观置 accepting → 成功置 accepted(呈现「本次将更新」清单);
|
// 验收:乐观置 accepting → 成功置 accepted(呈现「本次将更新」清单);
|
||||||
@@ -59,7 +61,8 @@ export function useAccept(): UseAccept {
|
|||||||
setStatus("error");
|
setStatus("error");
|
||||||
const env = error as ApiErrorEnvelope | undefined;
|
const env = error as ApiErrorEnvelope | undefined;
|
||||||
const code = env?.error?.code;
|
const code = env?.error?.code;
|
||||||
const missing = env?.error?.details?.missing_conflict_indices ?? [];
|
const details = env?.error?.details as ConflictErrorDetails | undefined;
|
||||||
|
const missing = details?.missing_conflict_indices ?? [];
|
||||||
if (code === "CONFLICT_UNRESOLVED") {
|
if (code === "CONFLICT_UNRESOLVED") {
|
||||||
return { result: null, missingIndices: missing, conflictUnresolved: true };
|
return { result: null, missingIndices: missing, conflictUnresolved: true };
|
||||||
}
|
}
|
||||||
|
|||||||
70
apps/web/lib/security/dependencyFloors.test.ts
Normal file
70
apps/web/lib/security/dependencyFloors.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isAtLeast, SECURITY_FLOORS } from "./dependencyFloors";
|
||||||
|
|
||||||
|
// 读取本包 package.json 声明版本(相对本测试文件 ../../ → apps/web/package.json)。
|
||||||
|
function declaredVersions(): Record<string, string> {
|
||||||
|
const raw = readFileSync(
|
||||||
|
new URL("../../package.json", import.meta.url),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const pkg = JSON.parse(raw) as {
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
devDependencies?: Record<string, string>;
|
||||||
|
};
|
||||||
|
return { ...pkg.devDependencies, ...pkg.dependencies };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SECURITY_FLOORS 锁定安全下界(CR-C1)", () => {
|
||||||
|
const declared = declaredVersions();
|
||||||
|
|
||||||
|
it.each(Object.keys(SECURITY_FLOORS))(
|
||||||
|
"%s 声明版本不得低于安全下界",
|
||||||
|
(name) => {
|
||||||
|
const version = declared[name];
|
||||||
|
if (version === undefined) {
|
||||||
|
throw new Error(`${name} 未在 package.json 声明`);
|
||||||
|
}
|
||||||
|
const floor = SECURITY_FLOORS[name as keyof typeof SECURITY_FLOORS];
|
||||||
|
expect(
|
||||||
|
isAtLeast(version, floor),
|
||||||
|
`${name}@${version} 低于安全下界 ${floor}`,
|
||||||
|
).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAtLeast 语义版本比较", () => {
|
||||||
|
it("相等即满足", () => {
|
||||||
|
expect(isAtLeast("15.5.20", "15.5.20")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patch 更高满足、更低不满足", () => {
|
||||||
|
expect(isAtLeast("19.2.7", "19.2.0")).toBe(true);
|
||||||
|
expect(isAtLeast("19.2.0", "19.2.7")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("minor 边界", () => {
|
||||||
|
expect(isAtLeast("15.5.0", "15.1.3")).toBe(true);
|
||||||
|
expect(isAtLeast("15.1.3", "15.5.20")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("major 边界", () => {
|
||||||
|
expect(isAtLeast("19.0.0", "18.9.9")).toBe(true);
|
||||||
|
expect(isAtLeast("18.9.9", "19.0.0")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("去除前导 ^ / ~ 范围符", () => {
|
||||||
|
expect(isAtLeast("^19.2.7", "19.2.7")).toBe(true);
|
||||||
|
expect(isAtLeast("~19.2.7", "19.2.7")).toBe(true);
|
||||||
|
expect(isAtLeast("19.2.0", "^19.2.7")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺省段按 0 计", () => {
|
||||||
|
expect(isAtLeast("15", "15.0.0")).toBe(true);
|
||||||
|
expect(isAtLeast("15.5", "15.5.0")).toBe(true);
|
||||||
|
expect(isAtLeast("15", "15.5.0")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
apps/web/lib/security/dependencyFloors.ts
Normal file
30
apps/web/lib/security/dependencyFloors.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// 依赖安全下界守卫:锁定 next/react/react-dom 不得低于修补 RCE / 安全补丁的最低版本。
|
||||||
|
// dependencyFloors.test.ts 读取 package.json 声明版本比对,回归防止误降级回易受攻击版本(CR-C1)。
|
||||||
|
|
||||||
|
export const SECURITY_FLOORS = {
|
||||||
|
next: "15.5.20",
|
||||||
|
react: "19.2.7",
|
||||||
|
"react-dom": "19.2.7",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 语义版本比较:version >= floor(仅比对 major.minor.patch,忽略预发布 / 构建元数据)。
|
||||||
|
// 去除前导 ^ / ~ 范围符;缺省段按 0 计;非数字段按 0 计。
|
||||||
|
export function isAtLeast(version: string, floor: string): boolean {
|
||||||
|
const a = parseSemver(version);
|
||||||
|
const b = parseSemver(floor);
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
const av = a[i] ?? 0;
|
||||||
|
const bv = b[i] ?? 0;
|
||||||
|
if (av > bv) return true;
|
||||||
|
if (av < bv) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSemver(raw: string): number[] {
|
||||||
|
const cleaned = raw.trim().replace(/^[\^~]/, "");
|
||||||
|
return cleaned.split(".").map((part) => {
|
||||||
|
const n = Number.parseInt(part, 10);
|
||||||
|
return Number.isNaN(n) ? 0 : n;
|
||||||
|
});
|
||||||
|
}
|
||||||
134
apps/web/lib/start/useDeferredDraft.test.ts
Normal file
134
apps/web/lib/start/useDeferredDraft.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DRAFT_FIRST_CHAPTER,
|
||||||
|
DRAFT_PLACEHOLDER_TITLE,
|
||||||
|
useDeferredDraft,
|
||||||
|
} from "./useDeferredDraft";
|
||||||
|
|
||||||
|
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||||
|
const post = vi.fn();
|
||||||
|
const put = vi.fn();
|
||||||
|
const toast = vi.fn();
|
||||||
|
vi.mock("@/lib/api/client", () => ({
|
||||||
|
api: {
|
||||||
|
POST: (...a: unknown[]) => post(...a),
|
||||||
|
PUT: (...a: unknown[]) => put(...a),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
describe("useDeferredDraft", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
post.mockReset();
|
||||||
|
put.mockReset();
|
||||||
|
toast.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("初始为 idle", () => {
|
||||||
|
const { result } = renderHook(() => useDeferredDraft());
|
||||||
|
expect(result.current.status).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("首次落库:懒建占位项目 + 用已敲入正文种首章草稿,返回新项目 id", async () => {
|
||||||
|
post.mockResolvedValue({ data: { id: "p1" }, error: null });
|
||||||
|
put.mockResolvedValue({ data: {}, error: null });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeferredDraft());
|
||||||
|
let id: string | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
id = await result.current.ensureCreated("第一段正文");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(id).toBe("p1");
|
||||||
|
expect(result.current.status).toBe("created");
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
expect(post).toHaveBeenCalledWith("/projects", {
|
||||||
|
body: { title: DRAFT_PLACEHOLDER_TITLE },
|
||||||
|
});
|
||||||
|
expect(put).toHaveBeenCalledWith(
|
||||||
|
"/projects/{project_id}/chapters/{chapter_no}/draft",
|
||||||
|
{
|
||||||
|
params: { path: { project_id: "p1", chapter_no: DRAFT_FIRST_CHAPTER } },
|
||||||
|
body: { text: "第一段正文" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("幂等:已建后再次调用复用同一 id,绝不重复 POST", async () => {
|
||||||
|
post.mockResolvedValue({ data: { id: "p1" }, error: null });
|
||||||
|
put.mockResolvedValue({ data: {}, error: null });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeferredDraft());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.ensureCreated("正文一");
|
||||||
|
});
|
||||||
|
let second: string | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
second = await result.current.ensureCreated("正文二");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toBe("p1");
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("正文为空白:仍懒建项目但跳过草稿落库", async () => {
|
||||||
|
post.mockResolvedValue({ data: { id: "p1" }, error: null });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeferredDraft());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.ensureCreated(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
expect(put).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("创建失败:status=error、弹错误 toast、返回 null、不落草稿", async () => {
|
||||||
|
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeferredDraft());
|
||||||
|
let id: string | null = "sentinel";
|
||||||
|
await act(async () => {
|
||||||
|
id = await result.current.ensureCreated("正文");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(id).toBeNull();
|
||||||
|
expect(result.current.status).toBe("error");
|
||||||
|
expect(put).not.toHaveBeenCalled();
|
||||||
|
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("草稿落库失败为非致命:项目已建仍返回 id,提示但不阻断", async () => {
|
||||||
|
post.mockResolvedValue({ data: { id: "p1" }, error: null });
|
||||||
|
put.mockResolvedValue({ data: null, error: { detail: "写库失败" } });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeferredDraft());
|
||||||
|
let id: string | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
id = await result.current.ensureCreated("正文");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(id).toBe("p1");
|
||||||
|
expect(result.current.status).toBe("created");
|
||||||
|
expect(toast).toHaveBeenCalledWith(expect.any(String), "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("请求抛异常:status=error、弹网络异常 toast、返回 null", async () => {
|
||||||
|
post.mockRejectedValue(new Error("network down"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeferredDraft());
|
||||||
|
let id: string | null = "sentinel";
|
||||||
|
await act(async () => {
|
||||||
|
id = await result.current.ensureCreated("正文");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(id).toBeNull();
|
||||||
|
expect(result.current.status).toBe("error");
|
||||||
|
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
95
apps/web/lib/start/useDeferredDraft.ts
Normal file
95
apps/web/lib/start/useDeferredDraft.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import { useToast } from "@/components/Toast";
|
||||||
|
|
||||||
|
// 进来即写(延迟落库):落地页「直接开始写」直达空白编辑器,**只有敲入实质正文后**
|
||||||
|
// 才懒建占位项目并把已敲入正文种进首章草稿。空进空出不落库,杜绝孤儿「未命名草稿」。
|
||||||
|
export const DRAFT_PLACEHOLDER_TITLE = "未命名草稿";
|
||||||
|
export const DRAFT_FIRST_CHAPTER = 1;
|
||||||
|
|
||||||
|
export type DeferredStatus = "idle" | "creating" | "created" | "error";
|
||||||
|
|
||||||
|
export interface UseDeferredDraft {
|
||||||
|
status: DeferredStatus;
|
||||||
|
// 首次有实质正文时调用:懒建占位项目 + 落首章草稿,返回新项目 id(调用方据此跳转编辑器)。
|
||||||
|
// 幂等:已建/在建时复用同一结果,绝不重复 POST。失败返回 null(不跳转)。
|
||||||
|
ensureCreated: (text: string) => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeferredDraft(): UseDeferredDraft {
|
||||||
|
const [status, setStatus] = useState<DeferredStatus>("idle");
|
||||||
|
const toast = useToast();
|
||||||
|
// 已建项目 id 与在途 Promise:双重幂等闸,防抖多次触发只落一次库。
|
||||||
|
const createdIdRef = useRef<string | null>(null);
|
||||||
|
const inflightRef = useRef<Promise<string | null> | null>(null);
|
||||||
|
|
||||||
|
const ensureCreated = useCallback(
|
||||||
|
(text: string): Promise<string | null> => {
|
||||||
|
if (createdIdRef.current) return Promise.resolve(createdIdRef.current);
|
||||||
|
if (inflightRef.current) return inflightRef.current;
|
||||||
|
|
||||||
|
const run = createDraftProject(text, toast, setStatus).then((id) => {
|
||||||
|
inflightRef.current = null;
|
||||||
|
if (id) createdIdRef.current = id;
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
inflightRef.current = run;
|
||||||
|
return run;
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { status, ensureCreated };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = ReturnType<typeof useToast>;
|
||||||
|
|
||||||
|
// 懒建项目 + 落首章草稿(草稿失败为非致命:项目已建,正文仍在前端,进编辑器后自动重存)。
|
||||||
|
async function createDraftProject(
|
||||||
|
text: string,
|
||||||
|
toast: Toast,
|
||||||
|
setStatus: (s: DeferredStatus) => void,
|
||||||
|
): Promise<string | null> {
|
||||||
|
setStatus("creating");
|
||||||
|
try {
|
||||||
|
const { data, error } = await api.POST("/projects", {
|
||||||
|
body: { title: DRAFT_PLACEHOLDER_TITLE },
|
||||||
|
});
|
||||||
|
if (error || !data) {
|
||||||
|
setStatus("error");
|
||||||
|
toast("创建草稿失败,请重试", "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await seedFirstChapter(data.id, text, toast);
|
||||||
|
setStatus("created");
|
||||||
|
return data.id;
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
toast("创建草稿异常,请检查网络", "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把已敲入正文种进首章草稿;空白正文跳过。落库失败仅提示、不抛(项目已建,不阻断进入编辑器)。
|
||||||
|
async function seedFirstChapter(
|
||||||
|
projectId: string,
|
||||||
|
text: string,
|
||||||
|
toast: Toast,
|
||||||
|
): Promise<void> {
|
||||||
|
if (text.trim().length === 0) return;
|
||||||
|
const { error } = await api.PUT(
|
||||||
|
"/projects/{project_id}/chapters/{chapter_no}/draft",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
path: { project_id: projectId, chapter_no: DRAFT_FIRST_CHAPTER },
|
||||||
|
},
|
||||||
|
body: { text },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) {
|
||||||
|
toast("首段草稿保存失败,进入编辑器后会自动重试", "info");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,7 +85,7 @@ export class SseFrameBuffer {
|
|||||||
let idx: number;
|
let idx: number;
|
||||||
// 块之间以空行(\n\n,兼容 \r\n\r\n)分隔。
|
// 块之间以空行(\n\n,兼容 \r\n\r\n)分隔。
|
||||||
while ((idx = this.findBoundary(this.buf)) !== -1) {
|
while ((idx = this.findBoundary(this.buf)) !== -1) {
|
||||||
const block = this.buf.slice(0, idx.valueOf());
|
const block = this.buf.slice(0, idx);
|
||||||
this.buf = this.buf.slice(this.boundaryEnd(this.buf, idx));
|
this.buf = this.buf.slice(this.boundaryEnd(this.buf, idx));
|
||||||
const evt = parseSseBlock(block);
|
const evt = parseSseBlock(block);
|
||||||
if (evt) events.push(evt);
|
if (evt) events.push(evt);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe("useDraftStream", () => {
|
|||||||
await result.current.start("p1", 2, " 写得热血一点 ");
|
await result.current.start("p1", 2, " 写得热血一点 ");
|
||||||
});
|
});
|
||||||
|
|
||||||
const [, init] = fetchMock.mock.calls[0];
|
const [, init] = fetchMock.mock.calls[0]!;
|
||||||
expect(init.method).toBe("POST");
|
expect(init.method).toBe("POST");
|
||||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||||
expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" });
|
expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" });
|
||||||
@@ -99,7 +99,7 @@ describe("useDraftStream", () => {
|
|||||||
await result.current.start("p1", 2, " ");
|
await result.current.start("p1", 2, " ");
|
||||||
});
|
});
|
||||||
|
|
||||||
const [, init] = fetchMock.mock.calls[0];
|
const [, init] = fetchMock.mock.calls[0]!;
|
||||||
expect(init.body).toBeUndefined();
|
expect(init.body).toBeUndefined();
|
||||||
expect(init.headers["Content-Type"]).toBeUndefined();
|
expect(init.headers["Content-Type"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
19
apps/web/lib/style/errorCode-dedup.test.ts
Normal file
19
apps/web/lib/style/errorCode-dedup.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// CR-H10 回归守卫:errorCode 只应有一份权威实现(lib/generation/cards),
|
||||||
|
// style 侧两个 hook 不得再各自本地定义 `function errorCode(`,须统一 import。
|
||||||
|
describe("errorCode 去重(CR-H10)", () => {
|
||||||
|
it.each(["useRefine.ts", "useStyleLearn.ts"])(
|
||||||
|
"%s 不再本地定义 errorCode,且从 generation/cards 引入",
|
||||||
|
(file) => {
|
||||||
|
const src = readFileSync(new URL(`./${file}`, import.meta.url), "utf-8");
|
||||||
|
|
||||||
|
expect(src).not.toMatch(/function\s+errorCode\s*\(/);
|
||||||
|
expect(src).toMatch(
|
||||||
|
/import\s*\{[^}]*\berrorCode\b[^}]*\}\s*from\s*["']@\/lib\/generation\/cards["']/,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -122,4 +122,40 @@ describe("useRefine", () => {
|
|||||||
expect(result.current.status).toBe("idle");
|
expect(result.current.status).toBe("idle");
|
||||||
expect(result.current.result).toBeNull();
|
expect(result.current.result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("中止后迟到的响应不覆盖状态(段切换竞态)", async () => {
|
||||||
|
// Arrange:post 挂起,手动兑现以模拟在途请求。
|
||||||
|
let resolvePost!: (v: unknown) => void;
|
||||||
|
const pending = new Promise((r) => {
|
||||||
|
resolvePost = r;
|
||||||
|
});
|
||||||
|
post.mockReturnValue(pending);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRefine());
|
||||||
|
|
||||||
|
// Act:发起回炉(进入 refining),随后 abort(模拟切段取消在途)。
|
||||||
|
let refinePromise!: Promise<unknown>;
|
||||||
|
act(() => {
|
||||||
|
refinePromise = result.current.refine("p1", 1, "新段");
|
||||||
|
});
|
||||||
|
expect(result.current.status).toBe("refining");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迟到的旧响应兑现——应被丢弃:不写状态、不弹 toast。
|
||||||
|
await act(async () => {
|
||||||
|
resolvePost({
|
||||||
|
data: { original: "旧段", refined: "旧改写" },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
await refinePromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert:结果仍为空、未落到 done、未误报 toast。
|
||||||
|
expect(result.current.result).toBeNull();
|
||||||
|
expect(result.current.status).not.toBe("done");
|
||||||
|
expect(toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
|
import { errorCode } from "@/lib/generation/cards";
|
||||||
import { buildRefineRequest } from "./style";
|
import { buildRefineRequest } from "./style";
|
||||||
|
|
||||||
export type RefineStatus = "idle" | "refining" | "done" | "error";
|
export type RefineStatus = "idle" | "refining" | "done" | "error";
|
||||||
@@ -23,24 +24,25 @@ export interface UseRefine {
|
|||||||
segment: string,
|
segment: string,
|
||||||
instruction?: string,
|
instruction?: string,
|
||||||
) => Promise<RefineResult | null>;
|
) => Promise<RefineResult | null>;
|
||||||
|
// 取消在途回炉(段切换/卸载时调用)——不触碰 state,安全用于 effect 清理。
|
||||||
|
abort: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorCode(error: unknown): string | undefined {
|
|
||||||
if (typeof error !== "object" || error === null) return undefined;
|
|
||||||
const env = error as { error?: { code?: unknown } };
|
|
||||||
return typeof env.error?.code === "string" ? env.error.code : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回炉(POST .../refine):同步返回新旧 diff,不写库(不变量#3)。
|
// 回炉(POST .../refine):同步返回新旧 diff,不写库(不变量#3)。
|
||||||
// 503 LLM_UNAVAILABLE(无凭据)优雅提示去设置;其余失败 toast。
|
// 503 LLM_UNAVAILABLE(无凭据)优雅提示去设置;其余失败 toast。
|
||||||
export function useRefine(): UseRefine {
|
export function useRefine(): UseRefine {
|
||||||
const [status, setStatus] = useState<RefineStatus>("idle");
|
const [status, setStatus] = useState<RefineStatus>("idle");
|
||||||
const [result, setResult] = useState<RefineResult | null>(null);
|
const [result, setResult] = useState<RefineResult | null>(null);
|
||||||
|
const controllerRef = useRef<AbortController | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const refine = useCallback<UseRefine["refine"]>(
|
const refine = useCallback<UseRefine["refine"]>(
|
||||||
async (projectId, chapterNo, segment, instruction) => {
|
async (projectId, chapterNo, segment, instruction) => {
|
||||||
|
// 取消上一次在途请求,换新 controller(段切换竞态防护,CR-H12)。
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
setStatus("refining");
|
setStatus("refining");
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
@@ -51,8 +53,11 @@ export function useRefine(): UseRefine {
|
|||||||
path: { project_id: projectId, chapter_no: chapterNo },
|
path: { project_id: projectId, chapter_no: chapterNo },
|
||||||
},
|
},
|
||||||
body: buildRefineRequest(segment, instruction),
|
body: buildRefineRequest(segment, instruction),
|
||||||
|
signal: controller.signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// 已被后续请求/卸载中止:丢弃迟到响应,不写状态。
|
||||||
|
if (controller.signal.aborted) return null;
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
const code = errorCode(error);
|
const code = errorCode(error);
|
||||||
@@ -72,6 +77,8 @@ export function useRefine(): UseRefine {
|
|||||||
setStatus("done");
|
setStatus("done");
|
||||||
return outcome;
|
return outcome;
|
||||||
} catch {
|
} catch {
|
||||||
|
// 主动中止(abort)触发的异常不该报错 toast。
|
||||||
|
if (controller.signal.aborted) return null;
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
toast("回炉请求异常,请检查网络。", "error");
|
toast("回炉请求异常,请检查网络。", "error");
|
||||||
return null;
|
return null;
|
||||||
@@ -80,10 +87,18 @@ export function useRefine(): UseRefine {
|
|||||||
[toast],
|
[toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 仅取消在途请求,不触碰 state——可安全用于 effect 清理 / 卸载。
|
||||||
|
const abort = useCallback((): void => {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
controllerRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const reset = useCallback((): void => {
|
const reset = useCallback((): void => {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
controllerRef.current = null;
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { status, result, refine, reset };
|
return { status, result, refine, abort, reset };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from "@/lib/api/client";
|
|||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||||
import { styleLearnResult } from "@/lib/jobs/job";
|
import { styleLearnResult } from "@/lib/jobs/job";
|
||||||
|
import { errorCode } from "@/lib/generation/cards";
|
||||||
import {
|
import {
|
||||||
buildLearnRequest,
|
buildLearnRequest,
|
||||||
normalizeFingerprint,
|
normalizeFingerprint,
|
||||||
@@ -27,12 +28,6 @@ export interface UseStyleLearn {
|
|||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorCode(error: unknown): string | undefined {
|
|
||||||
if (typeof error !== "object" || error === null) return undefined;
|
|
||||||
const env = error as { error?: { code?: unknown } };
|
|
||||||
return typeof env.error?.code === "string" ? env.error.code : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 学文风编排:受理(202 job_id)→ useJobPoll 轮询 → done 拉 GET /style 展示指纹。
|
// 学文风编排:受理(202 job_id)→ useJobPoll 轮询 → done 拉 GET /style 展示指纹。
|
||||||
export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
|
export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
|
||||||
const [fingerprint, setFingerprint] = useState<Fingerprint | null>(initial);
|
const [fingerprint, setFingerprint] = useState<Fingerprint | null>(initial);
|
||||||
|
|||||||
51
apps/web/lib/toolbox/aiSummary.test.ts
Normal file
51
apps/web/lib/toolbox/aiSummary.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ToolInputFieldView } from "@/lib/api/types";
|
||||||
|
import type { PreviewModel } from "./toolbox";
|
||||||
|
import { fieldSummary, renderPreviewText } from "./aiSummary";
|
||||||
|
|
||||||
|
const fields: ToolInputFieldView[] = [
|
||||||
|
{ name: "brief", label: "脑洞", type: "textarea", required: true, help: null },
|
||||||
|
{ name: "count", label: "数量", type: "number", required: false, help: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("fieldSummary", () => {
|
||||||
|
it("拼出「label:value」摘要,跳过空值", () => {
|
||||||
|
const summary = fieldSummary(fields, { brief: "废柴逆袭", count: "" });
|
||||||
|
expect(summary).toBe("脑洞:废柴逆袭");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("多字段用 · 连接", () => {
|
||||||
|
const summary = fieldSummary(fields, { brief: "扮猪吃虎", count: "3" });
|
||||||
|
expect(summary).toBe("脑洞:扮猪吃虎 · 数量:3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("字段为空 / 无值 → 空串", () => {
|
||||||
|
expect(fieldSummary([], {})).toBe("");
|
||||||
|
expect(fieldSummary(fields, {})).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderPreviewText", () => {
|
||||||
|
it("把预览项渲染成人读文本(heading + 字段 + 正文)", () => {
|
||||||
|
const preview: PreviewModel = {
|
||||||
|
kind: "IdeaListResult",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
heading: "废柴逆袭",
|
||||||
|
fields: [{ label: "钩子", value: "扮猪吃虎" }],
|
||||||
|
body: null,
|
||||||
|
},
|
||||||
|
{ heading: null, fields: [], body: "一段开篇正文。" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const text = renderPreviewText(preview);
|
||||||
|
expect(text).toContain("废柴逆袭");
|
||||||
|
expect(text).toContain("钩子:扮猪吃虎");
|
||||||
|
expect(text).toContain("一段开篇正文。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空预览 → 空串", () => {
|
||||||
|
expect(renderPreviewText({ kind: "x", items: [] })).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
33
apps/web/lib/toolbox/aiSummary.ts
Normal file
33
apps/web/lib/toolbox/aiSummary.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// 工具箱生成留痕的两个纯格式化助手(AI 对话记录用):
|
||||||
|
// - fieldSummary:把作者填写的输入字段拼成人读摘要(author 气泡 content;长文进 content 不进 meta)。
|
||||||
|
// - renderPreviewText:把结构化预览渲染成人读文本(ai 气泡 content;不把原始产物塞进 meta)。
|
||||||
|
// 确定性纯函数,无 IO;供 GeneratorRunner 结束后 append 一批往复。
|
||||||
|
|
||||||
|
import type { ToolInputFieldView } from "@/lib/api/types";
|
||||||
|
import type { FieldValues, PreviewModel } from "./toolbox";
|
||||||
|
|
||||||
|
// 输入字段 → 「label:value」摘要,跳过空值,多字段用 · 连接。
|
||||||
|
export function fieldSummary(
|
||||||
|
fields: readonly ToolInputFieldView[],
|
||||||
|
values: FieldValues,
|
||||||
|
): string {
|
||||||
|
return fields
|
||||||
|
.map((f) => ({ label: f.label, value: (values[f.name] ?? "").trim() }))
|
||||||
|
.filter((entry) => entry.value.length > 0)
|
||||||
|
.map((entry) => `${entry.label}:${entry.value}`)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览模型 → 人读文本:每项 = heading + 「label:value」次要字段 + 正文段,项间空行分隔。
|
||||||
|
export function renderPreviewText(preview: PreviewModel): string {
|
||||||
|
return preview.items
|
||||||
|
.map((item) => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (item.heading) lines.push(item.heading);
|
||||||
|
for (const f of item.fields) lines.push(`${f.label}:${f.value}`);
|
||||||
|
if (item.body) lines.push(item.body);
|
||||||
|
return lines.join("\n");
|
||||||
|
})
|
||||||
|
.filter((block) => block.length > 0)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
@@ -55,8 +55,9 @@ describe("useGenerator", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useGenerator());
|
const { result } = renderHook(() => useGenerator());
|
||||||
|
let returned: unknown;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.generate("p1", "idea", { brief: "脑洞" });
|
returned = await result.current.generate("p1", "idea", { brief: "脑洞" });
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.genStatus).toBe("preview");
|
expect(result.current.genStatus).toBe("preview");
|
||||||
@@ -65,6 +66,10 @@ describe("useGenerator", () => {
|
|||||||
expect(result.current.preview?.kind).toBe("IdeaListResult");
|
expect(result.current.preview?.kind).toBe("IdeaListResult");
|
||||||
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
|
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
|
||||||
expect(toast).not.toHaveBeenCalled();
|
expect(toast).not.toHaveBeenCalled();
|
||||||
|
// 追加断言:generate 返回本次产物(供 GeneratorRunner 结束后留痕)。
|
||||||
|
const artifact = returned as { outputKind: string; rawPreview: unknown };
|
||||||
|
expect(artifact.outputKind).toBe("IdeaListResult");
|
||||||
|
expect(artifact.rawPreview).toEqual(preview);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {
|
it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ import type { ToolGenerateRequest } from "@/lib/api/types";
|
|||||||
export type GenStatus = "idle" | "generating" | "preview" | "error";
|
export type GenStatus = "idle" | "generating" | "preview" | "error";
|
||||||
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error";
|
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error";
|
||||||
|
|
||||||
|
// 一次生成的产物(供调用方在结束后留痕;与 hook state 同源)。
|
||||||
|
export interface GeneratorArtifact {
|
||||||
|
preview: PreviewModel;
|
||||||
|
rawPreview: Record<string, unknown> | undefined;
|
||||||
|
outputKind: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseGenerator {
|
export interface UseGenerator {
|
||||||
genStatus: GenStatus;
|
genStatus: GenStatus;
|
||||||
preview: PreviewModel | null;
|
preview: PreviewModel | null;
|
||||||
@@ -30,7 +37,7 @@ export interface UseGenerator {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
toolKey: string,
|
toolKey: string,
|
||||||
body: ToolGenerateRequest,
|
body: ToolGenerateRequest,
|
||||||
) => Promise<void>;
|
) => Promise<GeneratorArtifact | null>;
|
||||||
// 入库选中行;acknowledge 用于过 409(已查看冲突后重发)。
|
// 入库选中行;acknowledge 用于过 409(已查看冲突后重发)。
|
||||||
ingest: (
|
ingest: (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
@@ -74,15 +81,22 @@ export function useGenerator(): UseGenerator {
|
|||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
setGenStatus("error");
|
setGenStatus("error");
|
||||||
toast(generationErrorMessage(error), "error");
|
toast(generationErrorMessage(error), "error");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
setPreview(mapPreview(data.output_kind, data.preview));
|
const mapped = mapPreview(data.output_kind, data.preview);
|
||||||
|
setPreview(mapped);
|
||||||
setRawPreview(data.preview);
|
setRawPreview(data.preview);
|
||||||
setOutputKind(data.output_kind);
|
setOutputKind(data.output_kind);
|
||||||
setGenStatus("preview");
|
setGenStatus("preview");
|
||||||
|
return {
|
||||||
|
preview: mapped,
|
||||||
|
rawPreview: data.preview,
|
||||||
|
outputKind: data.output_kind,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
setGenStatus("error");
|
setGenStatus("error");
|
||||||
toast("生成请求异常,请检查网络。", "error");
|
toast("生成请求异常,请检查网络。", "error");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[toast],
|
[toast],
|
||||||
|
|||||||
52
apps/web/lib/toolbox/useToolboxTools.test.ts
Normal file
52
apps/web/lib/toolbox/useToolboxTools.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { useToolboxTools } from "./useToolboxTools";
|
||||||
|
|
||||||
|
const get = vi.fn();
|
||||||
|
const toast = vi.fn();
|
||||||
|
vi.mock("@/lib/api/client", () => ({
|
||||||
|
api: { GET: (...a: unknown[]) => get(...a) },
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
describe("useToolboxTools", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
toast.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("加载成功:拉全量描述符,status=ready", async () => {
|
||||||
|
const tools = [{ key: "golden-finger", title: "金手指生成器", is_legacy: false }];
|
||||||
|
get.mockResolvedValue({ data: { tools }, error: null });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useToolboxTools());
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||||||
|
expect(result.current.tools).toEqual(tools);
|
||||||
|
expect(get).toHaveBeenCalledWith("/skills/toolbox", {});
|
||||||
|
expect(toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("data.tools 缺省时回退空数组", async () => {
|
||||||
|
get.mockResolvedValue({ data: { tools: null }, error: null });
|
||||||
|
const { result } = renderHook(() => useToolboxTools());
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||||||
|
expect(result.current.tools).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("后端 error:status=error、弹错误 toast", async () => {
|
||||||
|
get.mockResolvedValue({ data: null, error: { detail: "boom" } });
|
||||||
|
const { result } = renderHook(() => useToolboxTools());
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("error"));
|
||||||
|
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("请求抛异常:status=error、弹网络异常 toast", async () => {
|
||||||
|
get.mockRejectedValue(new Error("network down"));
|
||||||
|
const { result } = renderHook(() => useToolboxTools());
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("error"));
|
||||||
|
expect(toast).toHaveBeenCalledWith("工具箱加载异常,请检查网络。", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
48
apps/web/lib/toolbox/useToolboxTools.ts
Normal file
48
apps/web/lib/toolbox/useToolboxTools.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import { useToast } from "@/components/Toast";
|
||||||
|
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||||
|
|
||||||
|
// 客户端拉取创作工具箱全量描述符(GET /skills/toolbox),供编辑器内联工具箱调用。
|
||||||
|
// 描述符只读、少变,进面板时拉一次即可。
|
||||||
|
export type ToolboxStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export interface UseToolboxTools {
|
||||||
|
status: ToolboxStatus;
|
||||||
|
tools: ToolDescriptorView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToolboxTools(): UseToolboxTools {
|
||||||
|
const [status, setStatus] = useState<ToolboxStatus>("loading");
|
||||||
|
const [tools, setTools] = useState<ToolDescriptorView[]>([]);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await api.GET("/skills/toolbox", {});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (error || !data) {
|
||||||
|
setStatus("error");
|
||||||
|
toast("工具箱加载失败,请重试。", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTools(data.tools ?? []);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
setStatus("error");
|
||||||
|
toast("工具箱加载异常,请检查网络。", "error");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
return { status, tools };
|
||||||
|
}
|
||||||
135
apps/web/lib/workbench/aiConversation.test.ts
Normal file
135
apps/web/lib/workbench/aiConversation.test.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { AiMessageView } from "@/lib/api/types";
|
||||||
|
import {
|
||||||
|
acceptActionFor,
|
||||||
|
bubbleSide,
|
||||||
|
groupByThread,
|
||||||
|
KIND_LABEL,
|
||||||
|
kindLabel,
|
||||||
|
partitionByScope,
|
||||||
|
} from "./aiConversation";
|
||||||
|
|
||||||
|
// AiMessageView 工厂:只填测试关心的字段,其余给稳定默认。
|
||||||
|
function view(partial: Partial<AiMessageView>): AiMessageView {
|
||||||
|
return {
|
||||||
|
id: partial.id ?? crypto.randomUUID(),
|
||||||
|
project_id: "p1",
|
||||||
|
chapter_no: partial.chapter_no ?? null,
|
||||||
|
thread_id: partial.thread_id ?? "t1",
|
||||||
|
seq: partial.seq ?? 0,
|
||||||
|
kind: partial.kind ?? "refine",
|
||||||
|
tool_key: partial.tool_key ?? null,
|
||||||
|
role: partial.role ?? "ai",
|
||||||
|
content: partial.content ?? "",
|
||||||
|
meta: partial.meta ?? {},
|
||||||
|
created_at: partial.created_at ?? "2026-07-09T00:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("kindLabel / KIND_LABEL", () => {
|
||||||
|
it("五类 kind 各有中文标签", () => {
|
||||||
|
expect(KIND_LABEL.refine).toBe("润色");
|
||||||
|
expect(KIND_LABEL.rewrite).toBe("整章重写");
|
||||||
|
expect(KIND_LABEL.clarify).toBe("反问澄清");
|
||||||
|
expect(KIND_LABEL.continue).toBe("续写");
|
||||||
|
expect(KIND_LABEL.generator).toBe("工具箱");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未知 kind 回退为原始串(不崩)", () => {
|
||||||
|
expect(kindLabel("mystery")).toBe("mystery");
|
||||||
|
expect(kindLabel("refine")).toBe("润色");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bubbleSide", () => {
|
||||||
|
it("author 靠右、ai 靠左", () => {
|
||||||
|
expect(bubbleSide("author")).toBe("right");
|
||||||
|
expect(bubbleSide("ai")).toBe("left");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupByThread", () => {
|
||||||
|
it("按 thread_id 归组,返回每组 kind/toolKey/chapterNo", () => {
|
||||||
|
const msgs = [
|
||||||
|
view({ thread_id: "a", kind: "refine", chapter_no: 1, seq: 0 }),
|
||||||
|
view({ thread_id: "b", kind: "continue", tool_key: "continue", chapter_no: 1, seq: 0 }),
|
||||||
|
view({ thread_id: "a", kind: "refine", chapter_no: 1, seq: 1 }),
|
||||||
|
];
|
||||||
|
const threads = groupByThread(msgs);
|
||||||
|
const a = threads.find((t) => t.threadId === "a");
|
||||||
|
expect(a?.messages).toHaveLength(2);
|
||||||
|
expect(a?.kind).toBe("refine");
|
||||||
|
const b = threads.find((t) => t.threadId === "b");
|
||||||
|
expect(b?.toolKey).toBe("continue");
|
||||||
|
expect(b?.chapterNo).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("同一批次 created_at 相同时,按 seq 升序排组内(不靠 created_at 单独定序)", () => {
|
||||||
|
// 故意乱序传入;同 created_at,靠 seq 稳定定序。
|
||||||
|
const msgs = [
|
||||||
|
view({ thread_id: "a", seq: 2, content: "c2", created_at: "2026-07-09T00:00:00Z" }),
|
||||||
|
view({ thread_id: "a", seq: 0, content: "c0", created_at: "2026-07-09T00:00:00Z" }),
|
||||||
|
view({ thread_id: "a", seq: 1, content: "c1", created_at: "2026-07-09T00:00:00Z" }),
|
||||||
|
];
|
||||||
|
const thread = groupByThread(msgs)[0]!;
|
||||||
|
expect(thread.messages.map((m) => m.content)).toEqual(["c0", "c1", "c2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("跨批次靠 created_at 定序,组内新批 append 在后", () => {
|
||||||
|
const msgs = [
|
||||||
|
view({ thread_id: "a", seq: 0, content: "old", created_at: "2026-07-09T00:00:00Z" }),
|
||||||
|
view({ thread_id: "a", seq: 0, content: "new", created_at: "2026-07-09T00:05:00Z" }),
|
||||||
|
];
|
||||||
|
const thread = groupByThread(msgs)[0]!;
|
||||||
|
expect(thread.messages.map((m) => m.content)).toEqual(["old", "new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("线程按各自首条 (created_at, seq) 升序(老线程在前)", () => {
|
||||||
|
const msgs = [
|
||||||
|
view({ thread_id: "late", seq: 0, created_at: "2026-07-09T01:00:00Z" }),
|
||||||
|
view({ thread_id: "early", seq: 0, created_at: "2026-07-09T00:00:00Z" }),
|
||||||
|
];
|
||||||
|
const threads = groupByThread(msgs);
|
||||||
|
expect(threads.map((t) => t.threadId)).toEqual(["early", "late"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空输入 → 空数组", () => {
|
||||||
|
expect(groupByThread([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partitionByScope", () => {
|
||||||
|
it("本章线程与项目级(chapter_no=null)线程分区", () => {
|
||||||
|
const msgs = [
|
||||||
|
view({ thread_id: "chap", chapter_no: 3, seq: 0 }),
|
||||||
|
view({ thread_id: "proj", chapter_no: null, kind: "generator", seq: 0 }),
|
||||||
|
];
|
||||||
|
const threads = groupByThread(msgs);
|
||||||
|
const { chapterThreads, projectThreads } = partitionByScope(threads, 3);
|
||||||
|
expect(chapterThreads.map((t) => t.threadId)).toEqual(["chap"]);
|
||||||
|
expect(projectThreads.map((t) => t.threadId)).toEqual(["proj"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("非本章号的章线程不落入本章分区(联合查询兜底)", () => {
|
||||||
|
const threads = groupByThread([
|
||||||
|
view({ thread_id: "other", chapter_no: 9, seq: 0 }),
|
||||||
|
]);
|
||||||
|
const { chapterThreads, projectThreads } = partitionByScope(threads, 3);
|
||||||
|
expect(chapterThreads).toHaveLength(0);
|
||||||
|
expect(projectThreads).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("acceptActionFor", () => {
|
||||||
|
it("按 kind 映射接受动作;clarify/未知无动作", () => {
|
||||||
|
const mk = (kind: string) =>
|
||||||
|
groupByThread([view({ thread_id: kind, kind, seq: 0 })])[0]!;
|
||||||
|
expect(acceptActionFor(mk("rewrite"))).toBe("replace_chapter");
|
||||||
|
expect(acceptActionFor(mk("continue"))).toBe("append_draft");
|
||||||
|
expect(acceptActionFor(mk("generator"))).toBe("append_draft");
|
||||||
|
expect(acceptActionFor(mk("refine"))).toBe("refill_segment");
|
||||||
|
expect(acceptActionFor(mk("clarify"))).toBeNull();
|
||||||
|
expect(acceptActionFor(mk("mystery"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user