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
|
||||
LOG_JSON=false
|
||||
# 凭据加密密钥(Fernet, urlsafe-base64 32B)— **必填**:API 启动即校验,缺失/非法则拒绝启动。
|
||||
# 下方为开发占位 key(可直接用于本地);生产请换新值:
|
||||
# ⚠️ 下方为**不可用占位符**,必须替换:`cp .env.example .env` 后用下方命令生成真实 key 填入。
|
||||
# 切勿把真实 key 提交进版本库;任何曾经泄露过的 key 必须立即轮换(重新加密已存凭据)。
|
||||
# uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
CREDENTIAL_ENC_KEY=cnMpG6QQxJejuDLHTe_S-nq2snoKgXCqWFfsctEHB-4=
|
||||
CREDENTIAL_ENC_KEY=replace-me-with-generated-fernet-key
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,3 +19,5 @@ apps/web/lib/api/openapi.json
|
||||
# os
|
||||
.DS_Store
|
||||
.gstack/
|
||||
# coverage artifact(不入库;覆盖率跑生成,误提交由 CR 批 c596a8d 撤销)
|
||||
.coverage
|
||||
|
||||
@@ -481,7 +481,6 @@ class LlmRequest(BaseModel):
|
||||
input: str | list[Block] # 易变内容(断点之后)
|
||||
stream: bool = False
|
||||
output_schema: type[BaseModel] | None = None # 需结构化输出时(Pydantic 模型/instructor)
|
||||
thinking: bool = False # 是否启用思考/推理(有则启用)
|
||||
max_tokens: int | None = None
|
||||
scope: "Scope" # {project_id; user_id 原型可固定}
|
||||
|
||||
|
||||
@@ -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 sync`(后端,仓库根);`pnpm install`(前端,`cd apps/web`)。
|
||||
- **本地起服务**:`docker compose up`(pg + api + web)。仅起库:`docker compose up -d pg`。裸跑 API:`uv run uvicorn ww_api.main:app --reload`。
|
||||
- **本地起服务(宿主端口 +1000 防冲突:web 4000 / api 9000 / pg 6432;容器内仍 3000/8000/5432)**:`docker compose up`(pg + api + web,端口已 +1000)。仅起库:`docker compose up -d pg`(宿主 6432)。裸跑:API `uv run uvicorn ww_api.main:app --port 9000 --reload`(读根 `.env`→pg 6432 / CORS 放行 4000);前端 `cd apps/web && pnpm dev`(`next dev -p 4000`,读 `apps/web/.env.local`→`NEXT_PUBLIC_API_BASE=http://localhost:9000`)。`.env`/`.env.local` 为本机文件(gitignored)。
|
||||
- **迁移**:`uv run alembic upgrade head`;改模型后 `uv run alembic revision --autogenerate -m "..."`;漂移校验 `uv run alembic check`(需 pg 在跑)。
|
||||
- **后端门禁**:`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。覆盖率门禁:`uv run pytest -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/**`)。
|
||||
|
||||
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 面。
|
||||
> **整改 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-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-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | ⬜ | @backend | — | `services/job_runner.py:75`+`routers/kimi_oauth.py:85` 轮询全程(最坏 >200s)占连接池连接、饿死其他请求;轮询循环无需 DB,仅最终 `upsert_oauth_credential` 开短会话 |
|
||||
| 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-H3 🟠 Anthropic 结构化输出记真实 usage | ⬜ | @llm | — | `adapters/anthropic.py:161` 硬编码 `usage=0`→`usage_ledger` 全记 0 成本、腐蚀计费(违 ARCH §4.8);用 `create_with_completion` 取原始响应 usage |
|
||||
| CR-H4 🟠 `request_id` 贯通网关日志 | ⬜ | @llm | — | `gateway.py:183` 所有 `llm_call`/`llm_provider_failed` 无 request_id;`LlmRequest` 加 `request_id` 字段并从 API 层透传(违 ARCH §9.3 端到端追踪不变量) |
|
||||
| CR-H5 🟠 `thinking` 字段:实现或删除 | ⬜ | @llm | — | `types.py:41` 声明但所有 adapter 忽略,`thinking=True` 静默无效、无报错;按 YAGNI 删除或实现转发 + 测试 |
|
||||
| CR-H6 🟠 流式路径加 per-provider 重试 | ⬜ | @llm | — | `gateway.py:268` 流式仅 1 次尝试,首 token 前一次瞬时 429 烧光 fallback 链(与非流式 `_complete_with_retry` 不对称);用 `_retrying()` 包裹首 token 前尝试或显式文档化 |
|
||||
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | ⬜ | @backend | — | `packages/skills/ww_skills/toolbox_registry.py:70` 裸 dict 可变;仿同库 `SPECS` 用 `Final[Mapping]=MappingProxyType(...)`(违不可变不变量) |
|
||||
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | ⬜ | @devops | — | 已确认提交真实可用 key(注释「可直接用于本地」),换非功能占位符 + 突出轮换提示;联动 CR-C1 轮换 |
|
||||
| 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-H10 🟠 前端 `errorCode` 去重 | ⬜ | @frontend | — | `lib/style/{useRefine,useStyleLearn}.ts` 各自重复定义,统一 import 自 `lib/generation/cards.ts:132`(DRY 漂移隐患) |
|
||||
| CR-H11 🟠 Drawer 关闭还原焦点 | ⬜ | @frontend | — | `components/Drawer.tsx:41` 关闭后焦点掉到 body(违 WCAG 2.4.3);传 `triggerRef`、关闭时 `.focus()` |
|
||||
| CR-H12 🟠 RefineView 取消在途 refine | ⬜ | @frontend | — | `components/style/RefineView.tsx:33` 无 AbortController,段切换时旧响应可覆盖新响应;`useRefine` 暴露 abort 在 effect cleanup 调用,去 blanket exhaustive-deps disable |
|
||||
| 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-M2 🟡 网关/adapters 整改批 | ⬜ | @llm | — | `gateway.py:90` `is_open()` 副作用违 CQS;`pricing.py` 未知 model 静默记 0 → 加 warning;adapter `str(exc)` 入异常消息可能带 vendor 响应体→只留类名+status |
|
||||
| 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-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-M5 🟡 前端 TS 边界类型收紧 | ⬜ | @frontend | — | `lib/api/server.ts:34` `parseJsonBody` 加最小结构校验;`useAccept.ts:60` `ApiErrorEnvelope` 从生成 schema 派生;`applyFix.ts` `hasApplicableFix` 改类型谓词去 `as string` |
|
||||
| CR-L1 🟢 LOW 清理批 | ⬜ | @backend·@llm·@frontend | — | `chain.py:256` 静默回退加 warning;SSE 端点补 OpenAPI `text/event-stream` 声明;冗余 `as`/`.valueOf()` cast 清理;测试脚本 `print`→logging |
|
||||
| CR-D1 🟡 抬高后端依赖下界(可选升级评估) | ⬜ | @devops | — | `pyproject.toml` 下界过旧(`langgraph>=0.2.40`/`anthropic>=0.34`),抬到接近 lockfile 实锁版本保可复现;后端实锁版本均当前。可选评估 vitest 3.x / tailwind 4.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 | — | `ed0a679` **前提证伪**:langgraph 1.2.5 默认上限=10007(非 25)、count≤50→~200 步,**无实活崩溃**;仍加显式**顶层** `recursion_limit=count*4+10`(防御硬化);测断言 config 键(行为「跑长链完成」测会 vacuous) |
|
||||
| CR-H1 🟠 Kimi OAuth 轮询不占 DB 会话 | ✅ | @backend | — | `ca8fbc8` 轮询移出会话:`_poll_for_token`(无会话)+`_run_kimi_oauth_poll`,仅最终 persist 走 run_job 短会话;`job_runner` 未动(与 style_learn 共享) |
|
||||
| CR-H2 🟠 修 `httpx.AsyncClient` 泄漏 | ✅ | @backend | — | `5dd336b`+`26f10d0` 请求级依赖改 `_http_client` 生成器(`async with`→`aclose`);两处测 override 跟随改,堵住 E2E 误触真网 |
|
||||
| CR-H3 🟠 Anthropic 结构化输出记真实 usage | ✅ | @llm | — | `cb8fddf` 改 `create_with_completion` 取 `raw.usage`,弃硬编码 0(修计费腐蚀) |
|
||||
| CR-H4 🟠 `request_id` 贯通网关日志 | ✅ | @llm | — | `51fe9f4`+`e08b01a` LlmRequest 加可选 `request_id` + **条件透传**(仅非 None,否则覆盖 contextvars 反退化 sync/SSE 追踪);`llm_call`+两处 `llm_provider_failed` 全锁测。契约 C1-扩 |
|
||||
| CR-H5 🟠 `thinking` 字段:实现或删除 | ✅ | @llm | — | `afeac51` 删死字段 `LlmRequest.thinking`(YAGNI);保 `Capabilities.thinking`(异名活字段);回写 ARCH/contracts |
|
||||
| CR-H6 🟠 流式路径加 per-provider 重试 | ✅ | @llm | — | `8a2b1b0` `_stream_with_retry` 对称 run:仅首 token 前重试,mid-stream 失败照抛(不变量 §4.5) |
|
||||
| CR-H7 🟠 `TOOLBOX` 注册表不可变 | ✅ | @backend | — | `25d48b7` `Final[Mapping]=MappingProxyType(_TOOLBOX_BY_KEY)`(仿 SPECS) |
|
||||
| CR-H8 🟠 `.env.example` 移除可用 Fernet key | ✅ | @devops | — | `baa41d3` 换不可用占位符(Fernet 拒绝→照抄即启动失败)+ 轮换提示。**⚠️ 真 key 仍在 git 历史,曾用该 key 的环境须轮换(用户)** |
|
||||
| CR-H9 🟠 API 文本输入加 `max_length` | ✅ | @backend | — | `e1a24a2`+`d6b53d0` 全请求文本字段加上限(api_key≤512/brief≤1e4/final_text≤2e5/segment≤2e4/directive≤2e4/instruction≤2e3…)+ **2 MiB body 中间件 413 兜底** + `PAYLOAD_TOO_LARGE`。契约 C3;`gen:api` 无漂移 |
|
||||
| CR-H10 🟠 前端 `errorCode` 去重 | ✅ | @frontend | — | `5665cc9` `lib/style/{useRefine,useStyleLearn}` 统一 import 自 `generation/cards` |
|
||||
| CR-H11 🟠 Drawer 关闭还原焦点 | ✅ | @frontend | — | `6e2b478` 行为本分支早已修;提取 `lib/a11y/useRestoreFocus` + renderHook 回归测试 + DRY(Drawer/ContextDrawer 共用) |
|
||||
| CR-H12 🟠 RefineView 取消在途 refine | ✅ | @frontend | — | `5c02792` useRefine 暴露 `abort`(setState-free)+ AbortController + aborted 守卫弃迟到响应;RefineView cleanup 调用 + 真 deps |
|
||||
| CR-M1 🟡 后端架构整改批 | ✅ 大部 | @backend | — | `c596a8d` outline volume 下推 DB WHERE(Protocol/Sql/fake 加 keyword `volume`);`bf3dabe` 角色/世界观入库 N+1→`create_many`(世界观 add_all;角色一次 IN 查+一次 flush 保 upsert 幂等/批内去重;单条 create 薄封装);`1392830` jobs 读端点裸 ORM→JobRepo;`0892ff6` projects/templates 列表加分页(`ww_api.pagination` limit∈[1,200]/offset≥0,越界 422,下推 LIMIT/OFFSET)。**item#4 provider_deps `_client`→已由 @llm 交付**(`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 改调,删私有 `_client` 反手 + `type:ignore`/`noqa:SLF001`;isinstance 收窄兜非 OpenAI 兼容)。**item#6 assemble 并行→SKIP**:8 查询共享同一注入 AsyncSession,`gather` 会腐蚀(ledger 警告属实);安全化须把 session 工厂穿透 assemble + 8 repo 各独立 session + 并发正确性测,对单用户几 ms 收益不值该回归风险 |
|
||||
| CR-M2 🟡 网关/adapters 整改批 | ✅ 大部 | @llm | — | `32451a9` pricing 未知 model 发 `pricing_unknown_model` warning(成本仍 0、照常记账);`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status(不泄 vendor 响应体)。**item `is_open()` CQS→SKIP**:冷却过读时半开清态是**故意的惰性转移**(唯一转半开点),拆 CQS 需两处调用方各先发命令、漏发则熔断永不复位(更差),风格分不值动熔断语义 |
|
||||
| CR-M3 🟡 LangGraph 链健壮性 | ✅ 大部 | @llm | — | `ec91c8f` runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`;SpyGraph 测同步补 aget_state 透传);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8,collect 仍一次落库;并发峰值测 + 五审落齐测)。**item `run_overdue_scan` 幂等→SKIP**:`scan_overdue` 本已幂等(单条 `UPDATE...RETURNING` + `status NOT IN [CLOSED,OVERDUE]` + commit-only-if-changed,原子无部分空洞),且下一章扫描 current+1 自愈漏标;再动会触碰验收事务边界,单用户零收益 |
|
||||
| CR-M4 🟡 前端 key/a11y/cleanup 批 | ✅ | @frontend | — | `47d5558` ARIA(NavDrawer `aria-controls` + Drawer `id` + ForeshadowCard 语义化 `<dl>/<dt>/<dd>`)+ `125908e` ReviewReport `flashMark` setTimeout 卸载清理(ref 存句柄)。**核实为无需改/已具备**:index-key(三处均 replace-all/append-only 静态展示、无天然稳定 id,index 安全;卡名可编辑作 key 更糟)、`stop`(经查**非**服务端 SSE 帧、是客户端 AbortController 动作,加则处理不存在事件)、AppShell nav label(`<nav aria-label>` 已有)、ProjectWizard 步骤焦点(`stepHeadingRef`+首帧守卫 useEffect 已实现) |
|
||||
| CR-M5 🟡 前端 TS 边界类型收紧 | ✅ | @frontend | — | `d80d41a` `ApiErrorEnvelope` 改由生成 schema(`components.schemas.ErrorEnvelope`)派生防漂移 + `9cf4915` `hasApplicableFix` 改对象参类型谓词、消除 `applyConflictFix` 两处 `as string` + `16f2eae` `parseJsonBody`(结构校验 HEAD 已具备,补非对象/非 JSON 守卫分支测试) |
|
||||
| CR-L1 🟢 LOW 清理批 | ✅ | @backend·@llm·@frontend | — | **@backend**:`49e3cb6` SSE draft/rewrite/review 补 OpenAPI `text/event-stream` 声明(共享 `_SSE_RESPONSE`);`print`→logging **无需改**(owned 目录唯一 `print(` 是 `export_openapi.py` CLI stdout,前端 gen:api 管道消费,改则断管道)。**@llm**:`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning。**@frontend**:`6414542` 去冗余 `as`/`.valueOf()` cast(`stream/sse.ts` idx.valueOf、`jobs/job.ts` `dict[error] as string`;保留 load-bearing 的 CSS-var 键/变量键强转) |
|
||||
| CR-D1 🟡 抬高后端依赖下界(可选升级评估) | ✅ | @devops | — | 8 个 pyproject 后端运行时下界抬到 lockfile 主次版本(`langgraph>=1.2`/`anthropic>=0.111`/`openai>=2.43`/`pydantic>=2.13`/`fastapi>=0.137`/`cryptography>=49` 等,每条 ≤ 实锁);`uv.lock` **纯元数据刷新**(仅 `requires-dist` specifier,零 pinned 版本变动);`uv lock --check` 绿、pytest **971**、ruff 干净。root dev 工具下界 + 前端 vitest/tailwind 升级评估未纳入(超范围) |
|
||||
|
||||
> **[2026-07-08] CRITICAL + HIGH 全部 ✅ 落地**(14 项 + 4 项低危收尾),多 agent 并行开发(@llm/@backend/@devops/@frontend 四簇串行整合,各 TDD 红→绿、逐项补回归测试)。**门禁全绿**:后端 ruff/format 干净 · mypy **229** · alembic **无漂移** · pytest **934**;前端 lint/tsc 干净 · vitest **669** · build OK · coverage **95.41%**。**14 项对抗式复核 0 problem**(skeptic 逐条实证 test 非 vacuous)。落地含 3 处前提证伪(C2 非实活崩溃/H4 sync-SSE 已由 contextvars 贯通/H11 行为本分支已修)。
|
||||
>
|
||||
> **[2026-07-08] MEDIUM/LOW(CR-M1..M5 / L1 / D1)亦全部清理 ✅**(四簇串行整合):明确取舍——高价值低风险项落地、风险>价值或前提已陈旧的子项**有据 SKIP/NOOP**(assemble 并行 gather / breaker is_open CQS / overdue-scan 已幂等 / index-key 安全 / `stop` 非服务端帧 / 已具备的 nav-label、wizard 焦点)。**门禁全绿**:后端 mypy **232** · alembic 无漂移 · pytest **971**;前端 vitest **681** · build · coverage **95.45%**。附带修复:一 agent 误提交的非源文件(`.coverage`/`AGENTS.md`/`example_skills`)已撤销还原为未跟踪(`1bda024`)。
|
||||
>
|
||||
> **维度小结**:后端 FastAPI 架构 0C/2H/7M·Python 规范 0C/4H/6M·LangGraph 1C/2H/5M·前端 React 0C/2H/9M·前端 TS 0C/1H/8L·安全 0C/2H/4M·依赖 1C。后端依赖全部当前大版本线;前端唯一关键缺口=Next RCE。
|
||||
>
|
||||
> **[2026-07-08] CR-M1 + CR-L1 后端片 ✅ 落地**(@backend,6 提交 `c596a8d`/`bf3dabe`/`1392830`/`0892ff6`/`49e3cb6`,各行为改动先写红测再实现)。CR-M1 4/6 完成(volume 下推 · N+1→create_many · jobs→JobRepo · 列表分页),item#4(去 `_client`)与 item#6(assemble 并行)SKIP(理由见 CR-M1 行);CR-L1 后端片完成(SSE OpenAPI;print 属误报)。**门禁全绿**:后端 ruff/format 干净 · mypy **230** · pytest **960 passed**(较基线 955 +5 新测:volume 下推 1 + 分页 2 + 批量入库 2)· 无模型改动故免 alembic。新增 5 测无回归,既有 upsert 幂等/冲突 gate + m5 真 pg E2E 全绿。
|
||||
>
|
||||
> **[请求 → @llm]** CR-M1 item#4:`apps/api/ww_api/services/provider_deps.py:91` 需去掉对 adapter 私有 `_client` 的探测(`adapter._client.models.list()`)。owning 类 `OpenAICompatAdapter`/`ProviderAdapter`(`packages/llm_gateway/ww_llm_gateway/adapters/`)在 @llm 域——请在 adapter 上加一个公开 `async probe_connection()`(内部最小请求验 Key,如 `models.list()`),@backend 改调该公开方法即可删私有属性访问 + `type: ignore`/`noqa: SLF001`。行为保持不变。
|
||||
|
||||
---
|
||||
|
||||
## 进行中:写作工作台重构(writing-first)· 分支 `feat/writing-first-workspace`
|
||||
|
||||
> **背景**:[2026-07-07] 用户对写作体验提 8 点反馈,产品主张升级为 **writing-first 工作台**——进来即写、所有工具/AI 动作从编辑器内按需调用并回填正文、元数据由内容渐进生成。多 agent 工作流(扎根代码→Web+GitHub 调研→三方设计→四镜头对抗评审→定稿)产出 DEV_PLAN(`scratchpad/writing-first-workspace-DEVPLAN.md`,**ephemeral 未入库**;灵感/取舍见 `memory/selectable-write-prompts.md`)。
|
||||
> **已拍板决策**:进来即写=**延迟落库**(首次击键才建占位项目 + 列表过滤空稿);可选提示词 MVP=**纯前端** directive→volatile(不改 `DraftStreamRequest` 契约、不动 `write_craft.md` 字节 → 金标准零回归);预设 MVP=先只上内置;续写=多候选卡点选插入。
|
||||
> **纪律**:全程 TDD 小步提交,回填全守 HITL(AI 绝不 auto-replace,点选/接受才落草稿);`/refine`、`/rewrite` 只读不写库(不变量 #3)。**门禁全绿**:前端 vitest **678** / lint / tsc / build / coverage **95.36%**;后端 ruff / mypy **229** / pytest **955**(含 WFW-9 clarify E2E)/ alembic 无漂移。**WFW-0~9 全部交付**(WFW-5/6/7 多 agent 并行 → `3dd7d28`;WFW-8 整章重写跨栈 → `6ed9c89`+`542c2ab`;**WFW-9 三段全交付** M1 `1652ad9`+`e30c933` / M2 `4a09f27`..`8e29b33` / M3 `ea9a9d9`+`a703a90`,契约 C-Rewrite+C-Clarify 见 `memory/contracts.md`)。**未 push、未开 PR**(等用户示意)。
|
||||
|
||||
| 任务 | 状态 | 负责 | 依赖 | 反馈# | 备注 |
|
||||
|---|---|---|---|---|---|
|
||||
| WFW-0 进来即写(延迟落库) | ✅ | @frontend | — | #5 | `8389572` `/write`(DraftComposer) + `lib/start/useDeferredDraft`(懒建占位项目+种首章草稿,双重幂等,空进空出不落库) + 落地页 CTA「直接开始写」主/「先立项」辅 |
|
||||
| WFW-1 润色 / 再沟通 | ✅ | @frontend | — | #1#2 | `2f0b841` `useRefine`(同步/refine,版本栈,再沟通=上一版产出+新意见再回炉) + `refineApply`(接受回填按内容重锚,原文变则提示重选) + RefinePanel 内联卡 + Editor 上报选区 |
|
||||
| WFW-2 续写(多候选) | ✅ | @frontend | WFW-0 | #2 | `9195cf3` `useContinue`(continue 生成器 with_prior_chapter 读前文,多候选累加) + ContinuePanel 候选卡点选插入章末 + flush 后读最新草稿 |
|
||||
| WFW-3 内联工具箱 + 金手指露出 | ✅ | @frontend | — | #4#8 | `04f4785` `useToolboxTools`(客户端拉描述符) + InlineToolbox(列非 legacy 生成器含金手指) + `GeneratorRunner` 加 `onInsertText`(文本产物插入正文) |
|
||||
| WFW-4 AI 写作三槽(文风/剧情/自定义) | ✅ | @frontend | — | #3 | `44b0b8a` `directive.ts` 加 `PLOT_PRESETS` + `composeDirective` 合并;DirectivePanel 拆文风/剧情两组 chips + 自定义。纯前端零契约,`write_craft.md` 字节不变 |
|
||||
| WFW-5 内容感知书名 | ✅ | @frontend | — | #6 | `3dd7d28` 纯前端零后端 MVP:`buildManuscriptExcerpt`(首尾摘录+硬上界) + GeneratorRunner「用当前正文」按钮把摘录填 brief,book-title 据正文反推。**后续更佳版**:后端富化 book-title.md + `with_manuscript` 策略(未做) |
|
||||
| WFW-6 侧栏 IA 收敛(ContextDrawer) | ✅ | @frontend | — | #7 | `3dd7d28` 加法式 ContextDrawer 右侧速查抽屉 + 底栏「速查」按钮;设定库/大纲**完整只读速查**,伏笔/规则/文风**摘要+跳全宽页**(后续可补真实数据)。`CONTEXT_DRAWER_ENABLED` 一键回滚、旧侧栏/全宽页路由全保留 |
|
||||
| WFW-7 首次 AI 写作前极轻 genre 采集 + 无大纲降级 | ✅ | @frontend | WFW-0 | — | `3dd7d28` 无题材首次写章前弹 GenrePicker(临时并入本次 directive 不落库) + chapters 空时「尚无大纲,将按设定自由生成」提示,杜绝无题材 slop |
|
||||
| WFW-8 整章级「再沟通/重写」(对话式迭代) | ✅ | @backend·@llm·@frontend | — | 新#1 | `6ed9c89`(后端)+`542c2ab`(前端)。后端 SSE `POST .../rewrite`(`rewrite.md` 教条 + `rewrite_node` + `RewriteStreamRequest`,复用 assemble 记忆注入,只读不写库、工具非写节点、守 #3/#7/#9);前端 `useChapterRewrite`(版本栈)+`ChapterRewritePanel`+底栏「整章重写」:给意见→流式重写整章→多轮迭代→接受某版才替换正文(HITL)。契约 C-Rewrite。用户反馈「整章不满意要跟 AI 交流」 |
|
||||
| WFW-9 M1 AI 反问澄清(refine 侧,路线A 两阶段) | ✅ | @backend·@llm·@frontend | — | 新#2 | `1652ad9`(后端)+`e30c933`(前端)。多agent并行(后端整片‖ChoiceChips)+主线集成。非流式预检 `POST .../refine/clarify`→`ClarifyDecision`(结构化,analyst,只读,失败回退放行)+clarify_refine spec/教条(SPECS#24);既有 refine 未改。前端 `useClarify`+`ChoiceChips`+`clarify.ts`(foldClarifications/门控)+RefinePanel 门控接线:意见<10字或点「帮我理清方向」→AI反问选项→折答案进instruction→走既有refine。契约 C-Clarify。**M1 refine 侧完成;M2/M3 见下两行** |
|
||||
| WFW-9 M2 AI 反问澄清(rewrite 侧,整章重写路径) | ✅ | @backend·@llm·@frontend | — | 新#2 | `4a09f27`+`6fd6375`(后端)+`9b32743`+`4b1088a`+`8e29b33`(前端),多 agent 串行整合。新非流式预检 `POST .../rewrite/clarify`→`ClarifyDecision`(复用 M1 schema/`run_clarify`)+新 `clarify_rewrite` spec/教条(章级,SPECS 24→25,prompt_hashes +1)+`RewriteClarifyRequest{feedback,prior_draft?}`(仅取有界 3000 字摘录喂 analyst,不整章入 LLM);只读不写库、失败放行。前端 `useRewriteClarify`+`toVM/PROCEED` 提取入共享 `clarify.ts`(DRY)+`ChapterRewritePanel` 门控(镜像 RefinePanel):意见<10字或「帮我理清方向」→反问→折进 feedback→走既有 rewrite SSE。契约 C-Clarify 扩;gen:api 纳入新端点 |
|
||||
| WFW-9 M3 澄清 UX 打磨 + E2E | ✅ | @frontend·@qa | — | — | `ea9a9d9`(a11y):refine/rewrite 两面板 AI 反问块加 `role=status` 向读屏动态播报(WCAG 4.1.3 Status Messages)。`a703a90`(E2E):`tests/test_wfw9_clarify_e2e.py` 7 用例真 pg+mock 网关零 token——refine/rewrite 含糊→反问 / 清晰→放行 / 失败开路(parsed=None→200 need_clarification=false,不 500) / 只读不写业务表但记 usage_ledger / 404。无产品 bug。**深度视觉/交互 QA(真起前端浏览)为人工验收事项** |
|
||||
| WFW-9 M2 AI 反问澄清(rewrite 侧,路线A 两阶段) | ✅ 后端 | @llm·@backend | WFW-8·WFW-9 M1 | 新#2 | `4a09f27`(llm)+`6fd6375`(后端)。镜像 M1:非流式预检 `POST .../rewrite/clarify`←`RewriteClarifyRequest{feedback(必给),prior_draft(可选)}`→`ClarifyDecision`(复用同一 schema,analyst,只读,失败回退放行)+`clarify_rewrite_spec`/教条 `clarify_rewrite.md`(segment→整章,SPECS#25);既有 `.../rewrite` SSE 未改。端点只喂 prior_draft 开头 3000 字摘录(不整章入 LLM)、日志脱敏。契约 C-Clarify(扩)。门禁绿:后端 ruff/format·mypy229·pytest948·alembic 无漂移。**前端仍欠 `pnpm gen:api`+M2 UI 接线(仿 RefinePanel 门控 ChapterRewritePanel) + M3 UX/E2E 待办** |
|
||||
| WFW-9 E2E(两阶段 AI 反问澄清,refine+rewrite) | ✅ | @qa | WFW-9 M1·M2 | 新#2 | `tests/test_wfw9_clarify_e2e.py` 7 用例真 pg + mock 网关(provider/adapter 层,同 M2/T6 范式)零 token:refine 含糊→反问1问 / refine 明确→放行、rewrite 含糊→反问 / rewrite 明确→放行、失败开放(网关产非 ClarifyDecision→仍200 need_clarification=false 不 500 且仍记账)、只读(seed chapter 字节一致+无 review/digest 行)但 usage_ledger 真落 analyst 一行、404 信封。响应绝不回灌原文摘录。无产品 bug。门禁绿:ruff/format·mypy 本文件净·pytest 955 passed |
|
||||
|
||||
---
|
||||
|
||||
@@ -161,6 +208,20 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
||||
|
||||
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
||||
|
||||
- [2026-07-10] @frontend — **✅ 写作台 UX 改造 P2-1..P2-3 全落地 → P0/P1/P2 整套 IA 改造完成**(`docs/design/ux-exploration.md` §4/§5,P2-4 按计划延后不做)。**P2-1 左栏分组**:`lib/nav/items.ts` 新增纯函数 `navSections(projectId?)`——把 10 个平铺项目项归为 **写作 / 资料库(大纲·设定库·伏笔·文风)/ 审阅(审稿·多章链)/ 更多(工具箱·规则·技能库)** 四组(全局组 作品库/模板库/设置 无标题恒在顶部),`NavItems.tsx` 复用现有「本作」小标题样式逐组渲染小标题(桌面侧栏/移动抽屉共用,链接/路由/激活键均不变,纯重排+加标题);工具箱在原分组清单缺列,收进「更多」以不丢入口(记 decisions)。**P2-2 目录归位(可折叠列)**:新增 `lib/workbench/useChapterListCollapse.ts`(`localStorage` 键 `ww.workbench_chapter_list_collapsed`,SSR 首帧恒展开避免 hydration 不一致、挂载后恢复、toggle 即时写回),`ChapterList` 收起时降为一条带「展开目录」按钮的窄轨(`2.75rem`)、展开时目录标题旁加「收起目录」按钮,`Workbench` 三栏栅格据折叠态在 `xl:grid-cols-[2.75rem_1fr_18rem]`↔`[12rem_1fr_18rem]` 间切换(章节列表/当前章高亮/切章导航全保留;窄屏抽屉不受影响、不带折叠按钮)。**P2-3 放置规则**:`memory/decisions.md` 落一条四象限唯一落点规则——导航→左栏、AI 动作→中央输入条+「或让 AI:」行、章节状态→底栏、参考/透明度→右栏「本章参考」,防三重复回潮。**P2-4 统一对话面板按计划延后**(§3/§4-P2-4/§6 明列为「先观察的大赌注、非开局」,本波不实现)。**TDD**:`navSections` +6 断言(分组标签/成员/全项恰好一次覆盖)、`useChapterListCollapse` +3(默认展开/toggle 持久化/挂载恢复)均 RED→GREEN。**门禁全绿**:lint/typecheck 干净 · vitest **716 passed**(710 基线 +6 新)· `test:coverage` 通过(`lib/**` 95.43% ≥ 80)· `build` OK。仅动 `apps/web/` + `memory/decisions.md` + `PROGRESS.md`。
|
||||
- [2026-07-10] @frontend — **✅ 写作台 UX 改造 P1-1..P1-5 全落地**(`docs/design/ux-exploration.md` §4,接 P0 新布局)。**P1-1 底栏瘦身 + act-on-chapter/导航分家**:把生成动词(续写/润色选段/整章重写/工具箱)从底栏搬进中央输入条正下方常驻的**「或让 AI:」次级动作行**(新 `AiVerbsRow`/`VerbButton`,读作「同一个 AI 的其它用法」,生成中禁用而非隐藏,触发沿用既有 `openContinue/openRefine/openRewrite/openToolbox`——仅搬家不改行为);删底栏纯导航「大纲」「伏笔」(左栏已有,连带下线 `BookOpen/Library` import);**唯一强调的「审稿」** 作为写→审→验收前进 CTA 保留(outline);`写本章`「发送键」上移到 `DirectivePanel` 表头**常驻可见**(折叠时也在,streaming 切「停」),底栏遂只剩 字数/生成中 · 保存 · 里程碑播报 · streaming 时的「停」(sticky 底栏够得着)+ 审稿 CTA + 速查/对话记录抽屉入口。**P1-2 统一「采用」动词族**:`ConversationThread.ACCEPT_LABEL`→采用(替换整章)/采用(加到章末)/采用(替换这段);`RefinePanel`「接受回填」+`ContinuePanel`「插入正文」+`ChapterRewritePanel`「接受最新版」同步;弃「回填」(含 Workbench 三处 toast 与两处面板描述)。**P1-3 右栏去术语(不降级,守 #6 透明度)**:`ChapterAssistant`「本章助手」→本章参考、「本章注入(透明)」→AI 写这章会参考、「四审」→写完后检查(白话描述,**删重复的「去审稿」快捷** + 去 `next/link` import)、空/加载/错误态与「已排除」及 pin/排除 aria 全部注入→参考白话;实体透明内容本身未动;移动抽屉 label 同改本章参考。**P1-4 四动词可见范围提示**(非 hover-only):写本章=整章·从头写(表头按钮内)、续写=接着往下写、整章重写=整章·基于现有重写、润色选段=选中段·打磨(「或让 AI:」行内 2xs 副标签)。**P1-5 题材去弹窗**:删中途弹出的 `genrePromptOpen`/`onPickGenre`,改为项目无题材时正文上方**常驻内联题材条**(复用 `GenrePicker`,`onPick=setChosenGenre` 不自动开写)→ 首次「写本章」顺滑流式不被打断;no-slop 守卫保留(未选题材点写本章给 toast 引导而非静默生成)。守 §3:五交互未并框、写本章仍流式进正文、右栏透明度未删。**纯表现层,无新增 `lib/**` 逻辑**(`useGenreGate`/`directive` 未动 → 无新 vitest)。**门禁全绿**:lint/typecheck 干净 · vitest **710 passed**(基线持平)· `test:coverage` 通过(`lib/**` 95.47% ≥ 80)· `build` OK。仅动 `apps/web/`。**决策**:速查/对话记录(两抽屉,flag 均 true)非导航亦非那 4 个生成动词,留在底栏作辅助入口(保全接线,符「底栏 essentially 只剩状态 + 审稿」)。**P2(左栏分组/目录归位/放置规则)待后续。**
|
||||
- [2026-07-10] @frontend — **✅ 写作台 UX 改造 P0-1..P0-5 全落地**(`docs/design/ux-exploration.md` §4,回答用户原话「我怎么和 AI 对话」)。**P0-1** 底栏「AI 对话」→「对话记录」(只读日志正名,让「对话」一词归还真输入框):`AiConversationDrawer` 标题/`aria-label`/关闭 aria/空态(→「这里留存你和 AI 的往复记录」)+ 底栏按钮文案/title 同改。**P0-2** 把折叠 `<details>`「本章写作指令[可选]」原地提升为正文正上方**常驻输入条**(`Workbench.tsx` 内联 `DirectivePanel`):标题「告诉 AI 这章想怎么写」、去「可选」徽标、空章默认展开(`defaultOpen={initialText 空}`,受控 `useState` 取代原生 details)、「AI 写作诉求」→「你的写作要求」、textarea 邀请式 placeholder,**发送键即「写本章」**(内联 primary 写本章 / streaming 时切「停」,复用既有 `onWrite`/`stop` + `composeDirective` + 文风/剧情 chips + 流式打字机,**未改成聊天气泡**)。**P0-3** 删顶部「AI 工具条」:`AppShell` 去 `AiToolbar` 渲染 + `--chrome` 恒 4rem(原 7rem,省 48px),`git rm` 死代码 `components/AiToolbar.tsx`+`AiToolbarMoreMenu.tsx`+`lib/nav/ai-tools.ts`+`ai-tools.test.ts`(全仓仅 AppShell 用,无其它 importer)。**P0-4** 底栏「润色选段」常驻可点(去 `disabled`/hover-only title),无选区点击弹 toast「先在正文里选中一段再润色」(`openRefine` 守卫 + `canRefine` 从 `Toolbar` props 下线,仅留 Workbench 内做面板渲染守卫)。**P0-5** 输入条旁常驻一行 HITL 微文案「AI 给草稿,你决定采不采用,原文永远你说了算」+ 可再发现的「?这是什么」弹层(`HitlHelp`,非一次性 coach,Esc/点击外部关闭)。守 §3:五种交互未并框、写本章未改聊天、右栏透明度未删、未用 Sparkles 作 AI 标记、润色未藏到有选区才现。**纯表现层改造,无新增 `lib/**` 逻辑**(`directive.ts` 未动 → 无新 vitest;删 `ai-tools.test.ts` 使基线 715→710)。**门禁全绿**:lint/typecheck 干净 · vitest **710 passed** · `test:coverage` 通过(`lib/**` 95.47% ≥ 80)· `build` OK。仅动 `apps/web/`。**P1(底栏瘦身/统一采用词/右栏去术语)+ P2(左栏分组/目录归位/放置规则)待后续。**
|
||||
- [2026-07-10] @frontend — **✅ AC-3 后续:润色/整章重写面板内联对话气泡流**(用户反馈:不想每次开「AI 对话」抽屉才看得到对话,要在对话时就地内联可见)。抽出复用组件 `components/workbench/ConversationThread.tsx`(把抽屉内联的 `Bubble`/`readOptions`/`ACCEPT_LABEL` 提取为共享气泡列表,抽屉与内联面板共用 → 外观一致 DRY);新纯助手 `lib/workbench/inlineConversation.ts`(`mergeInlineThreads` 把已落库留痕〔本章 + 指定 kinds〕与「进行中」实时气泡按 id 去重合并、复用 `groupByThread` 定序;`toPendingView` 合成未落库气泡〔`meta._pending/_streaming` UI 标记〕;`isPendingView/isStreamingView/readSegment`)+ **RED→GREEN 9 单测**。RefinePanel/ChapterRewritePanel 内联渲染 `role=log` 滚动气泡流(本章 refine/rewrite + 其 clarify 往复 **∪** 本轮进行中气泡:流式 token 逐字进气泡、缓冲的 clarify Q&A 亦实时可见),输入框置底像聊天;**pending→persisted 无重影**(进行中气泡结束即清、已落库经 `mergeById` 去重);**fail-soft**(历史加载失败仍显示本轮实时气泡,不阻塞回炉/重写);气泡「再接受/回填」复用既有 DRAFT-only HITL 回调(drift-safe,正文仍只在验收才变);clarify gating/abort/停/接受悉数保留。**抽屉保留原样**(底栏仍可开,跨章/项目级全量历史浏览)。**门禁全绿**:lint/typecheck 干净 · vitest **715 passed**(706 基线 + 9)· `test:coverage` 通过(`lib/**` 95.48% ≥ 80)· `build` OK。仅动 `apps/web/`。
|
||||
- [2026-07-09] @qa — **✅ AC-4 E2E + #1/#6 真源围栏守卫|AI 对话(聊天记录)AC-1..AC-4 全交付**(计划 §6/§8)。**E2E**(`tests/test_ai_chat_history_e2e.py`,真 pg、**零 LLM 无网关**——纯 CRUD 侧记录,镜像 `test_t6_toolbox_e2e.py` 的 `e2e_sm` 生命周期)覆盖 §6 全 6 用例:① 缓冲多轮线程一批落库(作者含糊→AI 反问〔选项进 meta〕→作者答→AI 定稿,同 `thread_id`/`seq` 0..3)→ GET newest-first(`seq`/`role`/`thread_id`/`meta{options,version_no,segment}` 往复保真、全文 content 未截断);② 作用域分区(章级 ∪ 项目级 `chapter_no IS NULL`——`?chapter_no=N` 两者都返回、缺省仅项目级、项目级现于任一章抽屉);③ 跨多批 newest-first + `limit/offset` 分页 + `kind` 过滤;④ **append 只写 `ai_messages`**(UsageLedger/Chapter/ChapterReview/WorldEntity/Job 逐表零变化、`usage_ledger` 恒 0 证无网关)、GET 只读不建行;⑤ 未知项目 POST/GET 皆 404、非法 role/kind·空 messages·超上限 content/meta 五类 422 且失败零落库;⑥ clarify-abandon 代理(服务端无部分写路径,只持久化被显式 POST 的已定线程)。**围栏**(`tests/test_ai_messages_not_in_generation_path.py`,AST 静态守卫,**能变红**):memory/orchestrator 两目录 23 文件无一 import/引用 `AiMessage*`/`ai_message_repo`(Name/Attribute/Import 全扫)+ `MemoryRepos` 捆绑与 `assemble` 形参都不含它 + 植入违规自证探测器非空——把 ai_message repo 接进 `assemble()` 会直接失败(已用真 assemble.py 源注入验证变红)。守 #1/#6:聊天记录是 append-only 旁路侧记录(同 `usage_ledger` 物种),绝不喂 prompt/记忆注入。**门禁全绿**:ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移** · pytest **1002 passed**(992 基线 + 10:E2E 6 + 围栏 4)。gotcha 已登记(ai_messages 真源围栏 = code-review BLOCKER)。仅动 `tests/` + `memory/gotchas.md` + `PROGRESS.md`,**未碰任何产品代码**。
|
||||
- [2026-07-09] @frontend — **✅ AC-3 gen:api + AI 对话 hook/抽屉 + 五类交互留痕**(计划 §4,`dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`)。**gen:api**(`dce882c`):`POST/GET /projects/{id}/ai-messages` + `AiMessage*` 4 schema 入 `schema.d.ts`(openapi.json 为 gitignore 构建产物),`lib/api/types.ts` re-export 码生成名。**核心**(`ddf7fa0`):`lib/workbench/aiConversation.ts` 纯视图模型(`kindLabel/kindBadge`、`bubbleSide`、`groupByThread` 按 `(created_at,seq)` 组内+线程定序〔**绝不单靠 created_at**——同批共享 created_at 靠 repo 的 seq 消歧〕、`partitionByScope`〔本章 ∪ 项目级〕、`acceptActionFor`〔rewrite→替换整章 / continue·generator→插入正文 / refine→回填选段 / clarify→无〕)+ 11 单测;`lib/workbench/useAiConversation.ts` Workbench 级单实例(懒加载去重 + `mergeById` 防并发覆盖 + 乐观插入/正位对账/失败回滚,**fail-soft 绝不 throw 进生成/HITL**〔守 #3 意图〕;`silent` 直投供无抽屉的工具箱页)+ 9 renderHook 测试;`components/workbench/AiConversationDrawer.tsx` 克隆 ContextDrawer 的 a11y(`role=dialog/log`、`aria-live` additions、Esc、focus trap、还原焦点、prefers-reduced-motion),本章对话 + 项目级生成两段气泡、clarify 只读选项芯片、ai 气泡挂 DRAFT-only 再接受。**hook 返回产物**(`59f2f60`):四生成 hook 加返回值(`RefineVersion|null`/`string|null`/`RewriteVersion|null`/`GeneratorArtifact|null`,向后兼容,补成功用例断言)。**五处接线**(`33a9b76`):RefinePanel/ChapterRewritePanel 结束后 append `[author(意见/答复), ai(产出)]`,**clarify buffer-until-concluded**——澄清 Q&A(原含糊意见→AI 反问〔选项进 meta〕→作者答复)缓冲,随最终产出批一起落库同 `thread_id`,**纯放弃不记**;refine ai 行 meta 存 `version_no`+原选段(每版重锚);ContinuePanel ai-only(续写无作者输入)meta `candidate_index`;GeneratorRunner append `[author(字段摘要,meta{tool_key,input_fields}), ai(渲染预览,meta{tool_key,output_kind})]`(空产物不记,`lib/toolbox/aiSummary.ts` 纯助手+5 单测),InlineToolbox 挂本章号、ToolboxPage silent 项目级(`chapter_no=null`);Workbench 挂 hook 单实例 + 抽屉 + 底栏「AI 对话」入口(`AI_CONVERSATION_ENABLED` 灰度)+ 三个再接受回调(成功关抽屉+toast、refine 漂移不盲替换)。**留痕永不改「是否/何时写正文」**——正文仍只在验收事务落库(守 #3)。**门禁全绿**:lint/typecheck 干净 · vitest **706 passed**(681 基线 + 25:aiConversation 11 + useAiConversation 9 + aiSummary 5)· `test:coverage` 通过(`lib/**` 95.44% ≥ 80,新纯模块/hook 全测)· `build` OK。→ 剩 **AC-4 @qa** E2E(计划 §6)+ P4 #1/#6 围栏。
|
||||
- [2026-07-09] @backend — **✅ AC-2 repo + append/list 端点 + schema + 契约**(计划 §3,`c675a74`)。repo `packages/core/ww_core/domain/ai_message_repo.py`(`AiMessageRepo`/`SqlAiMessageRepo`/`AiMessageView`/`AiTurnRow`,经 `ww_core.domain` 导出):`append`(一批多条 bubble → 批内 `seq`=enumerate 0 基位置、同 `thread_id`、meta 新 dict 不原地 mutate、`add_all`+一次 flush+refresh,**只 flush 不 commit**) + `list_for_chapter`(`created_at DESC, seq DESC` newest-first;`chapter_no` 非 None → 本章 ∪ 项目级 `IS NULL`,None → 仅项目级;可选 `kind`;`limit/offset`)。schemas `apps/api/ww_api/schemas/ai_messages.py`:批级 append 信封 `AiMessageAppendRequest{thread_id,chapter_no(ge=1)|null,kind,tool_key,messages}` + `AiMessageInput{role,content(min1,max200_000),meta(序列化≤50_000)}` + `AiMessageView` + `AiMessageListResponse{messages}`;`Literal` 为 kind/role 枚举权威(边界 422)。端点 `apps/api/ww_api/routers/ai_messages.py`(prefix `/projects`,tag `ai-messages`,已注册 main):**`POST /projects/{id}/ai-messages`**(201,项目 404,校验 422,append 只 flush + 端点 `commit()`) + **`GET /projects/{id}/ai-messages?chapter_no=&kind=`+`PageDep`**(200,newest-first,只读)。**这是显式记录写入**——五个生成端点逐字节不变、只读(守 #3);`ai_messages` 旁路侧记录,`assemble()`/agent 永不读(守 #1/#6)。注入缝 `get_ai_message_repo`。**TDD**:repo 真 pg 单测 9 passed(seq 0..n-1 / 同 thread_id / `created_at DESC,seq DESC` / union 含 NULL / 分页 / flush-only 跨 session 不可见);端点集成 12 passed(round-trip / 本章∪项目级 / kind 过滤 / 404×2 / 422×5〔非法 role/kind、空 messages、空 content、超上限 content、超上限 meta〕 / commit 边界 / 响应 key 集合不泄漏)。**门禁全绿**:ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移**(本任务不加模型)· `export_openapi` 成功(新端点+4 schema 已入表面)· pytest **992 passed**(971 基线 + 21)· 新模块 cov router 100%/schemas 100%/repo 97%(余 Protocol `...` 桩),全局 89%。契约 **C3 扩(AC-2)已登记「稳定」** → **@frontend AC-3 须 `cd apps/web && pnpm gen:api`** 后消费码生成名。
|
||||
- [2026-07-09] @db — **✅ AC-1 `ai_messages` 表 + 迁移**(计划 §2 canonical DDL)。`packages/db/ww_db/models.py` 加 `AiMessage(UuidPk, CreatedAt, Base)`(补 `Uuid` 导入):`project_id`(FK projects CASCADE, NOT NULL, indexed)/`chapter_no`(Integer nullable=项目级)/`thread_id`(Uuid NOT NULL)/`seq`(Integer NOT NULL, repo 批内 0 基定序)/`kind`(Text 无 CHECK)/`tool_key`(Text nullable)/`role`(Text + `ck_ai_messages_role` CHECK author|ai)/`content`(Text NOT NULL 全文)/`meta`(JSONB NOT NULL server_default `'{}'`)/`id`+`created_at`(mixin);append-only 无 updated_at。复合索引 `ix_ai_messages_project_chapter=(project_id,chapter_no,created_at DESC)` 手写 `sa.text` DESC + FK 单列索引。迁移 `ceabd3c2c00d`(down=`f6a7b8c9d0e1`,**autogenerate 仅捕获 ai_messages 无杂散漂移**,DESC 表达式归一为 `sa.text`)。**T0.2 MVP schema 后第一张新业务表**——对话历史是 18 表都无的能力、正交于 review/job/手稿,属真新增(非重复建表);是**旁路侧记录非手稿真源**,`assemble()`/agent 永不读(守 #1/#3/#6,围栏留 AC-4/P4 收口)。**门禁全绿**:ruff/format 干净 · mypy **233 文件** · `alembic upgrade head` + **`alembic check` 无漂移** + **downgrade→upgrade 往复干净** · pytest **971 passed**(基线不变,无代码用新表)。契约 **C2 扩(AC-1)已登记「稳定」** → AC-2 @backend 可动工。
|
||||
- [2026-07-08] @devops — **✅ QA-CR CR-D1 抬高后端依赖下界至 lockfile 主次版本(保可复现)**。8 个 pyproject(`apps/api` + `packages/{core,agents,config,db,llm_gateway,shared,skills}`)的后端运行时依赖下界从过旧值抬到实锁 MAJOR.MINOR:`langgraph>=1.2`/`langgraph-checkpoint-postgres>=3.1`/`openai>=2.43`/`anthropic>=0.111`/`google-genai>=2.9`/`instructor>=1.15`/`tenacity>=9.1`/`pydantic>=2.13`/`pydantic-settings>=2.14`/`structlog>=26.1`/`asyncpg>=0.31`/`alembic>=1.18`/`fastapi>=0.137`/`uvicorn>=0.49`/`cryptography>=49`(每条 ≤ lockfile 实锁,同小版本线的 sqlalchemy/psycopg 留原样)。`uv.lock` 变动为**纯元数据刷新**——仅各 workspace 成员 `[package.metadata].requires-dist` 的 specifier 字符串随下界更新,**零 pinned `version=` 变动、零 re-resolve、零第三方版本 bump**。root dev 工具下界(pytest/ruff/mypy/httpx)+ 前端 vitest 3.x/tailwind 4.x 升级评估**明确不纳入**(非运行时可复现关键,超范围)。**门禁**:`uv lock --check` 绿(元数据刷新后一致)· `uv run ruff check .` 干净 · pytest **971 passed**(与基线一致,无回归)。无源码/测试改动。
|
||||
- [2026-07-08] @llm — **✅ QA-CR MEDIUM/LOW 网关+链整改批(CR-M2/M3/L1@llm/M1.4,TDD 逐项提交)**。**完成 6**:`32451a9` pricing 未知 model 发 `pricing_unknown_model` warning(成本仍 0);`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status(不泄 vendor 响应体);`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 弃私有 `_client` 反手(CR-M1.4);`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning(CR-L1);`ec91c8f` 链 runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8,collect 一次落库)。**SKIP 2**:CR-M2 `is_open()` CQS(惰性半开转移是唯一转半开点、拆分漏发命令会致熔断永不复位,风险>收益);CR-M3 `run_overdue_scan` 幂等(`scan_overdue` 本已是原子 `UPDATE...RETURNING`+`NOT IN[CLOSED,OVERDUE]`+commit-only-if-changed,且下一章 current+1 扫描自愈漏标,再动会触碰验收事务边界)。**门禁全绿**:后端 ruff/format 干净 · mypy **232 文件** · pytest **971 passed**(+11 新测:pricing 2 / 错误脱敏 4 / probe 2 / 续写缺前文告警 2 / 并行审 1)· 无 DDL/无迁移。守不变量 #1/#3/#4(熔断/验收事务/add-only 记账 sink 未动)。
|
||||
- [2026-07-08] @llm/@backend/@devops/@frontend — **✅ QA-CR CRITICAL + HIGH 整改批全部落地**(14 项 + 4 低危收尾)。多 agent 工作流:先 14 个只读 agent 逐项**扎根真实代码**产精确修复+测试规格(纠出 3 处台账前提漂移),再按 owner 四簇**串行整合**入单工作树(避免并发写树 + repo 级 `ruff format .` 竞态),各 TDD 红→绿、逐项补回归测试、逐 fix 小步提交,末尾 14 个 skeptic **对抗式复核**(实证每条 test 非 vacuous,0 problem)。**关键落地/纠偏**:C1 next 15.1.3→**15.5.20**+react **19.2.7**(`pnpm audit` 清、`dependencyFloors.ts` 下界守卫,**CVE 号无法对真库核验+密钥轮换为用户运维事项**);C2 **前提证伪**(langgraph 默认上限 10007 非 25,无实活崩溃)仍加显式顶层 `recursion_limit`;H4 request_id **条件透传**(否则覆盖 contextvars 反退化);H2 修请求级 httpx 泄漏时**揪出 E2E 会误触真网**(override 跟随修);H9 全文本字段上限+2 MiB body 中间件 413 兜底(契约 C3,`gen:api` 无漂移);H8 `.env.example` 换不可用占位符(**真 key 仍在 git 历史,曾用环境须轮换**)。**门禁全绿**:后端 mypy 229/pytest **934**/alembic 无漂移;前端 vitest **669**/build/coverage 95.41%。契约 C1-扩(LlmRequest.request_id)+ C3(H9 caps)。**MEDIUM/LOW 择批后清理。** 提交 `cb8fddf`→`dd6d2fc`(19 提交)。
|
||||
|
||||
- [2026-07-08] @backend — **✅ QA-CR 后端整改 5 项(TDD,逐 fix 提交)**。**CR-C2**:`chain_runner.run_chain_job` 给 `graph.ainvoke` 传显式 top-level `recursion_limit=count*NODES_PER_CHAPTER+HEADROOM`(防御硬化,命名常量)。**CR-H1**:Kimi OAuth 设备轮询移出 DB 会话——新增 `_poll_for_token`(纯 HTTP)/`_make_persist_work`/`_run_kimi_oauth_poll`,仅最终持久化经 `run_job` 开短会话(防连接池饥饿;删 `_make_poll_work`)。**CR-H2**:请求级 httpx 客户端改**生成器依赖** `_http_client`(`async with ... yield`,请求结束 aclose 修泄漏);`_default_http_client` 保留供后台裸调;E2E + 端点测试 override 键跟随改 `_http_client`。**CR-H7**:冻结 `TOOLBOX` 为只读 `MappingProxyType`(`_TOOLBOX_BY_KEY` 私有底 dict + `Final[Mapping]`,仿 SPECS)。**CR-H9**:用户请求字符串字段补 `max_length`(providers/generation/projects/style/foreshadow/rules/templates,命名常量无魔数,样本列表长度+每条双上界)+ 新错误码 `ErrorCode.PAYLOAD_TOO_LARGE`(413) + 应用级 `body_size_limit_middleware`(2 MiB,按 Content-Length 拦截自建 413 信封),`main.py` 注册。**门禁全绿**:ruff/format 干净 · mypy **229 文件**(0 错)· pytest **928**(+23:recursion_limit 1 / poll-no-session 1 / http-gen 1 / toolbox-readonly 1 / input-size 19[17 参数化 + samples 1 + body413 1])· **无 DDL/无迁移**。记 contracts C3(CR-H9:新增 `maxLength` + `PAYLOAD_TOO_LARGE`)。→ **@frontend 须 `pnpm gen:api`**(请求字段现有 `maxLength`;本 cluster 未代跑)。守不变量 #3/#8/#9。
|
||||
|
||||
- [2026-07-06] @backend/@llm/@frontend — **✅ 灵感⑦ 群像按定位配比·缩减版 + §0 归属纠偏(develop 直提)**。角色群像生成支持「按定位配比」下发。**契约变更** `CharacterGenerateRequest` 加 `role_mix:dict[str,int]|None`——`model_validator(mode="after")` 定义与既有 `count` 的关系:在场时校验(非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12)并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count);缺省退回单一 `role`+`count`。**§0 归属纠偏**:`build_character_gen_context`+`run_character_gen`(`ww_core/orchestrator/generation_node.py`)确为 **@llm 域**——加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7 assemble 纯函数;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix`。`character-gen.md` 加「按配比严格分配 role、各定位之和=总数」纪律 → **regen `prompt_hashes.json`**(仅 character-gen 哈希变,**不进 SPECS→不 bump 计数**)。**前端** `cards.ts` 加 `sanitizeRoleMix`(去空/去重/clamp≥1/按 12 截断)+`roleMixTotal`+`buildCharacterGenerateRequest` 第 4 参 roleMix;`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**TDD**:schema 校验 7 例(总和≤12/空 dict/非正值拒绝)+ context 注入 2 例 + run 穿参 1 例 + 前端 cards 构造/clamp 5 例 + hook renderHook 1 例。**门禁全绿**:后端 ruff/format 干净 · mypy **222** · pytest **858**(+11)· 无迁移(未动模型,未跑 alembic);前端 gen:api/lint/typecheck 干净 · vitest **543**(+6)· coverage 95%(cards.ts 100%)· build OK。记 contracts C3。守不变量 #2/#6/#7/#9。**无 DB 迁移**。
|
||||
|
||||
- [2026-07-06] @llm/@db/@backend/@frontend — **✅ 灵感③ 人物塑造 advisory 审(develop 直提)**。写→审链新增第五审「人物塑造」:**advisory 单维——仅建议、不阻断验收**(不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**,D2);只做人物塑造、不做氛围(氛围噪声大折进 style/pace)。**新 SPEC** `characterization_spec`(`tier="analyst"` 不写 model,`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度对抗 LLM-judge 冗长/辞藻偏见)+ schema `CharacterizationReview{issues:[…character/aspect/where/quote/diagnosis/suggestion/confidence]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`;**计数三处 22→23**(`specs.py` assert / `test_prompt_loader.py` len / `schema_catalog.py` docstring)+ **regen `prompt_hashes.json`**(仅加一条,无其余漂移)。**DB** `chapter_reviews` 加 1 列 `characterization`(**JSONB nullable**,迁移 `f6a7b8c9d0e1`,nullable 免 backfill)。**契约变更** `ReviewHistoryItem`+`ReviewView` 加 `characterization`;接线链 `graph.py REVIEW_SPECS` 末位追加 + `chain/nodes.py ReviewRecordRepo` + `collect.py {CHARACTERIZATION/extract_characterization/ReviewRecorder/collect_reviews}` + `review_repo.py {Protocol/SqlReviewRepo/_to_view}` + `sse.py {EVENT_CHARACTERIZATION/characterization_event/_section_result_events}`(normalize 按 sorted 键自动纳入,字典序排最前不打乱既有四审断言)。**前端** `lib/review/{sse.ts,history.ts,useReviewStream.ts}` + 新 `CharacterizationPanel.tsx`(advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed。**门禁全绿**:后端 ruff/format 干净 · mypy **221** · pytest **848**(+核心 char 审 12 + agents spec 11 + api accept 2 等)· alembic 无漂移;前端 gen:api/lint/typecheck 干净 · vitest **536**(+10 含 `test_characterization_does_not_block_accept` 对应前端 reducer/normalize/parse)· coverage 95% · build OK。记 contracts C3+C4。守不变量 #2/#3/#9。依赖 ⑧(motive/appearance 已入 CharacterCard 契约,作客观锚点)。
|
||||
@@ -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-29] @frontend/@qa — **UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review;新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build;后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。
|
||||
- [2026-07-06] @frontend — **✅ 灵感⑥ 关系图谱(缩减版)+ D4 完成**(纯前端,无后端契约/迁移/gen:api)。新增 `lib/characters/graphLayout.ts`(纯逻辑:`roleStyle` role→分组配色关键词映射、`buildGraphNodes`/`buildGraphEdges`(kind→标签,`DEFAULT_RELATION_LABEL` 命名常量)、`countDanglingRelations`、`layoutGraph`(@dagrejs/dagre 一次性确定性初始坐标、中心→左上、不可变)、`buildRelationshipGraph` 端到端)——**仅用现有 `CharacterCardView.relations={name,kind,note}`**:边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造);悬空边(引用未入库角色)跳过并计数。新增 `components/characters/RelationshipGraph.tsx`(`'use client'`,React Flow @xyflow/react;CodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab,显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06)。TDD:graphLayout **+22 测**(role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿:lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%(graphLayout.ts 97.6%)** · **build OK**(codex 路由 7.58 kB,React Flow 懒加载独立 chunk)。注:`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500(stale chunk),非代码缺陷、dev watcher 下次重编自愈,未重启服务。
|
||||
- [2026-07-08] @frontend — **CR-M4/M5/L1 前端评审收尾(MEDIUM/LOW 打磨,仅 apps/web,无后端契约/gen:api)**。DONE:①a11y——`NavDrawer` 汉堡按钮补 `aria-controls`(新增 `Drawer` 可选 `id` prop 指向面板,默认 `nav-drawer`)、`ForeshadowCard` 的 `<dl>` 裸 `<div>` 行改真 `<dt>/<dd>` 结构(可视文案不变,importance 用 sr-only 标签);②`ReviewReport.flashMark` 的 `setTimeout` 句柄入 ref + 卸载 useEffect 清理(防定时器泄漏,提 `FLASH_DURATION_MS` 常量);③`useAccept` 手写 `ApiErrorEnvelope` 改由生成 schema 派生 `components["schemas"]["ErrorEnvelope"]`(端点专属 details 就地收窄,行为不变);④`applyFix.hasApplicableFix` 重写为对象入参**类型谓词** `patch is ApplicablePatch`,消除 `applyConflictFix` 里两处 `as string`(含收窄单测);⑤`server.parseJsonBody` 已具边界校验,补两条守卫分支测试(非对象体/非 JSON 体);⑥去冗余 cast:`stream/sse.ts` `idx.valueOf()`→`idx`、`jobs/job.ts` `dict["error"] as string`(typeof 守卫后 TS 自动收窄)。SKIP/NOOP(前提已过时/本就安全):`ProjectWizard` 换步聚焦标题已实现;`AppShell`/`LeftNav` 两处 `<nav>` 均已带 `aria-label`;`KNOWN_EVENTS` 加 `stop`——`stop` 是客户端 abort 动作非服务端 SSE 帧(对照 contracts.md 草稿 token/done/error、续审 section/conflict/foreshadow/pace/style/characterization/done/error 均无 stop),加入会为不存在的事件建处理;`CharacterGenerator`/`GeneratorRunner`/`ForeshadowCard.progress` 的 index-key——三处均为整表替换/追加式/静态展示列表(无逐项重排或删除,`CharacterCardItem`/`PreviewRow` 为无内部状态受控组件,选择/编辑均按 index 且随新预览整体重置),index 作 key 安全且无天然稳定 id,加合成 UUID 需重做 index 选择/编辑态属过度改造。门禁全绿:lint 干净 · typecheck 干净 · **vitest 681 passed**(+3)· **coverage lib 95.45%**(exit 0)· **build OK**。
|
||||
|
||||
12
README.md
12
README.md
@@ -43,6 +43,8 @@
|
||||
|
||||
前置:Python 3.12+ · Node 22 · [uv](https://docs.astral.sh/uv/) · pnpm · Docker
|
||||
|
||||
> **本地端口(宿主已 +1000,防与其他项目冲突)**:前端 **http://localhost:4000** · 后端 **http://localhost:9000**(API 文档 `/docs`)· Postgres **localhost:6432**。容器内部端口不变(3000 / 8000 / 5432),只改宿主发布端口;要改回默认各减 1000 即可。接线:前端 `apps/web/.env.local` 的 `NEXT_PUBLIC_API_BASE=http://localhost:9000` 指向后端;后端 `.env` 的 `CORS_ORIGINS` 放行 `http://localhost:4000`、`DATABASE_URL` 指向 `localhost:6432`。
|
||||
|
||||
```bash
|
||||
# 1. 克隆后,安装后端依赖(仓库根,uv workspace)
|
||||
uv sync
|
||||
@@ -59,17 +61,17 @@ cp .env.example .env
|
||||
docker compose up -d pg
|
||||
uv run alembic upgrade head
|
||||
|
||||
# 5. 起后端 API(默认 http://localhost:8000,文档 /docs)
|
||||
uv run uvicorn ww_api.main:app --reload
|
||||
# 5. 起后端 API(http://localhost:9000,文档 /docs)— 宿主端口 +1000
|
||||
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
|
||||
```
|
||||
|
||||
或一键起全套(pg + api + web):
|
||||
或一键起全套(pg + api + web,宿主端口均已 +1000):
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
docker compose up # web → :4000 · api → :9000 · pg → :6432
|
||||
```
|
||||
|
||||
> **LLM API Key 不放 `.env`**:在应用设置页 **`/settings/providers`** 录入,密文存于数据库凭据库(用 `CREDENTIAL_ENC_KEY` 加密)。`.env` 里只有那把加密主密钥本身。
|
||||
|
||||
@@ -12,4 +12,6 @@ EXPOSE 8000
|
||||
# 存活探测:镜像无 curl,用 stdlib urllib 打 /health(非 200/连接失败会抛异常,退出非零)。
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
|
||||
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"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.111",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"structlog>=24.1",
|
||||
"cryptography>=43",
|
||||
"fastapi>=0.137",
|
||||
"uvicorn[standard]>=0.49",
|
||||
"structlog>=26.1",
|
||||
"cryptography>=49",
|
||||
"ww-shared",
|
||||
"ww-config",
|
||||
"ww-db",
|
||||
|
||||
@@ -63,8 +63,13 @@ class FakeProjectRepo:
|
||||
self.rows[pid] = (owner_id, view)
|
||||
return view
|
||||
|
||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
|
||||
return [v for (o, v) in self.rows.values() if o == owner_id]
|
||||
async def list_for_owner(
|
||||
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
|
||||
) -> list[ProjectView]:
|
||||
views = [v for (o, v) in self.rows.values() if o == owner_id]
|
||||
if limit is None:
|
||||
return views
|
||||
return views[offset : offset + limit]
|
||||
|
||||
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
|
||||
entry = self.rows.get(project_id)
|
||||
@@ -358,8 +363,14 @@ class FakeOutlineReadRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self.rows.get((project_id, chapter_no))
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
views = [v for (p, _), v in self.rows.items() if p == project_id]
|
||||
async def list_for_project(
|
||||
self, project_id: uuid.UUID, *, volume: int | None = None
|
||||
) -> list[OutlineView]:
|
||||
views = [
|
||||
v
|
||||
for (p, _), v in self.rows.items()
|
||||
if p == project_id and (volume is None or v.volume == volume)
|
||||
]
|
||||
return sorted(views, key=lambda v: v.chapter_no)
|
||||
|
||||
|
||||
|
||||
325
apps/api/tests/test_ai_messages.py
Normal file
325
apps/api/tests/test_ai_messages.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""AC-2 POST/GET /projects/{id}/ai-messages 端点(内存替身,无 DB/无网络)。
|
||||
|
||||
docs/design/ai-chat-history-plan.md §3.3 / §6。覆盖:
|
||||
- POST 201 + 回显持久化视图(seq/id/created_at)+ 端点 commit 边界;
|
||||
- append→list 回环;项目级(chapter_no null) vs 本章 union;
|
||||
- 校验 → 422(非法 role、超上限 content、空 messages、超大 meta);
|
||||
- 未知项目 → 404;响应 key 集合不泄漏意外字段。
|
||||
|
||||
owner_id 全程 stub(单用户原型)。AI 一律不触达(纯 CRUD)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeProjectRepo, FakeSession
|
||||
from ww_core.domain.ai_message_repo import AiMessageView, AiTurnRow
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
|
||||
# 期望的响应 bubble 字段集合(防泄漏意外字段)。
|
||||
_EXPECTED_KEYS = {
|
||||
"id",
|
||||
"project_id",
|
||||
"chapter_no",
|
||||
"thread_id",
|
||||
"seq",
|
||||
"kind",
|
||||
"tool_key",
|
||||
"role",
|
||||
"content",
|
||||
"meta",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
|
||||
class _FakeAiMessageRepo:
|
||||
"""镜像 `SqlAiMessageRepo` 语义的内存版(批内 seq、newest-first union)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[AiMessageView] = []
|
||||
self._clock = 0
|
||||
|
||||
async def append(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
thread_id: uuid.UUID,
|
||||
chapter_no: int | None,
|
||||
kind: str,
|
||||
tool_key: str | None,
|
||||
rows: list[AiTurnRow],
|
||||
) -> list[AiMessageView]:
|
||||
self._clock += 1
|
||||
created = datetime(2026, 7, 9, tzinfo=UTC) + timedelta(seconds=self._clock)
|
||||
out: list[AiMessageView] = []
|
||||
for i, r in enumerate(rows):
|
||||
view = AiMessageView(
|
||||
id=uuid.uuid4(),
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
thread_id=thread_id,
|
||||
seq=i,
|
||||
kind=kind,
|
||||
tool_key=tool_key,
|
||||
role=r.role,
|
||||
content=r.content,
|
||||
meta=dict(r.meta),
|
||||
created_at=created,
|
||||
)
|
||||
self.rows.append(view)
|
||||
out.append(view)
|
||||
return out
|
||||
|
||||
async def list_for_chapter(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int | None,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[AiMessageView]:
|
||||
matched = [r for r in self.rows if r.project_id == project_id]
|
||||
if chapter_no is not None:
|
||||
matched = [r for r in matched if r.chapter_no in (chapter_no, None)]
|
||||
else:
|
||||
matched = [r for r in matched if r.chapter_no is None]
|
||||
if kind is not None:
|
||||
matched = [r for r in matched if r.kind == kind]
|
||||
matched.sort(key=lambda r: (r.created_at, r.seq), reverse=True)
|
||||
return matched[offset : offset + limit]
|
||||
|
||||
|
||||
def _make_client() -> tuple[httpx.AsyncClient, _FakeAiMessageRepo, FakeProjectRepo, FakeSession]:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
|
||||
from ww_db import get_session
|
||||
|
||||
repo = _FakeAiMessageRepo()
|
||||
project_repo = FakeProjectRepo()
|
||||
session = FakeSession()
|
||||
|
||||
# 一个已存在的项目(owner = stub),供 404 对照——直接种入 fake 的行表(避开事件循环)。
|
||||
project_id = uuid.uuid4()
|
||||
project_repo.rows[project_id] = (STUB_OWNER_ID, ProjectView(id=project_id, title="AC-2"))
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_ai_message_repo] = lambda: repo
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
client.project_id = project_id # type: ignore[attr-defined]
|
||||
return client, repo, project_repo, session
|
||||
|
||||
|
||||
def _refine_batch(chapter_no: int | None = 5) -> dict[str, object]:
|
||||
return {
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"chapter_no": chapter_no,
|
||||
"kind": "refine",
|
||||
"messages": [
|
||||
{"role": "author", "content": "原选段"},
|
||||
{"role": "ai", "content": "重写段", "meta": {"version_no": 1, "segment": "原选段"}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_returns_201_persisted_views_and_commits() -> None:
|
||||
client, repo, _pr, session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
msgs = body["messages"]
|
||||
assert [m["seq"] for m in msgs] == [0, 1]
|
||||
assert [m["role"] for m in msgs] == ["author", "ai"]
|
||||
assert msgs[1]["meta"] == {"version_no": 1, "segment": "原选段"}
|
||||
assert all(set(m.keys()) == _EXPECTED_KEYS for m in msgs)
|
||||
assert all(m["id"] and m["created_at"] for m in msgs)
|
||||
assert session.commits == 1
|
||||
assert len(repo.rows) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_then_list_roundtrip() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||||
resp = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
|
||||
assert resp.status_code == 200
|
||||
msgs = resp.json()["messages"]
|
||||
# newest-first:批内 seq DESC → ai(seq1) 先于 author(seq0)。
|
||||
assert [m["content"] for m in msgs] == ["重写段", "原选段"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_chapter_union_includes_project_level() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||||
# 项目级工具箱生成(chapter_no 省略 → NULL)。
|
||||
await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "generator",
|
||||
"tool_key": "expand",
|
||||
"messages": [{"role": "ai", "content": "扩写预览"}],
|
||||
},
|
||||
)
|
||||
chapter_view = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
|
||||
toolbox_view = await client.get(f"/projects/{pid}/ai-messages")
|
||||
chapter_contents = {m["content"] for m in chapter_view.json()["messages"]}
|
||||
toolbox_contents = {m["content"] for m in toolbox_view.json()["messages"]}
|
||||
assert chapter_contents == {"原选段", "重写段", "扩写预览"} # 本章 ∪ 项目级
|
||||
assert toolbox_contents == {"扩写预览"} # 仅项目级
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_kind_filter() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(2))
|
||||
await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"chapter_no": 2,
|
||||
"kind": "rewrite",
|
||||
"messages": [{"role": "ai", "content": "整章新版"}],
|
||||
},
|
||||
)
|
||||
resp = await client.get(
|
||||
f"/projects/{pid}/ai-messages", params={"chapter_no": 2, "kind": "rewrite"}
|
||||
)
|
||||
msgs = resp.json()["messages"]
|
||||
assert [m["kind"] for m in msgs] == ["rewrite"]
|
||||
assert [m["content"] for m in msgs] == ["整章新版"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_unknown_project_returns_404() -> None:
|
||||
client, repo, _pr, session = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{uuid.uuid4()}/ai-messages", json=_refine_batch(5))
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert session.commits == 0
|
||||
assert repo.rows == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_unknown_project_returns_404() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{uuid.uuid4()}/ai-messages", params={"chapter_no": 1})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_invalid_role_returns_422() -> None:
|
||||
client, _repo, _pr, session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"chapter_no": 5,
|
||||
"kind": "refine",
|
||||
"messages": [{"role": "system", "content": "x"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_invalid_kind_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "translate", # 非五类枚举 → 422
|
||||
"messages": [{"role": "ai", "content": "x"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_empty_messages_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={"thread_id": str(uuid.uuid4()), "kind": "refine", "messages": []},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_empty_content_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "refine",
|
||||
"messages": [{"role": "ai", "content": ""}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_content_over_cap_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "rewrite",
|
||||
"messages": [{"role": "ai", "content": "x" * 200_001}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_oversize_meta_returns_422() -> None:
|
||||
client, _repo, _pr, _session = _make_client()
|
||||
pid = client.project_id # type: ignore[attr-defined]
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/ai-messages",
|
||||
json={
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
"kind": "refine",
|
||||
"messages": [{"role": "ai", "content": "x", "meta": {"blob": "y" * 60_000}}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -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]
|
||||
|
||||
|
||||
async def test_run_chain_job_sets_recursion_limit_scaled_to_count(
|
||||
_patched_runner: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""CR-C2:run_chain_job 给 graph.ainvoke 传显式 top-level recursion_limit(防御硬化)。
|
||||
|
||||
langgraph 默认上界已足够(无实时崩溃),但每链上界应显式 = count*NODES_PER_CHAPTER +
|
||||
HEADROOM。断言 config 键值 + 仍正常跑完(行为不变)。用 PROXY 包裹真 graph 记录 config
|
||||
(不 setattr 到可能冻结的 CompiledStateGraph 上)。
|
||||
"""
|
||||
from ww_core.orchestrator.chain import build_chain_graph as real_build
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _SpyGraph:
|
||||
def __init__(self, inner: Any) -> None:
|
||||
self._inner = inner
|
||||
|
||||
async def ainvoke(self, inp: Any, *, config: Any = None, **kw: Any) -> Any:
|
||||
captured["config"] = config
|
||||
return await self._inner.ainvoke(inp, config=config, **kw)
|
||||
|
||||
async def aget_state(self, config: Any, **kw: Any) -> Any:
|
||||
# runner 现经公开 aget_state 判 interrupt(CR-M3)——代理透传给真 graph。
|
||||
return await self._inner.aget_state(config, **kw)
|
||||
|
||||
def _spy_build(*args: Any, **kwargs: Any) -> Any:
|
||||
return _SpyGraph(real_build(*args, **kwargs))
|
||||
|
||||
monkeypatch.setattr("ww_api.services.chain_runner.build_chain_graph", _spy_build)
|
||||
|
||||
fakes = _chain_runner_fakes()
|
||||
gateway = _FakeChainGateway(conflicts=[])
|
||||
job_id = uuid.uuid4()
|
||||
saver = MemorySaver()
|
||||
|
||||
await run_chain_job(
|
||||
_session_factory,
|
||||
job_id,
|
||||
project_id=uuid.uuid4(),
|
||||
user_id=USER,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=1,
|
||||
count=7,
|
||||
chain_gateway_builder=_async_returning(gateway),
|
||||
checkpointer_ctx=lambda: _memsaver_ctx(saver),
|
||||
accept_op=fakes["accept_op"],
|
||||
chapter_repo_factory=fakes["chapter_repo_factory"],
|
||||
review_repo_factory=fakes["review_repo_factory"],
|
||||
)
|
||||
|
||||
# (1) 显式上界 = count*NODES_PER_CHAPTER + HEADROOM = 7*4+10 = 38。
|
||||
assert captured["config"]["recursion_limit"] == 7 * 4 + 10 == 38
|
||||
# (2) recursion_limit 是 top-level 键(不在 configurable 下——langgraph 只认 top-level)。
|
||||
assert "recursion_limit" not in captured["config"]["configurable"]
|
||||
# (3) 仍正常跑完(行为不变)。
|
||||
rec = _RecordingJobRepo.state[job_id]
|
||||
assert rec["status"] == STATUS_DONE
|
||||
assert rec["result"]["written"] == list(range(1, 8))
|
||||
|
||||
|
||||
async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
|
||||
_patched_runner: None,
|
||||
) -> None:
|
||||
|
||||
@@ -28,7 +28,7 @@ from ww_agents import (
|
||||
WorldEntityCard,
|
||||
WorldGenResult,
|
||||
)
|
||||
from ww_core.domain.character_repo import CharacterWriteView
|
||||
from ww_core.domain.character_repo import CharacterWriteFields, CharacterWriteView
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.rule_repo import RuleListItemView
|
||||
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)
|
||||
|
||||
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:
|
||||
"""实现规则 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_multiple_characters_all_land_in_one_batch() -> None:
|
||||
# 批量入库:多张卡一次 create_many 全部落库,created 保序,单次 commit。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
def _card(name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"role": "配角",
|
||||
"traits": [],
|
||||
"backstory": "x",
|
||||
"arc": "y",
|
||||
"speech_tics": [],
|
||||
"tags": [],
|
||||
"relations": [],
|
||||
}
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters",
|
||||
json={"cards": [_card("甲"), _card("乙"), _card("丙")]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["created"] == ["甲", "乙", "丙"]
|
||||
assert [r["name"] for r in char_repo.rows] == ["甲", "乙", "丙"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_characters_with_conflict_blocks_409() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
|
||||
@@ -38,8 +38,11 @@ class _OutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self._rows.get(chapter_no)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
return [self._rows[k] for k in sorted(self._rows)]
|
||||
async def list_for_project(
|
||||
self, project_id: uuid.UUID, *, volume: int | None = None
|
||||
) -> list[OutlineView]:
|
||||
rows = [self._rows[k] for k in sorted(self._rows)]
|
||||
return [v for v in rows if volume is None or v.volume == volume]
|
||||
|
||||
|
||||
class _CharRepo:
|
||||
|
||||
112
apps/api/tests/test_input_size_limits.py
Normal file
112
apps/api/tests/test_input_size_limits.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""CR-H9:用户请求文本字段 max_length 上界 + 应用级请求体大小中间件(防 DoS/成本)。
|
||||
|
||||
- 单元:各请求 schema 的字符串字段在 cap 处可构造、cap+1 触发 ValidationError(含样本列表长度
|
||||
与列表项两处上界——最大 DoS 面)。
|
||||
- 集成(无 pg):超大请求体经中间件直接 413(不入路由/DB)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
# create_app() 于 import ww_api.main 时构建,需 CREDENTIAL_ENC_KEY——先于任何 main 导入置好。
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
|
||||
from ww_api.schemas.foreshadow import ForeshadowRegisterRequest
|
||||
from ww_api.schemas.generation import CharacterGenerateRequest, WorldGenerateRequest
|
||||
from ww_api.schemas.projects import (
|
||||
AcceptRequest,
|
||||
DraftSaveRequest,
|
||||
DraftStreamRequest,
|
||||
ProjectCreateRequest,
|
||||
ReviewRequest,
|
||||
)
|
||||
from ww_api.schemas.providers import (
|
||||
ProviderCredentialInput,
|
||||
TierRoutingInput,
|
||||
)
|
||||
from ww_api.schemas.providers import (
|
||||
TestConnectionRequest as _TestConnectionRequest, # 别名避免 pytest 误采集 Test* 类
|
||||
)
|
||||
from ww_api.schemas.rules import RuleCreateRequest
|
||||
from ww_api.schemas.style import RefineRequest, StyleLearnRequest
|
||||
from ww_api.schemas.templates import TemplateCreateRequest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StrCase:
|
||||
"""一个字符串字段的上界用例:cap 处可构造、cap+1 触发 ValidationError。"""
|
||||
|
||||
schema: type[BaseModel]
|
||||
field_name: str
|
||||
cap: int
|
||||
case_id: str
|
||||
base: dict[str, Any] = field(default_factory=dict) # 该 schema 其余必填字段
|
||||
|
||||
|
||||
_STR_CASES: list[_StrCase] = [
|
||||
_StrCase(ProviderCredentialInput, "api_key", 512, "api_key<=512", {"provider": "p"}),
|
||||
_StrCase(ProviderCredentialInput, "provider", 64, "provider<=64", {"api_key": "k"}),
|
||||
_StrCase(TierRoutingInput, "model", 128, "model<=128", {"tier": "writer", "provider": "p"}),
|
||||
_StrCase(
|
||||
TierRoutingInput, "provider", 64, "routing-provider<=64", {"tier": "writer", "model": "m"}
|
||||
),
|
||||
_StrCase(_TestConnectionRequest, "provider", 64, "test-provider<=64"),
|
||||
_StrCase(WorldGenerateRequest, "brief", 10_000, "world-brief<=10000"),
|
||||
_StrCase(CharacterGenerateRequest, "brief", 10_000, "char-brief<=10000"),
|
||||
_StrCase(ProjectCreateRequest, "title", 200, "title<=200"),
|
||||
_StrCase(AcceptRequest, "final_text", 200_000, "final_text<=200000"),
|
||||
_StrCase(DraftSaveRequest, "text", 200_000, "draft-text<=200000"),
|
||||
_StrCase(ReviewRequest, "draft", 200_000, "review-draft<=200000"),
|
||||
_StrCase(RefineRequest, "segment", 20_000, "segment<=20000"),
|
||||
_StrCase(RefineRequest, "instruction", 2_000, "refine-instruction<=2000", {"segment": "s"}),
|
||||
_StrCase(DraftStreamRequest, "directive", 20_000, "directive<=20000"),
|
||||
_StrCase(ForeshadowRegisterRequest, "code", 100, "code<=100", {"title": "t"}),
|
||||
_StrCase(ForeshadowRegisterRequest, "title", 500, "foreshadow-title<=500", {"code": "c"}),
|
||||
_StrCase(RuleCreateRequest, "content", 10_000, "rule-content<=10000", {"level": "project"}),
|
||||
_StrCase(TemplateCreateRequest, "title", 200, "template-title<=200", {"body": "b"}),
|
||||
_StrCase(TemplateCreateRequest, "body", 20_000, "template-body<=20000", {"title": "t"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _STR_CASES, ids=[c.case_id for c in _STR_CASES])
|
||||
def test_request_str_fields_reject_over_max_length(case: _StrCase) -> None:
|
||||
# Arrange / Act — 长度 == cap:构造成功。
|
||||
case.schema(**{**case.base, case.field_name: "x" * case.cap})
|
||||
# Assert — 长度 == cap+1:超界即 ValidationError。
|
||||
with pytest.raises(ValidationError):
|
||||
case.schema(**{**case.base, case.field_name: "x" * (case.cap + 1)})
|
||||
|
||||
|
||||
def test_style_learn_samples_item_and_list_caps() -> None:
|
||||
# 单条样本 == 20_000 字符 OK;20_001 → ValidationError(最大 DoS 面:每条正文都设上界)。
|
||||
StyleLearnRequest(samples=["x" * 20_000])
|
||||
with pytest.raises(ValidationError):
|
||||
StyleLearnRequest(samples=["x" * 20_001])
|
||||
# 列表 == 20 条 OK;21 条 → ValidationError(同时约束列表长度)。
|
||||
StyleLearnRequest(samples=["s"] * 20)
|
||||
with pytest.raises(ValidationError):
|
||||
StyleLearnRequest(samples=["s"] * 21)
|
||||
|
||||
|
||||
def test_oversized_request_body_rejected_413() -> None:
|
||||
"""超大请求体经 body-size 中间件直接 413(不入路由/DB)。
|
||||
|
||||
不以 context manager 进入 TestClient → 不跑 lifespan/seed → 不需 Postgres。中间件在
|
||||
路由前拦截,故请求根本不触达任何 DB。
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from ww_api.main import create_app
|
||||
from ww_api.middleware import MAX_BODY_BYTES
|
||||
|
||||
client = TestClient(create_app())
|
||||
resp = client.post("/projects", content=b"x" * (MAX_BODY_BYTES + 1))
|
||||
|
||||
assert resp.status_code == 413
|
||||
assert resp.json()["error"]["code"] == "PAYLOAD_TOO_LARGE"
|
||||
@@ -15,6 +15,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -51,12 +53,65 @@ class _ScriptedHttp:
|
||||
return None
|
||||
|
||||
|
||||
class _TrackingSessionFactory:
|
||||
"""记录同一时刻在持会话数(open_count)+ 累计开启次数(total_opened)的 session 工厂替身。
|
||||
|
||||
CR-H1 断言用:轮询期间 open_count 应恒 0(无会话被长时间占用),持久化时 total_opened≥1。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.open_count = 0
|
||||
self.total_opened = 0
|
||||
|
||||
def __call__(self) -> Any:
|
||||
from fakes_projects import FakeSession
|
||||
|
||||
@asynccontextmanager
|
||||
async def _cm() -> AsyncIterator[Any]:
|
||||
self.open_count += 1
|
||||
self.total_opened += 1
|
||||
try:
|
||||
yield FakeSession()
|
||||
finally:
|
||||
self.open_count -= 1
|
||||
|
||||
return _cm()
|
||||
|
||||
|
||||
class _ObservingHttp:
|
||||
"""观测型 scripted http:每次 POST 到 TOKEN_URL(轮询)时记录当下在持会话数。aclose 无操作。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
responses: list[_FakeResponse],
|
||||
*,
|
||||
factory: _TrackingSessionFactory,
|
||||
token_url: str,
|
||||
) -> None:
|
||||
self._responses = responses
|
||||
self._factory = factory
|
||||
self._token_url = token_url
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
self.open_during_poll: list[int] = []
|
||||
|
||||
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
|
||||
idx = len(self.calls)
|
||||
self.calls.append((url, data))
|
||||
if url == self._token_url: # 轮询 POST(区别于 device_authorization POST)。
|
||||
self.open_during_poll.append(self._factory.open_count)
|
||||
return self._responses[idx]
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _make_client(
|
||||
*,
|
||||
store: FakeCredentialStore,
|
||||
http: _ScriptedHttp,
|
||||
http: Any,
|
||||
enc_key: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
session_factory: Any = None,
|
||||
) -> TestClient:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
|
||||
from ww_config import get_settings
|
||||
@@ -88,12 +143,15 @@ def _make_client(
|
||||
job_repo = FakeJobRepo()
|
||||
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
|
||||
|
||||
sf = session_factory if session_factory is not None else FakeSessionFactory()
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_credential_store] = lambda: store
|
||||
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
||||
app.dependency_overrides[get_session] = lambda: FakeSession()
|
||||
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
|
||||
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http
|
||||
app.dependency_overrides[get_session_factory] = lambda: sf
|
||||
# 请求级 http 现为生成器依赖 `_http_client`(CR-H2)——override 它注 fake(否则真生成器联网)。
|
||||
app.dependency_overrides[routers.kimi_oauth._http_client] = lambda: http
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -163,6 +221,90 @@ def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPat
|
||||
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
|
||||
|
||||
|
||||
def test_poll_loop_does_not_hold_db_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""CR-H1:设备授权轮询期间**不得**占用 DB 会话(否则连接池被长时间空占→饥饿)。
|
||||
|
||||
轮询循环只做 HTTP(无 DB);仅最终持久化才开一个**短**会话。断言:轮询 POST 期间
|
||||
session_factory 未开会话(open_count==0),而持久化确开过短会话;凭据已落库、响应不泄 token。
|
||||
"""
|
||||
from ww_api.services.kimi_oauth import TOKEN_URL
|
||||
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
factory = _TrackingSessionFactory()
|
||||
# device auth → poll #1 pending → poll #2 success(≥2 次轮询 POST)。
|
||||
http = _ObservingHttp(
|
||||
[
|
||||
_device_auth_response(),
|
||||
_FakeResponse(400, {"error": "authorization_pending"}),
|
||||
_token_response(),
|
||||
],
|
||||
factory=factory,
|
||||
token_url=TOKEN_URL,
|
||||
)
|
||||
client = _make_client(
|
||||
store=store,
|
||||
http=http,
|
||||
enc_key=enc_key,
|
||||
monkeypatch=monkeypatch,
|
||||
session_factory=factory,
|
||||
)
|
||||
|
||||
resp = client.post("/settings/providers/kimi-code/oauth/start")
|
||||
|
||||
assert resp.status_code == 202
|
||||
# 核心断言:轮询 POST 期间无 session_factory 会话在持。
|
||||
assert http.open_during_poll # 确有轮询 POST 发生
|
||||
assert all(c == 0 for c in http.open_during_poll)
|
||||
# 持久化时确开过一个短会话。
|
||||
assert factory.total_opened >= 1
|
||||
# 凭据已落库;响应绝不含 token。
|
||||
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
|
||||
assert "secret-acc" not in resp.text
|
||||
assert "secret-ref" not in resp.text
|
||||
|
||||
|
||||
async def test_http_client_dependency_is_generator_and_closes_client(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""CR-H2:请求级 `_http_client` 是**生成器依赖**,请求结束时 aclose 客户端(不泄漏连接)。
|
||||
|
||||
驱动生成器一轮(enter→yield→exit),断言:是 async 生成器;yield 时未关闭;耗尽时恰好
|
||||
关闭一次。RED:修复前 `_http_client` 不存在(AttributeError)。
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from ww_api.routers import kimi_oauth
|
||||
|
||||
closes = {"count": 0}
|
||||
|
||||
class _SpyClient:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> _SpyClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: Any) -> None:
|
||||
closes["count"] += 1
|
||||
|
||||
async def aclose(self) -> None:
|
||||
closes["count"] += 1
|
||||
|
||||
monkeypatch.setattr("ww_api.routers.kimi_oauth.httpx.AsyncClient", _SpyClient)
|
||||
|
||||
# 是 async 生成器依赖(有 teardown),非普通函数(普通函数依赖无 teardown → 泄漏)。
|
||||
assert inspect.isasyncgenfunction(kimi_oauth._http_client)
|
||||
|
||||
gen = kimi_oauth._http_client()
|
||||
client = await gen.__anext__()
|
||||
assert client is not None
|
||||
assert closes["count"] == 0 # yield 时尚未关闭
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await gen.__anext__()
|
||||
assert closes["count"] == 1 # 请求结束恰好关闭一次
|
||||
|
||||
|
||||
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outline_repo_volume_filter_pushed_into_query() -> None:
|
||||
# 卷过滤下推到 repo(DB WHERE),而非端点 Python 侧过滤:直接调 repo 断言只回该卷。
|
||||
repo = FakeOutlineReadRepo()
|
||||
pid = uuid.uuid4()
|
||||
repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·1"])
|
||||
repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·2"])
|
||||
repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·3"])
|
||||
|
||||
vol1 = await repo.list_for_project(pid, volume=1)
|
||||
vol2 = await repo.list_for_project(pid, volume=2)
|
||||
all_rows = await repo.list_for_project(pid)
|
||||
|
||||
assert [v.chapter_no for v in vol1] == [1, 2]
|
||||
assert all(v.volume == 1 for v in vol1)
|
||||
assert [v.chapter_no for v in vol2] == [3]
|
||||
assert [v.chapter_no for v in all_rows] == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
|
||||
@@ -10,7 +10,14 @@ import uuid
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
|
||||
from fakes_projects import (
|
||||
FakeChapterRepo,
|
||||
FakeProjectRepo,
|
||||
FakeReviewGateway,
|
||||
FakeSession,
|
||||
FakeWriterGateway,
|
||||
)
|
||||
from fastapi import FastAPI
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
from ww_core.domain.repositories import (
|
||||
@@ -31,7 +38,9 @@ class _EmptyOutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return None
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
async def list_for_project(
|
||||
self, project_id: uuid.UUID, *, volume: int | None = None
|
||||
) -> list[OutlineView]:
|
||||
return []
|
||||
|
||||
|
||||
@@ -213,6 +222,33 @@ async def test_list_projects() -> None:
|
||||
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
|
||||
async def test_get_project_detail() -> None:
|
||||
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")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
# ---- POST /rewrite/clarify(整章重写预检澄清,WFW-9 M2)----
|
||||
|
||||
# 摘录上界外的哨兵:喂超长 prior_draft,断言超界正文不进网关 input(不喂全章、不泄漏)。
|
||||
_LEAK_SENTINEL = "泄漏哨兵段落禁止外泄"
|
||||
|
||||
|
||||
def _need_clarification_decision() -> object:
|
||||
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=True,
|
||||
questions=[
|
||||
ClarifyQuestion(
|
||||
question="你说本章节奏太慢,是想往哪个方向重写整章?",
|
||||
options=[
|
||||
ClarifyOption(label="砍支线压缩篇幅", value="删掉次要支线,压缩铺陈直入主线"),
|
||||
ClarifyOption(label="提前引爆冲突", value="把本章高潮冲突提前到前半段"),
|
||||
],
|
||||
allow_free_text=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _clear_rewrite_decision() -> object:
|
||||
from ww_agents import ClarifyDecision
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=False,
|
||||
verification="我会把本章高潮提前、删掉开头回忆插叙,直接进冲突,对吗?",
|
||||
)
|
||||
|
||||
|
||||
def _clarify_app(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
session: FakeSession | None = None,
|
||||
clarify_gateway: object | None = None,
|
||||
) -> FastAPI:
|
||||
"""构造只挂 rewrite/clarify 端点依赖的 app(project_repo + session + clarify 网关)。"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_clarify_gateway, get_project_repo
|
||||
from ww_db import get_session
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_session] = lambda: session or FakeSession()
|
||||
if clarify_gateway is not None:
|
||||
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
|
||||
return app
|
||||
|
||||
|
||||
def _clarify_client(app: FastAPI) -> httpx.AsyncClient:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_vague_feedback_asks_one_question_and_commits() -> None:
|
||||
# (a) 含糊章级意见 → need_clarification True + 恰 1 问;analyst 档 + 末尾 commit 落账。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_need_clarification_decision())
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "节奏太慢", "prior_draft": "本章开头……"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is True
|
||||
assert len(body["questions"]) == 1
|
||||
assert body["questions"][0]["options"][0]["label"] == "砍支线压缩篇幅"
|
||||
assert body["questions"][0]["allow_free_text"] is True
|
||||
# 不变量 #2:analyst 档;意见折进网关 input;末尾一次 commit(网关 usage_ledger 落库)。
|
||||
assert gateway.requests[0].tier == "analyst"
|
||||
assert "节奏太慢" in str(gateway.requests[0].input)
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_clear_feedback_proceeds_read_only() -> None:
|
||||
# (b) 明确意见 → need_clarification False + verification;只读、无回滚。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "把本章高潮提前到前半段,删掉开头回忆插叙。"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is False
|
||||
assert body["questions"] == []
|
||||
assert body["verification"] is not None
|
||||
# 只读不写业务表:仅一次 commit 落网关 usage,无回滚(不变量 #3)。
|
||||
assert session.commits == 1
|
||||
assert session.rollbacks == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_unknown_project_404() -> None:
|
||||
# (c) 项目不存在 → 404(触网关前 fail-fast)。
|
||||
app = _clarify_app(
|
||||
project_repo=FakeProjectRepo(),
|
||||
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
|
||||
)
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/chapters/1/rewrite/clarify",
|
||||
json={"feedback": "节奏太慢"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_fails_open_on_wrong_parsed() -> None:
|
||||
# (d) 网关返回非 ClarifyDecision → 端点仍 200 + need_clarification False(韧性回退,非 500)。
|
||||
from ww_agents import WorldGenResult
|
||||
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=WorldGenResult(entities=[]))
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "重写一遍"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["need_clarification"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_empty_feedback_422() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
app = _clarify_app(
|
||||
project_repo=project_repo,
|
||||
clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
|
||||
)
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/rewrite/clarify", json={"feedback": ""}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rewrite_clarify_bounds_excerpt_and_never_leaks_manuscript() -> None:
|
||||
# 有界摘录:超长 prior_draft 的越界正文不进网关 input,响应也绝不回灌正文(脱敏)。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
|
||||
app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
# 哨兵放在远超摘录上界处(10k 填充 + 哨兵),确保被截断在摘录外。
|
||||
prior_draft = ("正" * 10_000) + _LEAK_SENTINEL
|
||||
|
||||
async with _clarify_client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/3/rewrite/clarify",
|
||||
json={"feedback": "重写本章", "prior_draft": prior_draft},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# 越界正文既不进 LLM 输入,也不出现在响应体(正文只喂有界摘录、绝不回灌)。
|
||||
assert _LEAK_SENTINEL not in str(gateway.requests[0].input)
|
||||
assert _LEAK_SENTINEL not in resp.text
|
||||
|
||||
@@ -59,10 +59,12 @@ def _app_with_overrides(
|
||||
session_factory: FakeSessionFactory | None = None,
|
||||
extract_gateway: object | None = None,
|
||||
refine_gateway: object | None = None,
|
||||
clarify_gateway: object | None = None,
|
||||
) -> FastAPI:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_clarify_gateway,
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
get_refine_gateway,
|
||||
@@ -85,6 +87,8 @@ def _app_with_overrides(
|
||||
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
|
||||
if refine_gateway is not None:
|
||||
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
|
||||
if clarify_gateway is not None:
|
||||
app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
|
||||
return app
|
||||
|
||||
|
||||
@@ -394,3 +398,134 @@ async def test_refine_without_credentials_503() -> None:
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
# ---- POST /refine/clarify(润色预检澄清)----
|
||||
|
||||
|
||||
def _need_clarification() -> object:
|
||||
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=True,
|
||||
questions=[
|
||||
ClarifyQuestion(
|
||||
question="你想让这段更有张力,是指哪种方向?",
|
||||
options=[
|
||||
ClarifyOption(label="加快节奏", value="拆短句、压缩铺陈"),
|
||||
ClarifyOption(label="加重冲突", value="强化人物对立"),
|
||||
],
|
||||
allow_free_text=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _clear_decision() -> object:
|
||||
from ww_agents import ClarifyDecision
|
||||
|
||||
return ClarifyDecision(
|
||||
need_clarification=False,
|
||||
verification="我会把被字句改成主动句,其余不动,对吗?",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_returns_need_clarification_and_commits() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_need_clarification())
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/refine/clarify",
|
||||
json={"segment": "原始段落。", "instruction": "更有张力"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is True
|
||||
assert len(body["questions"]) == 1
|
||||
assert body["questions"][0]["options"][0]["label"] == "加快节奏"
|
||||
assert body["questions"][0]["allow_free_text"] is True
|
||||
# analyst 档 + 末尾 commit(记账落库);指令折进输入文本。
|
||||
assert gateway.requests[0].tier == "analyst"
|
||||
assert session.commits == 1
|
||||
assert "更有张力" in str(gateway.requests[0].input)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_returns_clear_decision_read_only() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(parsed=_clear_decision())
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session, clarify_gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/2/refine/clarify",
|
||||
json={"segment": "把这句的被字句改成主动句。", "instruction": ""},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["need_clarification"] is False
|
||||
assert body["questions"] == []
|
||||
assert body["verification"] is not None
|
||||
# 只读不写库:无写侧 repo 参与,仅一次 commit 落网关 usage(不变量 #3)。
|
||||
assert session.commits == 1
|
||||
assert session.rollbacks == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_unknown_project_404() -> None:
|
||||
app = _app_with_overrides(
|
||||
project_repo=FakeProjectRepo(),
|
||||
clarify_gateway=FakeReviewGateway(parsed=_clear_decision()),
|
||||
)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/chapters/1/refine/clarify",
|
||||
json={"segment": "原段", "instruction": "x"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_empty_segment_422() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app_with_overrides(
|
||||
project_repo=project_repo, clarify_gateway=FakeReviewGateway(parsed=_clear_decision())
|
||||
)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clarify_without_credentials_503() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
|
||||
async def _no_creds() -> object:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session)
|
||||
from ww_api.services.project_deps import get_clarify_gateway
|
||||
|
||||
app.dependency_overrides[get_clarify_gateway] = _no_creds
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/refine/clarify", json={"segment": "原段"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
|
||||
@@ -33,8 +33,13 @@ class _FakeTemplateRepo:
|
||||
self.rows[view.id] = view
|
||||
return view
|
||||
|
||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
|
||||
return list(self.rows.values())
|
||||
async def list_for_owner(
|
||||
self, owner_id: uuid.UUID, *, limit: int | None = None, offset: int = 0
|
||||
) -> list[TemplateView]:
|
||||
views = list(self.rows.values())
|
||||
if limit is None:
|
||||
return views
|
||||
return views[offset : offset + limit]
|
||||
|
||||
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
|
||||
if template_id in self.rows:
|
||||
|
||||
@@ -32,7 +32,7 @@ from ww_core.domain.outline_write_repo import OutlineWriteView
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.repositories import OutlineView
|
||||
from ww_core.domain.rule_repo import RuleWriteView
|
||||
from ww_core.domain.world_entity_repo import WorldEntityWriteView
|
||||
from ww_core.domain.world_entity_repo import WorldEntityFields, WorldEntityWriteView
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
@@ -75,6 +75,15 @@ class _FakeWorldWriteRepo:
|
||||
self.rows.append({"type": type, "name": name, "rules": list(rules)})
|
||||
return WorldEntityWriteView(id=uuid.uuid4(), type=type, name=name)
|
||||
|
||||
async def create_many(
|
||||
self, project_id: uuid.UUID, entities: list[WorldEntityFields]
|
||||
) -> list[WorldEntityWriteView]:
|
||||
# 镜像 Sql:批量插入,保序返回逐条 View。
|
||||
return [
|
||||
await self.create(project_id, type=e.type, name=e.name, rules=list(e.rules))
|
||||
for e in entities
|
||||
]
|
||||
|
||||
|
||||
class _FakeOutlineWriteRepo:
|
||||
def __init__(self) -> None:
|
||||
@@ -569,6 +578,30 @@ async def test_ingest_world_entities_no_conflict_writes_201() -> None:
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_multiple_world_entities_all_land_in_one_batch() -> None:
|
||||
# 批量入库:多条实体一次 create_many 全部落库,created 保序,单次 commit。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
|
||||
payload = {
|
||||
"world_entities": [
|
||||
{"type": "力量体系", "name": "吞噬系统", "rules": ["每日上限"]},
|
||||
{"type": "势力", "name": "天机阁", "rules": ["中立"]},
|
||||
{"type": "地点", "name": "无尽海", "rules": []},
|
||||
]
|
||||
}
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=payload)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["created"] == ["吞噬系统", "天机阁", "无尽海"]
|
||||
assert [r["name"] for r in world_repo.rows] == ["吞噬系统", "天机阁", "无尽海"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_world_entities_conflict_blocks_409() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
|
||||
@@ -14,8 +14,13 @@ from ww_db import get_sessionmaker
|
||||
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import configure_logging, get_logger
|
||||
from ww_api.middleware import REQUEST_ID_HEADER, request_id_middleware
|
||||
from ww_api.middleware import (
|
||||
REQUEST_ID_HEADER,
|
||||
body_size_limit_middleware,
|
||||
request_id_middleware,
|
||||
)
|
||||
from ww_api.routers import (
|
||||
ai_messages,
|
||||
chain,
|
||||
foreshadow,
|
||||
generation,
|
||||
@@ -74,6 +79,8 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["content-type", "authorization", REQUEST_ID_HEADER],
|
||||
)
|
||||
app.middleware("http")(request_id_middleware)
|
||||
# 请求体大小守卫(CR-H9):在路由前拦截超大 body,直接 413。
|
||||
app.middleware("http")(body_size_limit_middleware)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
@@ -117,6 +124,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(rules.router)
|
||||
app.include_router(style.router)
|
||||
app.include_router(templates.router)
|
||||
app.include_router(ai_messages.router)
|
||||
app.include_router(generation.router)
|
||||
app.include_router(generation.skills_router)
|
||||
app.include_router(toolbox.router)
|
||||
|
||||
@@ -12,10 +12,14 @@ from collections.abc import Awaitable, Callable
|
||||
|
||||
import structlog
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from ww_shared import ErrorBody, ErrorCode, ErrorEnvelope
|
||||
|
||||
REQUEST_ID_HEADER = "x-request-id"
|
||||
|
||||
# 请求体大小上限(2 MiB):超过即 413,防超大 body 撑爆内存/成本(CR-H9)。
|
||||
MAX_BODY_BYTES = 2 * 1024 * 1024
|
||||
|
||||
# 放行安全字符集(字母数字 . _ -,1–128 位):覆盖 uuid4().hex、常见 trace id 与客户端
|
||||
# 自定义短 id;含空白/控制字符/换行/注入序列/超长的一律丢弃改生成新 uuid——防止把未经
|
||||
# 校验的客户端值原样写进日志/响应头(日志注入防护)。
|
||||
@@ -38,3 +42,29 @@ async def request_id_middleware(
|
||||
response = await call_next(request)
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
return response
|
||||
|
||||
|
||||
async def body_size_limit_middleware(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
"""按 `Content-Length` 拦截超大请求体,超限直接 413(CR-H9)。
|
||||
|
||||
用户中间件跑在 FastAPI 的 AppError 处理器**之外**,故必须自建错误信封响应(不能靠抛
|
||||
AppError)。分块编码(无 `Content-Length`)无法在此拦截——单用户原型可接受的限制。
|
||||
"""
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
size = int(content_length)
|
||||
except ValueError:
|
||||
size = 0 # 非法头防御性视作 0,交由下游正常解析/报错。
|
||||
if size > MAX_BODY_BYTES:
|
||||
envelope = ErrorEnvelope(
|
||||
error=ErrorBody(
|
||||
code=ErrorCode.PAYLOAD_TOO_LARGE,
|
||||
message="请求体过大",
|
||||
request_id=_sanitize_request_id(request.headers.get(REQUEST_ID_HEADER)),
|
||||
)
|
||||
)
|
||||
return JSONResponse(status_code=413, content=envelope.model_dump())
|
||||
return await call_next(request)
|
||||
|
||||
37
apps/api/ww_api/pagination.py
Normal file
37
apps/api/ww_api/pagination.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""列表端点的分页参数(`limit`/`offset`,边界校验 + 合理默认)。
|
||||
|
||||
无界列表端点(owner 维度:projects / templates)原来一次返回全部行,行数随用户
|
||||
数据线性增长。这里提供一个可复用的 FastAPI 依赖 `get_page`,把 `limit`/`offset`
|
||||
在边界处校验(越界 → 422)后交端点。默认返回第一页(`offset=0` + `DEFAULT_PAGE_LIMIT`),
|
||||
不传参的老客户端行为不破坏;`limit` 有上限(`MAX_PAGE_LIMIT`)防一次性拉爆。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Query
|
||||
|
||||
# 合理默认 + 上限(单用户原型:默认页足够覆盖常见数据量;上限防一次拉全表)。
|
||||
DEFAULT_PAGE_LIMIT = 50
|
||||
MAX_PAGE_LIMIT = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Page:
|
||||
"""一页的边界(不可变):`limit` 条数上限、`offset` 起始偏移。"""
|
||||
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
def get_page(
|
||||
limit: Annotated[int, Query(ge=1, le=MAX_PAGE_LIMIT)] = DEFAULT_PAGE_LIMIT,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Page:
|
||||
"""校验并封装分页参数(`limit∈[1,MAX]`、`offset≥0`;越界 FastAPI 返 422)。"""
|
||||
return Page(limit=limit, offset=offset)
|
||||
|
||||
|
||||
PageDep = Annotated[Page, Depends(get_page)]
|
||||
114
apps/api/ww_api/routers/ai_messages.py
Normal file
114
apps/api/ww_api/routers/ai_messages.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""AI 对话(聊天记录)端点(AC-2 / ai-chat-history-plan §3.3)。
|
||||
|
||||
作者↔AI 五类往复交换(refine/rewrite/clarify/continue/generator)落库,供每章「AI 对话」
|
||||
抽屉重放。**本端点是显式的记录写入**(前端在一轮交换结束后 POST 一批)——不是生成端点;
|
||||
五个生成端点保持只读、逐字节不变(守不变量 #3)。`ai_messages` 是侧记录,assemble()/agent
|
||||
永不读它(守 #1/#6)。
|
||||
|
||||
- POST /projects/{id}/ai-messages 一批 append(201;项目不存在 → 404)。
|
||||
- GET /projects/{id}/ai-messages 列出(newest-first;`chapter_no` 过滤含项目级 NULL;分页)。
|
||||
|
||||
提交边界:`AiMessageRepo.append` 只 flush,POST 端点写后 `commit()`(仿模板/规则写侧)。
|
||||
GET 只读不 commit。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import AiMessageRepo, AiTurnRow, ProjectRepo
|
||||
from ww_core.domain.ai_message_repo import AiMessageView as AiMessageRepoView
|
||||
from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.pagination import PageDep
|
||||
from ww_api.schemas.ai_messages import (
|
||||
AiKind,
|
||||
AiMessageAppendRequest,
|
||||
AiMessageListResponse,
|
||||
AiMessageView,
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
|
||||
|
||||
log = get_logger("ww.api.ai_messages")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["ai-messages"])
|
||||
|
||||
AiMessageRepoDep = Annotated[AiMessageRepo, Depends(get_ai_message_repo)]
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _to_response_view(v: AiMessageRepoView) -> AiMessageView:
|
||||
"""repo 视图 → API 视图(同字段镜像;避免路由碰 repo 视图类型泄漏)。"""
|
||||
return AiMessageView(
|
||||
id=v.id,
|
||||
project_id=v.project_id,
|
||||
chapter_no=v.chapter_no,
|
||||
thread_id=v.thread_id,
|
||||
seq=v.seq,
|
||||
kind=v.kind,
|
||||
tool_key=v.tool_key,
|
||||
role=v.role,
|
||||
content=v.content,
|
||||
meta=v.meta,
|
||||
created_at=v.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/ai-messages", status_code=201)
|
||||
async def append_ai_messages(
|
||||
project_id: uuid.UUID,
|
||||
body: AiMessageAppendRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
repo: AiMessageRepoDep,
|
||||
session: SessionDep,
|
||||
) -> AiMessageListResponse:
|
||||
"""一批往复 bubble append(201)。项目不存在 → 404;非法 kind/role/空/超上限 → 422。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
views = await repo.append(
|
||||
project_id,
|
||||
thread_id=body.thread_id,
|
||||
chapter_no=body.chapter_no,
|
||||
kind=body.kind,
|
||||
tool_key=body.tool_key,
|
||||
rows=[AiTurnRow(role=m.role, content=m.content, meta=m.meta) for m in body.messages],
|
||||
)
|
||||
await session.commit()
|
||||
log.info(
|
||||
"ai_messages_appended",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
thread_id=str(body.thread_id),
|
||||
kind=body.kind,
|
||||
count=len(views),
|
||||
)
|
||||
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])
|
||||
|
||||
|
||||
@router.get("/{project_id}/ai-messages")
|
||||
async def list_ai_messages(
|
||||
project_id: uuid.UUID,
|
||||
project_repo: ProjectRepoDep,
|
||||
repo: AiMessageRepoDep,
|
||||
page: PageDep,
|
||||
chapter_no: Annotated[int | None, Query(ge=1)] = None,
|
||||
kind: AiKind | None = None,
|
||||
) -> AiMessageListResponse:
|
||||
"""列出 AI 对话(newest-first)。`chapter_no` → 本章 ∪ 项目级(NULL);缺省 → 仅项目级。"""
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
views = await repo.list_for_chapter(
|
||||
project_id, chapter_no, kind=kind, limit=page.limit, offset=page.offset
|
||||
)
|
||||
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])
|
||||
@@ -33,6 +33,7 @@ from ww_agents import (
|
||||
worldbuilder_spec,
|
||||
)
|
||||
from ww_core.domain import (
|
||||
CharacterWriteFields,
|
||||
CharacterWriteRepo,
|
||||
ProjectRepo,
|
||||
RuleWriteRepo,
|
||||
@@ -372,10 +373,8 @@ async def ingest_characters(
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
for card in allowed.get("characters", []):
|
||||
view = await char_repo.create(
|
||||
project_id,
|
||||
fields = [
|
||||
CharacterWriteFields(
|
||||
name=card.name,
|
||||
role=card.role,
|
||||
traits=list(card.traits),
|
||||
@@ -387,7 +386,10 @@ async def ingest_characters(
|
||||
tags=list(card.tags),
|
||||
relations=[r.model_dump() for r in card.relations],
|
||||
)
|
||||
created.append(view.name)
|
||||
for card in allowed.get("characters", [])
|
||||
]
|
||||
# 批量 upsert(一次 flush 落全部),保序返回逐卡 View。
|
||||
created = [view.name for view in await char_repo.create_many(project_id, fields)]
|
||||
|
||||
# 提交边界:网关 ledger(precheck)+ 角色写侧均只 flush → 端点末尾一次 commit。
|
||||
await session.commit()
|
||||
|
||||
@@ -6,13 +6,11 @@ import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db import get_session
|
||||
from ww_db.models import Job
|
||||
from ww_core.domain import JobRepo
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.schemas.jobs import JobResponse
|
||||
from ww_api.services.project_deps import get_job_repo
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
|
||||
@@ -23,9 +21,9 @@ router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
)
|
||||
async def get_job(
|
||||
job_id: uuid.UUID,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
job_repo: Annotated[JobRepo, Depends(get_job_repo)],
|
||||
) -> JobResponse:
|
||||
job = (await session.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none()
|
||||
job = await job_repo.get(job_id)
|
||||
if job is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
|
||||
return JobResponse(
|
||||
|
||||
@@ -17,6 +17,8 @@ token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
@@ -47,6 +49,7 @@ from ww_api.services.kimi_oauth import (
|
||||
AuthorizationPending,
|
||||
DeviceAuth,
|
||||
SlowDown,
|
||||
TokenSet,
|
||||
decrypt_oauth_bundle,
|
||||
encrypt_oauth_bundle,
|
||||
poll_token,
|
||||
@@ -70,53 +73,104 @@ _JOB_KIND_KIMI_OAUTH = "kimi_oauth"
|
||||
_MAX_POLL_ATTEMPTS = 200
|
||||
|
||||
|
||||
#: 请求/后台 HTTP 客户端超时(秒)。
|
||||
_HTTP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
def _default_http_client() -> AsyncHttpClient:
|
||||
return httpx.AsyncClient(timeout=30.0)
|
||||
"""裸工厂:现建一个 httpx 客户端(**后台轮询**直接调用,自行在 finally 里 aclose)。"""
|
||||
return httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def _make_poll_work(device: DeviceAuth) -> Any:
|
||||
"""构造后台轮询工作闭包:循环 poll token 直到成功/过期/拒绝。
|
||||
async def _http_client() -> AsyncIterator[AsyncHttpClient]:
|
||||
"""请求级 httpx 客户端**生成器依赖**:请求结束时经 `async with` 自动 aclose(CR-H2)。
|
||||
|
||||
`work(session)` 自建 httpx 客户端 + 凭据 store(用 `run_job` 传入的独立 session)→
|
||||
按 `interval` 轮询 token → 成功则加密存 `oauth_enc` 并返回非密 job 结果 → 过期/拒绝则
|
||||
抛 AppError(`run_job` 置 job failed)。**token 绝不进 job 结果/日志**。
|
||||
普通函数依赖无 teardown,返回的 client 永不关闭 → 每次 `/start` 泄漏一个连接。故请求路径
|
||||
用此生成器依赖(后台路径仍用裸工厂 `_default_http_client`,自管生命周期)。
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS) as http:
|
||||
yield http
|
||||
|
||||
|
||||
# SlowDown 退避每次增大的间隔量(秒)。
|
||||
_SLOW_DOWN_STEP_SECONDS = 5
|
||||
|
||||
|
||||
async def _poll_for_token(http: AsyncHttpClient, device: DeviceAuth) -> TokenSet:
|
||||
"""设备授权轮询循环(**纯 HTTP,无 DB 会话**,CR-H1):直到拿到 token 或超时。
|
||||
|
||||
`authorization_pending` → 继续;`slow_down` → 增大 interval;成功 → 返回 `TokenSet`;
|
||||
次数耗尽 → 抛 `AppError`(视作过期,由调用方经 `run_job` 置 job failed)。
|
||||
"""
|
||||
interval = max(1, device.interval)
|
||||
for _ in range(_MAX_POLL_ATTEMPTS):
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
return await poll_token(http, device.device_code)
|
||||
except AuthorizationPending:
|
||||
continue
|
||||
except SlowDown:
|
||||
interval += _SLOW_DOWN_STEP_SECONDS
|
||||
continue
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"Kimi 设备授权轮询超时",
|
||||
{"provider": KIMI_CODE_PROVIDER},
|
||||
)
|
||||
|
||||
|
||||
def _make_persist_work(token: TokenSet) -> Any:
|
||||
"""构造**最终持久化**工作闭包:只做「加密 token → upsert oauth 凭据」的一次短事务。
|
||||
|
||||
`run_job` 用独立 session 调用此 work;轮询已在会话外完成,故此处会话极短。
|
||||
**明文 token 绝不进 job 结果/日志。**
|
||||
"""
|
||||
|
||||
async def work(session: AsyncSession) -> dict[str, Any]:
|
||||
enc_key = get_settings().credential_enc_key.get_secret_value()
|
||||
store = SqlCredentialStore(session)
|
||||
interval = max(1, device.interval)
|
||||
|
||||
http = _default_http_client()
|
||||
try:
|
||||
for _ in range(_MAX_POLL_ATTEMPTS):
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
token = await poll_token(http, device.device_code)
|
||||
except AuthorizationPending:
|
||||
continue
|
||||
except SlowDown:
|
||||
interval += 5
|
||||
continue
|
||||
# 成功:加密 token 包入库(auth_type=oauth)。明文 token 不进结果/日志。
|
||||
blob = encrypt_oauth_bundle(token, key=enc_key)
|
||||
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
|
||||
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
|
||||
# 轮询次数耗尽(视作过期)。
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"Kimi 设备授权轮询超时",
|
||||
{"provider": KIMI_CODE_PROVIDER},
|
||||
)
|
||||
finally:
|
||||
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
|
||||
aclose = getattr(http, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
blob = encrypt_oauth_bundle(token, key=enc_key)
|
||||
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
|
||||
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
|
||||
|
||||
return work
|
||||
|
||||
|
||||
def _raise_work(exc: Exception) -> Any:
|
||||
"""构造只把 `exc` 重抛的 work(轮询失败路径:交由 `run_job` 分类脱敏并置 job failed)。"""
|
||||
|
||||
async def work(_session: AsyncSession) -> dict[str, Any]:
|
||||
raise exc
|
||||
|
||||
return work
|
||||
|
||||
|
||||
async def _run_kimi_oauth_poll(
|
||||
session_factory: SessionFactory,
|
||||
job_id: uuid.UUID,
|
||||
device: DeviceAuth,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
"""后台任务:**先在会话外**轮询 token,再经 `run_job` 开短会话持久化(CR-H1)。
|
||||
|
||||
轮询期间不占用任何 DB 会话(防连接池饥饿);成功 → 交由 `run_job` 落库并置 done;
|
||||
失败 → 把异常带进 `run_job`,走其 `_classify_job_error`/`_mark_failed` 脱敏置 failed。
|
||||
"""
|
||||
http = _default_http_client()
|
||||
try:
|
||||
token = await _poll_for_token(http, device)
|
||||
work = _make_persist_work(token)
|
||||
except Exception as exc: # noqa: BLE001 — 失败经 run_job 统一脱敏置 job failed,不在此处冒泡。
|
||||
work = _raise_work(exc)
|
||||
finally:
|
||||
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
|
||||
aclose = getattr(http, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
await run_job(session_factory, job_id, work, request_id=request_id)
|
||||
|
||||
|
||||
@router.post("/start", status_code=202)
|
||||
async def start_oauth(
|
||||
request: Request,
|
||||
@@ -125,7 +179,7 @@ async def start_oauth(
|
||||
job_repo: JobRepoDep,
|
||||
session: SessionDep,
|
||||
session_factory: SessionFactoryDep,
|
||||
http: Annotated[AsyncHttpClient, Depends(_default_http_client)],
|
||||
http: Annotated[AsyncHttpClient, Depends(_http_client)],
|
||||
) -> OAuthStartResponse:
|
||||
"""发起 device authorization:返回 202 + user_code/verification_uri,后台轮询 token。
|
||||
|
||||
@@ -139,11 +193,12 @@ async def start_oauth(
|
||||
job = await job_repo.create(None, _JOB_KIND_KIMI_OAUTH)
|
||||
await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。
|
||||
|
||||
# 轮询移出 DB 会话(CR-H1):后台先在会话外轮询 token,仅最终持久化开一个短会话。
|
||||
background_tasks.add_task(
|
||||
run_job,
|
||||
_run_kimi_oauth_poll,
|
||||
session_factory,
|
||||
job.id,
|
||||
_make_poll_work(device),
|
||||
device,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,9 +158,7 @@ async def get_outline(
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
views = await outline_repo.list_for_project(project_id)
|
||||
if volume is not None:
|
||||
views = [v for v in views if v.volume == volume]
|
||||
views = await outline_repo.list_for_project(project_id, volume=volume)
|
||||
chapters = [
|
||||
OutlineChapterView(
|
||||
no=view.chapter_no,
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import Annotated, Any
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import ClarifyDecision, clarify_rewrite_spec
|
||||
from ww_core.domain.chapter_repo import ChapterRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo
|
||||
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
|
||||
@@ -36,13 +37,16 @@ from ww_core.orchestrator import (
|
||||
error_event,
|
||||
normalize_deltas,
|
||||
normalize_review,
|
||||
run_clarify,
|
||||
stream_chapter_draft,
|
||||
stream_chapter_rewrite,
|
||||
)
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.pagination import PageDep
|
||||
from ww_api.schemas.injection import (
|
||||
InjectionEntity,
|
||||
InjectionEntityRef,
|
||||
@@ -63,6 +67,8 @@ from ww_api.schemas.projects import (
|
||||
ReviewHistoryItem,
|
||||
ReviewHistoryResponse,
|
||||
ReviewRequest,
|
||||
RewriteClarifyRequest,
|
||||
RewriteStreamRequest,
|
||||
)
|
||||
from ww_api.services.accept_service import (
|
||||
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.project_deps import (
|
||||
get_chapter_repo,
|
||||
get_clarify_gateway,
|
||||
get_digest_append_repo,
|
||||
get_digest_gateway,
|
||||
get_injection_repo,
|
||||
@@ -98,12 +105,20 @@ _ACCEPT_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
409: {"model": ErrorEnvelope, "description": "存在未裁决冲突"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
}
|
||||
# SSE 端点:如实声明 200 的 media type 为 text/event-stream(否则 OpenAPI 误标 json)。
|
||||
_SSE_RESPONSE: dict[int | str, dict[str, Any]] = {
|
||||
200: {
|
||||
"description": "SSE 事件流(`event: <name>\\ndata: <json>`,见 memory/contracts.md)",
|
||||
"content": {"text/event-stream": {}},
|
||||
}
|
||||
}
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
||||
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
||||
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
||||
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
||||
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
|
||||
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
|
||||
@@ -137,8 +152,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID)
|
||||
async def list_projects(repo: ProjectRepoDep, page: PageDep) -> ProjectListResponse:
|
||||
"""作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。"""
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
|
||||
return ProjectListResponse(projects=[_to_response(v) for v in views])
|
||||
|
||||
|
||||
@@ -247,7 +263,7 @@ def _encode_sse(event: SseEvent) -> str:
|
||||
return f"event: {event.event}\ndata: {payload}\n\n"
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/draft")
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/draft", responses=_SSE_RESPONSE)
|
||||
async def stream_draft(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
@@ -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")
|
||||
async def save_draft(
|
||||
project_id: uuid.UUID,
|
||||
@@ -391,7 +539,7 @@ async def _resolve_review_draft(
|
||||
return saved.content
|
||||
|
||||
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/review")
|
||||
@router.post("/{project_id}/chapters/{chapter_no}/review", responses=_SSE_RESPONSE)
|
||||
async def review_chapter(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
|
||||
@@ -22,10 +22,10 @@ from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import refiner_spec, style_extract_spec
|
||||
from ww_agents import ClarifyDecision, clarify_refine_spec, refiner_spec, style_extract_spec
|
||||
from ww_core.domain import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
|
||||
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo
|
||||
from ww_core.orchestrator import run_style_extraction
|
||||
from ww_core.orchestrator import run_clarify, run_style_extraction
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
@@ -34,6 +34,7 @@ from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.style import (
|
||||
DimensionEntry,
|
||||
RefineClarifyRequest,
|
||||
RefineRequest,
|
||||
RefineResponse,
|
||||
StyleFingerprintResponse,
|
||||
@@ -45,6 +46,7 @@ from ww_api.services.foreshadow_scan import SessionFactory
|
||||
from ww_api.services.job_runner import run_job
|
||||
from ww_api.services.project_deps import (
|
||||
build_gateway_for_tier,
|
||||
get_clarify_gateway,
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
get_refine_gateway,
|
||||
@@ -62,6 +64,7 @@ JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
|
||||
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
|
||||
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
|
||||
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
|
||||
ClarifyGatewayDep = Annotated[Gateway, Depends(get_clarify_gateway)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
|
||||
|
||||
@@ -238,3 +241,59 @@ async def refine_segment(
|
||||
has_instruction=body.instruction is not None,
|
||||
)
|
||||
return RefineResponse(original=body.segment, refined=resp.text)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/chapters/{chapter_no}/refine/clarify",
|
||||
responses={
|
||||
404: {"model": ErrorEnvelope, "description": "项目不存在"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
},
|
||||
)
|
||||
async def clarify_refine(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
body: RefineClarifyRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
gateway: ClarifyGatewayDep,
|
||||
session: SessionDep,
|
||||
) -> ClarifyDecision:
|
||||
"""润色预检澄清(WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON,只判不改。
|
||||
|
||||
独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项,
|
||||
清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点)。analyst 档(不变量 #2)。
|
||||
|
||||
项目不存在 → 404;无凭据 → 503(dep 解析阶段拦下)。**只读不写库**(不变量 #3);
|
||||
末尾 `commit()` 让网关 usage_ledger 落库(add-only,否则记账静默丢失,同 refine 纪律)。
|
||||
判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
context = _build_refine_input(body.segment, body.instruction or None)
|
||||
decision = await run_clarify(
|
||||
clarify_refine_spec, # 不变量 #2:analyst 档,不传 model
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# 提交边界:只读不写业务表,但网关 ledger add-only → 端点末尾 commit(否则记账丢失)。
|
||||
await session.commit()
|
||||
|
||||
log.info(
|
||||
"refine_clarify_done",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
segment_len=len(body.segment),
|
||||
instruction_len=len(body.instruction),
|
||||
need_clarification=decision.need_clarification,
|
||||
question_count=len(decision.questions),
|
||||
)
|
||||
return decision
|
||||
|
||||
@@ -23,6 +23,7 @@ from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.pagination import PageDep
|
||||
from ww_api.schemas.templates import TemplateCreateRequest, TemplateResponse
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import get_template_repo
|
||||
@@ -36,9 +37,9 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]:
|
||||
"""列出当前用户(stub)的模板。"""
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID)
|
||||
async def list_templates(repo: TemplateRepoDep, page: PageDep) -> list[TemplateResponse]:
|
||||
"""列出当前用户(stub)的模板(分页;`?limit=&offset=`,默认第一页)。"""
|
||||
views = await repo.list_for_owner(STUB_OWNER_ID, limit=page.limit, offset=page.offset)
|
||||
return [
|
||||
TemplateResponse(
|
||||
id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key
|
||||
|
||||
@@ -25,7 +25,7 @@ from ww_core.domain.outline_write_repo import OutlineWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo
|
||||
from ww_core.domain.world_entity_repo import WorldEntityWriteRepo
|
||||
from ww_core.domain.world_entity_repo import WorldEntityFields, WorldEntityWriteRepo
|
||||
from ww_core.orchestrator import run_generator
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
@@ -427,12 +427,12 @@ async def _ingest_world_entities(
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
for entity in allowed.get("world_entities", []):
|
||||
view = await world_write_repo.create(
|
||||
project_id, type=entity.type, name=entity.name, rules=list(entity.rules)
|
||||
)
|
||||
created.append(view.name)
|
||||
fields = [
|
||||
WorldEntityFields(type=entity.type, name=entity.name, rules=list(entity.rules))
|
||||
for entity in allowed.get("world_entities", [])
|
||||
]
|
||||
# 批量插入(一次 flush 落全部),保序返回逐条 View。
|
||||
created = [view.name for view in await world_write_repo.create_many(project_id, fields)]
|
||||
|
||||
await session.commit()
|
||||
log.info(
|
||||
|
||||
74
apps/api/ww_api/schemas/ai_messages.py
Normal file
74
apps/api/ww_api/schemas/ai_messages.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""AI 对话(聊天记录)端点的请求/响应 schema(AC-2 / ai-chat-history-plan §3.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
|
||||
`Literal` 是 kind/role 的**枚举权威**(DB 列自由 Text,边界在此收窄):非法 kind/role → 422。
|
||||
批级字段(thread_id/chapter_no/kind/tool_key)提到请求顶层,`messages` 是本批各条 bubble。
|
||||
长文进 `content`(有手稿级上限),`meta` 有序列化上界兜底防绕过 content 上限。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints, field_validator
|
||||
|
||||
# kind/role 的枚举权威(DB 列自由 Text;加新 kind 改这里 + 零迁移)。
|
||||
AiKind = Literal["refine", "rewrite", "clarify", "continue", "generator"]
|
||||
AiRole = Literal["author", "ai"]
|
||||
|
||||
# content 上界 = 手稿上限(rewrite 整章文本级);命名常量非魔法数(CR-H9)。
|
||||
AI_MESSAGE_CONTENT_MAX = 200_000
|
||||
# meta 序列化上界兜底:防长文绕过 content 上限塞进 meta。
|
||||
_META_MAX_SERIALIZED = 50_000
|
||||
# 一次 append 的最大条数(一次交换的多轮 bubble)。
|
||||
_MAX_TURNS_PER_CALL = 50
|
||||
|
||||
|
||||
class AiMessageInput(BaseModel):
|
||||
"""一条待落库 bubble(角色 + 人读文本 + 结构化 meta)。"""
|
||||
|
||||
role: AiRole
|
||||
content: Annotated[str, StringConstraints(min_length=1, max_length=AI_MESSAGE_CONTENT_MAX)]
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("meta")
|
||||
@classmethod
|
||||
def _bound_meta(cls, v: dict[str, Any]) -> dict[str, Any]:
|
||||
if len(json.dumps(v, ensure_ascii=False)) > _META_MAX_SERIALIZED:
|
||||
raise ValueError("meta too large; put long text in content")
|
||||
return v
|
||||
|
||||
|
||||
class AiMessageAppendRequest(BaseModel):
|
||||
"""POST /projects/{id}/ai-messages:一次交换的一批 bubble(批级字段提顶层)。"""
|
||||
|
||||
thread_id: uuid.UUID
|
||||
chapter_no: Annotated[int, Field(ge=1)] | None = None # NULL = 项目级(工具箱)
|
||||
kind: AiKind
|
||||
tool_key: str | None = None
|
||||
messages: Annotated[list[AiMessageInput], Field(min_length=1, max_length=_MAX_TURNS_PER_CALL)]
|
||||
|
||||
|
||||
class AiMessageView(BaseModel):
|
||||
"""聊天留痕视图(append/list 回显;snake_case,镜像 repo view)。"""
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
chapter_no: int | None
|
||||
thread_id: uuid.UUID
|
||||
seq: int
|
||||
kind: str
|
||||
tool_key: str | None
|
||||
role: str
|
||||
content: str
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AiMessageListResponse(BaseModel):
|
||||
"""append/list 信封:`messages` 复数键(repo 约定,非 `items`)。"""
|
||||
|
||||
messages: list[AiMessageView]
|
||||
@@ -11,12 +11,18 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。代号=短标识;标题=一句话。
|
||||
_CODE_MAX = 100
|
||||
_TITLE_MAX = 500
|
||||
|
||||
|
||||
class ForeshadowRegisterRequest(BaseModel):
|
||||
"""POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。"""
|
||||
|
||||
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一")
|
||||
title: str = Field(min_length=1, description="伏笔标题/一句话描述")
|
||||
code: str = Field(
|
||||
min_length=1, max_length=_CODE_MAX, description="伏笔代号,`(project_id, code)` 唯一"
|
||||
)
|
||||
title: str = Field(min_length=1, max_length=_TITLE_MAX, description="伏笔标题/一句话描述")
|
||||
planted_at: int | None = Field(default=None, description="埋设章号")
|
||||
content: str | None = Field(default=None, description="伏笔正文/线索")
|
||||
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")
|
||||
|
||||
@@ -19,6 +19,9 @@ from ww_api.schemas.rules import RuleView
|
||||
# 批量生成数量上限(防一次性巨量调用;原型保守值)。
|
||||
MAX_GENERATE_COUNT = 12
|
||||
|
||||
# 一句话需求文本上界(CR-H9:防超长入参撑爆上下文/成本)。
|
||||
_BRIEF_MAX = 10_000
|
||||
|
||||
|
||||
# ---- 世界观生成(预览)----
|
||||
|
||||
@@ -26,7 +29,9 @@ MAX_GENERATE_COUNT = 12
|
||||
class WorldGenerateRequest(BaseModel):
|
||||
"""POST /projects/:id/world/generate:据需求生成世界观实体(预览)。"""
|
||||
|
||||
brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)")
|
||||
brief: str = Field(
|
||||
min_length=1, max_length=_BRIEF_MAX, description="世界观生成需求(题材/设定方向/约束)"
|
||||
)
|
||||
|
||||
|
||||
class WorldEntityCardView(BaseModel):
|
||||
@@ -54,7 +59,7 @@ class CharacterGenerateRequest(BaseModel):
|
||||
单一 `role` + `count` 语义。
|
||||
"""
|
||||
|
||||
brief: str = Field(min_length=1, description="角色生成需求")
|
||||
brief: str = Field(min_length=1, max_length=_BRIEF_MAX, description="角色生成需求")
|
||||
count: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
|
||||
@@ -11,11 +11,16 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# 请求文本长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;章正文=整章级。
|
||||
_TITLE_MAX = 200
|
||||
_CHAPTER_TEXT_MAX = 200_000
|
||||
_DIRECTIVE_MAX = 20_000 # CR-H9 收尾:本章指令喂 LLM,加上界(正文级 body 中间件仍兜底)
|
||||
|
||||
|
||||
class ProjectCreateRequest(BaseModel):
|
||||
"""POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。"""
|
||||
|
||||
title: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1, max_length=_TITLE_MAX)
|
||||
genre: str | None = None
|
||||
logline: str | None = None
|
||||
premise: str | None = None
|
||||
@@ -117,13 +122,44 @@ class DraftStreamRequest(BaseModel):
|
||||
后端只入 volatile(守缓存前缀不变量 #9)。无 body 时退化为纯按大纲生成(向后兼容)。
|
||||
"""
|
||||
|
||||
directive: str | None = None
|
||||
directive: str | None = Field(default=None, max_length=_DIRECTIVE_MAX)
|
||||
|
||||
|
||||
# 整章重写输入上界(守 DoS/成本,CR-H9 方向):意见短、整章草稿大(复用章正文上界,DRY)。
|
||||
_REWRITE_FEEDBACK_MAX = 4000
|
||||
_REWRITE_DRAFT_MAX = _CHAPTER_TEXT_MAX
|
||||
|
||||
|
||||
class RewriteStreamRequest(BaseModel):
|
||||
"""POST /projects/:id/chapters/:no/rewrite:整章再沟通/重写。
|
||||
|
||||
`feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆
|
||||
注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile(守缓存前缀不变量 #9)。
|
||||
新版停前端/草稿,接受才落库(HITL,不变量 #3)。
|
||||
"""
|
||||
|
||||
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
|
||||
prior_draft: str = Field(min_length=1, max_length=_REWRITE_DRAFT_MAX)
|
||||
|
||||
|
||||
class RewriteClarifyRequest(BaseModel):
|
||||
"""整章重写预检澄清:章级意见 + 可选当前草稿(WFW-9 M2 路线A 两阶段之「问题」阶段)。
|
||||
|
||||
与 `RewriteStreamRequest` 独立——预检只判「作者这条章级意见清不清楚」,**不改正文**
|
||||
(整章重写仍走既有 rewrite SSE 端点)。`feedback` = 作者对整章的修改意见(触发反问的场景,
|
||||
可极短/含糊);`prior_draft` = 当前整章草稿(可选,仅供锚定;端点只取有界摘录喂 analyst 预检,
|
||||
不整章入 LLM)。带长度上界防超长入参。响应=`ClarifyDecision`(在 ww_agents,端点直接返回,
|
||||
供前端 gen:api 生成强类型客户端)。
|
||||
"""
|
||||
|
||||
feedback: str = Field(min_length=1, max_length=_REWRITE_FEEDBACK_MAX)
|
||||
prior_draft: str | None = Field(default=None, max_length=_REWRITE_DRAFT_MAX)
|
||||
|
||||
|
||||
class DraftSaveRequest(BaseModel):
|
||||
"""PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""
|
||||
|
||||
text: str
|
||||
text: str = Field(max_length=_CHAPTER_TEXT_MAX)
|
||||
|
||||
|
||||
class DraftResponse(BaseModel):
|
||||
@@ -162,7 +198,7 @@ class ReviewRequest(BaseModel):
|
||||
不传 `draft` 时端点回退到已保存的草稿(chapter_repo)。
|
||||
"""
|
||||
|
||||
draft: str | None = None
|
||||
draft: str | None = Field(default=None, max_length=_CHAPTER_TEXT_MAX)
|
||||
|
||||
|
||||
class ReviewConflictView(BaseModel):
|
||||
@@ -220,7 +256,9 @@ class ConflictDecision(BaseModel):
|
||||
class AcceptRequest(BaseModel):
|
||||
"""POST /projects/:id/chapters/:no/accept:裁决清单 + 可能改过的终稿。"""
|
||||
|
||||
final_text: str = Field(min_length=1, description="作者裁决/改稿后的最终验收文本")
|
||||
final_text: str = Field(
|
||||
min_length=1, max_length=_CHAPTER_TEXT_MAX, description="作者裁决/改稿后的最终验收文本"
|
||||
)
|
||||
decisions: list[ConflictDecision] = Field(
|
||||
default_factory=list, description="对最近审稿每个冲突的裁决(每冲突必有其一,R5)"
|
||||
)
|
||||
|
||||
@@ -8,6 +8,11 @@ from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
from ww_llm_gateway.types import Tier
|
||||
|
||||
# 请求字符串字段长度上界(CR-H9:防超长入参 DoS/成本)。
|
||||
_PROVIDER_MAX = 64
|
||||
_MODEL_MAX = 128
|
||||
_API_KEY_MAX = 512
|
||||
|
||||
|
||||
class ProviderView(BaseModel):
|
||||
"""已配置提供商(掩码视图)。"""
|
||||
@@ -35,8 +40,8 @@ class ProvidersResponse(BaseModel):
|
||||
class ProviderCredentialInput(BaseModel):
|
||||
"""单条提供商凭据写入。"""
|
||||
|
||||
provider: str = Field(min_length=1)
|
||||
api_key: str = Field(min_length=1) # 明文入站,加密入库,绝不回显
|
||||
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||
api_key: str = Field(min_length=1, max_length=_API_KEY_MAX) # 明文入站,加密入库,绝不回显
|
||||
|
||||
|
||||
class TierRoutingInput(BaseModel):
|
||||
@@ -44,8 +49,8 @@ class TierRoutingInput(BaseModel):
|
||||
|
||||
# tier 限定已知档位 writer/analyst/light;未知档位 → 422(QA MEDIUM:原接受任意字符串)。
|
||||
tier: Tier
|
||||
provider: str = Field(min_length=1)
|
||||
model: str = Field(min_length=1)
|
||||
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||
model: str = Field(min_length=1, max_length=_MODEL_MAX)
|
||||
fallback: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -59,7 +64,7 @@ class ProvidersUpsertRequest(BaseModel):
|
||||
class TestConnectionRequest(BaseModel):
|
||||
"""POST /test:最小探测请求。"""
|
||||
|
||||
provider: str = Field(min_length=1)
|
||||
provider: str = Field(min_length=1, max_length=_PROVIDER_MAX)
|
||||
|
||||
|
||||
class CapabilitiesView(BaseModel):
|
||||
|
||||
@@ -14,8 +14,13 @@ from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
RuleLevel = Literal["global", "genre", "style", "project"]
|
||||
|
||||
# 规则正文长度上界(CR-H9:防超长入参 DoS/成本)。
|
||||
_RULE_CONTENT_MAX = 10_000
|
||||
|
||||
# 先 strip 再校验长度:纯空白内容(" ")strip 后为空 → 422(QA MEDIUM:此前被接受入库)。
|
||||
RuleContent = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
RuleContent = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_RULE_CONTENT_MAX)
|
||||
]
|
||||
|
||||
|
||||
class RuleCreateRequest(BaseModel):
|
||||
|
||||
@@ -7,15 +7,26 @@ snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
# 请求文本长度上界(CR-H9:防超长入参空跑 LLM/DoS)。选段/样本 20k 字符,样本列表 ≤20 条。
|
||||
_SEGMENT_MAX = 20_000
|
||||
_SAMPLE_MAX = 20_000
|
||||
_SAMPLES_LIST_MAX = 20
|
||||
# 预检澄清沿用相同选段上界(DRY);指令短。
|
||||
_MAX_CLARIFY_SEGMENT_LEN = _SEGMENT_MAX
|
||||
_MAX_CLARIFY_INSTRUCTION_LEN = 2000
|
||||
|
||||
|
||||
class StyleLearnRequest(BaseModel):
|
||||
"""学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。"""
|
||||
|
||||
samples: list[str] = Field(min_length=1)
|
||||
# 同时约束**列表条数**与**每条样本长度**(最大 DoS 面:多条超长正文)。
|
||||
samples: list[Annotated[str, StringConstraints(max_length=_SAMPLE_MAX)]] = Field(
|
||||
min_length=1, max_length=_SAMPLES_LIST_MAX
|
||||
)
|
||||
mode: Literal["create", "update"] = "create"
|
||||
|
||||
|
||||
@@ -47,8 +58,9 @@ class StyleFingerprintResponse(BaseModel):
|
||||
class RefineRequest(BaseModel):
|
||||
"""回炉:重写选中段(可选改写指令)。"""
|
||||
|
||||
segment: str = Field(min_length=1)
|
||||
instruction: str | None = None
|
||||
segment: str = Field(min_length=1, max_length=_SEGMENT_MAX)
|
||||
# CR-H9 收尾:再沟通意见喂 LLM,加上界(与 clarify instruction 同口径 2000)。
|
||||
instruction: str | None = Field(default=None, max_length=_MAX_CLARIFY_INSTRUCTION_LEN)
|
||||
|
||||
|
||||
class RefineResponse(BaseModel):
|
||||
@@ -56,3 +68,15 @@ class RefineResponse(BaseModel):
|
||||
|
||||
original: str
|
||||
refined: str
|
||||
|
||||
|
||||
class RefineClarifyRequest(BaseModel):
|
||||
"""润色预检澄清:选段 + 再沟通意见(WFW-9 M1 路线A 两阶段之「问题」阶段)。
|
||||
|
||||
与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的
|
||||
再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision`
|
||||
(在 ww_agents,端点直接返回,供前端 gen:api 生成强类型客户端)。
|
||||
"""
|
||||
|
||||
segment: str = Field(min_length=1, max_length=_MAX_CLARIFY_SEGMENT_LEN)
|
||||
instruction: str = Field(default="", max_length=_MAX_CLARIFY_INSTRUCTION_LEN)
|
||||
|
||||
@@ -12,15 +12,24 @@ from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
# 长度上界(CR-H9:防超长入参 DoS/成本)。标题=标签级;正文=段落级。
|
||||
_TITLE_MAX = 200
|
||||
_BODY_MAX = 20_000
|
||||
|
||||
# 先去首尾空白再校验长度:纯空白(" ")strip 后为空 → min_length=1 不满足 → 422。
|
||||
NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
NonBlankTitle = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_TITLE_MAX)
|
||||
]
|
||||
NonBlankBody = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=_BODY_MAX)
|
||||
]
|
||||
|
||||
|
||||
class TemplateCreateRequest(BaseModel):
|
||||
"""POST /templates:新建一条提示词模板。"""
|
||||
|
||||
title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422)")
|
||||
body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text)")
|
||||
title: NonBlankTitle = Field(description="模板标题(非空,纯空白 → 422)")
|
||||
body: NonBlankBody = Field(description="模板正文(非空,一键填入生成器的 brief/text)")
|
||||
category: str | None = Field(default=None, description="可选分类")
|
||||
tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)")
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ log = structlog.get_logger(__name__)
|
||||
# 链 job 的 kind(jobs.kind 自由 Text 列,零迁移复用;§7)。
|
||||
JOB_KIND_CHAIN = "chain"
|
||||
|
||||
# 每章在链图里的超级步数(write→review→decide→accept),用于算 recursion_limit 上界。
|
||||
NODES_PER_CHAPTER = 4 # 每章 4 个超级步:write→review→decide→accept
|
||||
# START 边 + interrupt/resume 重入的余量(防御性冗余,非精确计数)。
|
||||
RECURSION_LIMIT_HEADROOM = 10 # START 边 + interrupt/resume 重入余量
|
||||
|
||||
|
||||
# 「按 session 建 digest 网关」缝:accept 节点在自建短事务里需 light 档网关跑终稿提炼。
|
||||
DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]]
|
||||
@@ -156,13 +161,13 @@ def _chain_result(state: dict[str, Any], *, awaiting_chapter: int | None) -> dic
|
||||
}
|
||||
|
||||
|
||||
def _extract_awaiting_chapter(final: dict[str, Any]) -> int | None:
|
||||
"""从图返回值判 interrupt:有 `__interrupt__` → 取暂停章号;否则 None(跑完)。
|
||||
def _extract_awaiting_chapter(interrupts: Sequence[Any]) -> int | None:
|
||||
"""从 pending interrupts 判暂停章号:空 → None(跑完);否则取首个 interrupt 载荷的章号。
|
||||
|
||||
interrupt 载荷 = `{"chapter_no": n}`(见链图 `_accept` 节点)。LangGraph 把它放在
|
||||
`final["__interrupt__"]`(Interrupt 对象列表)。容错读 `.value`/dict 两种形。
|
||||
interrupts 经**公开** `graph.aget_state(config).interrupts`(`StateSnapshot`)拿到,不再
|
||||
反手 ainvoke 返回值里的私有 `__interrupt__` 键。载荷 = `{"chapter_no": n}`(见链图 `_accept`
|
||||
节点)。容错读 `.value`/dict 两种形。
|
||||
"""
|
||||
interrupts = final.get("__interrupt__")
|
||||
if not interrupts:
|
||||
return None
|
||||
first = interrupts[0]
|
||||
@@ -207,7 +212,14 @@ async def run_chain_job(
|
||||
try:
|
||||
async with checkpointer_ctx() as checkpointer:
|
||||
await _set_running(session_factory, job_id)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": str(job_id)}}
|
||||
# 显式每链 recursion_limit 上界(防御硬化,CR-C2):count 已被端点上限 MAX_CHAIN_COUNT
|
||||
# 夹住,故 count*每章步数+余量 是安全且贴合的上界。**必须放 config 顶层**——放到
|
||||
# `configurable` 下 langgraph 会静默忽略。单份 config 同时喂初始 run 与 resume 分支。
|
||||
recursion_limit = count * NODES_PER_CHAPTER + RECURSION_LIMIT_HEADROOM
|
||||
config: RunnableConfig = {
|
||||
"configurable": {"thread_id": str(job_id)},
|
||||
"recursion_limit": recursion_limit,
|
||||
}
|
||||
graph = build_chain_graph(
|
||||
chain_gateway_builder,
|
||||
session_factory=session_factory,
|
||||
@@ -232,9 +244,11 @@ async def run_chain_job(
|
||||
resume_value = [d.model_dump() for d in resume_decisions]
|
||||
raw_final = await graph.ainvoke(Command(resume=resume_value), config=config)
|
||||
|
||||
final: dict[str, Any] = dict(raw_final)
|
||||
awaiting_chapter = _extract_awaiting_chapter(final)
|
||||
result = _chain_result(final, awaiting_chapter=awaiting_chapter)
|
||||
# 经公开 `StateSnapshot.interrupts` 判 pending interrupt(不反手私有 `__interrupt__`);
|
||||
# checkpointer 仍在 `checkpointer_ctx()` 上下文内,aget_state 可读同一 thread 检查点。
|
||||
snapshot = await graph.aget_state(config)
|
||||
awaiting_chapter = _extract_awaiting_chapter(snapshot.interrupts)
|
||||
result = _chain_result(dict(raw_final), awaiting_chapter=awaiting_chapter)
|
||||
await _finish(session_factory, job_id, result, awaiting=awaiting_chapter is not None)
|
||||
log.info(
|
||||
"chain_job_settled",
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
||||
from ww_core.domain.ai_message_repo import AiMessageRepo, SqlAiMessageRepo
|
||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
@@ -160,6 +161,16 @@ def get_rule_write_repo(
|
||||
return SqlRuleWriteRepo(session)
|
||||
|
||||
|
||||
def get_ai_message_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> AiMessageRepo:
|
||||
"""AI 对话留痕 repo(POST/GET /projects/{id}/ai-messages;append 只 flush,端点提交)。
|
||||
|
||||
测试经 `app.dependency_overrides[get_ai_message_repo]` 注入内存 fake。
|
||||
"""
|
||||
return SqlAiMessageRepo(session)
|
||||
|
||||
|
||||
async def get_template_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> TemplateRepo:
|
||||
@@ -519,6 +530,18 @@ async def get_refine_gateway(
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
async def get_clarify_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""润色预检澄清(analyst 档位)的可注入网关缝。
|
||||
|
||||
`POST .../refine/clarify` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
|
||||
(503,进入端点前拦下)。测试经 override 注 mock(产 `ClarifyDecision`)。
|
||||
"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
async def get_chain_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway.adapters.base import Capabilities
|
||||
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
|
||||
from ww_llm_gateway.factory import build_adapter
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
@@ -85,10 +86,17 @@ class GatewayProviderProbe:
|
||||
|
||||
# 经网关工厂构造适配器(base_url 单点归网关 build_adapter,不在此本地构造 AsyncOpenAI)。
|
||||
adapter = build_adapter(provider, api_key=api_key, base_url=base_url)
|
||||
# `_PROVIDER_BASE_URLS` 里的 api_key 型 provider 均映射到 OpenAI 兼容适配器(含
|
||||
# kimi-code-key 子类);据此收窄类型,用公开 `probe_connection` 探测(不反手私有客户端)。
|
||||
if not isinstance(adapter, OpenAICompatAdapter):
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"provider {provider} 不支持 api_key 连接测试",
|
||||
{"provider": provider},
|
||||
)
|
||||
try:
|
||||
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。底层 AsyncOpenAI 由适配器持有
|
||||
# (已知 provider 为 OpenAI 兼容,见 _PROVIDER_BASE_URLS);读私有客户端属同仓既有约定。
|
||||
await adapter._client.models.list() # type: ignore[attr-defined] # noqa: SLF001
|
||||
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
|
||||
await adapter.probe_connection()
|
||||
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, Plus } from "lucide-react";
|
||||
import { BookOpen, PenLine, Plus } from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||
@@ -28,13 +28,22 @@ export default async function DashboardPage() {
|
||||
title="我的作品"
|
||||
description="从一个灵感进入正文、设定、审稿与验收闭环。"
|
||||
actions={
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href="/write"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
直接开始写
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "secondary" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
先立项
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -44,15 +53,24 @@ export default async function DashboardPage() {
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title="还没有作品"
|
||||
description="从一句灵感开始,后续的设定库、大纲、写章和审稿都会围绕这本书展开。"
|
||||
description="直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。"
|
||||
action={
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
新建作品
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Link
|
||||
href="/write"
|
||||
className={buttonClass({ variant: "primary" })}
|
||||
>
|
||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||
直接开始写
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/new"
|
||||
className={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 { buttonClass, focusRing } from "@/lib/ui/variants";
|
||||
import { AiToolbar } from "./AiToolbar";
|
||||
import { CommandSearchButton } from "./command/CommandPaletteMount";
|
||||
import { LeftNav } from "./LeftNav";
|
||||
import { NavDrawer } from "./NavDrawer";
|
||||
@@ -32,7 +31,7 @@ export function AppShell({
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
|
||||
style={{ ["--chrome" as string]: "4rem" }}
|
||||
>
|
||||
<a
|
||||
href="#main"
|
||||
@@ -80,9 +79,6 @@ export function AppShell({
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
{projectId ? (
|
||||
<AiToolbar projectId={projectId} activeNav={activeNav} />
|
||||
) : null}
|
||||
<div className="flex">
|
||||
<LeftNav projectId={projectId} activeNav={activeNav} />
|
||||
<main
|
||||
|
||||
@@ -5,6 +5,7 @@ import { X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
|
||||
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||
import { overlayScrim } from "@/lib/ui/variants";
|
||||
|
||||
@@ -15,6 +16,8 @@ interface DrawerProps {
|
||||
side?: "left" | "right";
|
||||
// 无障碍标签(role=dialog)。
|
||||
label: string;
|
||||
// 抽屉面板 id:供触发按钮 aria-controls 指向(无障碍关联)。
|
||||
id?: string;
|
||||
triggerRef?: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -26,24 +29,18 @@ export function Drawer({
|
||||
onClose,
|
||||
side = "left",
|
||||
label,
|
||||
id,
|
||||
triggerRef,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
useBodyScrollLock(open);
|
||||
useRestoreFocus(open, triggerRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (wasOpenRef.current) {
|
||||
wasOpenRef.current = false;
|
||||
triggerRef?.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
wasOpenRef.current = true;
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -54,7 +51,7 @@ export function Drawer({
|
||||
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
||||
closeRef.current?.focus();
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose, triggerRef]);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -64,6 +61,7 @@ export function Drawer({
|
||||
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
id={id}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
|
||||
@@ -15,6 +15,9 @@ interface NavDrawerProps {
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
// 抽屉面板 id:供汉堡按钮 aria-controls 关联(无障碍)。
|
||||
const DRAWER_ID = "nav-drawer";
|
||||
|
||||
// 移动端导航:汉堡按钮 + 左侧抽屉(仅 <lg;桌面用 LeftNav 静态侧栏)。UX §5.1。
|
||||
export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -32,6 +35,7 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="打开导航"
|
||||
aria-expanded={open}
|
||||
aria-controls={DRAWER_ID}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="-ml-2 lg:hidden"
|
||||
@@ -43,6 +47,7 @@ export function NavDrawer({ projectId, activeNav }: NavDrawerProps) {
|
||||
onClose={() => setOpen(false)}
|
||||
side="left"
|
||||
label="主导航"
|
||||
id={DRAWER_ID}
|
||||
triggerRef={triggerRef}
|
||||
>
|
||||
<div className="px-2 pb-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Blocks,
|
||||
@@ -19,9 +20,8 @@ import {
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
GLOBAL_NAV_ITEMS,
|
||||
isNavItemActive,
|
||||
projectNavItems,
|
||||
navSections,
|
||||
type ActiveNav,
|
||||
type NavItem,
|
||||
} from "@/lib/nav/items";
|
||||
@@ -36,35 +36,32 @@ interface NavItemsProps {
|
||||
}
|
||||
|
||||
// 导航条目列表(桌面静态侧栏与移动抽屉共用,避免渲染漂移)。
|
||||
// 项目内页把 10 项按 写作/资料库/审阅/更多 分组(P2-1),每组一个小标题;全局组无标题直显于顶部。
|
||||
export function NavItems({ projectId, activeNav, onNavigate }: NavItemsProps) {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const scoped = projectId ? projectNavItems(projectId) : [];
|
||||
const sections = navSections(projectId);
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1 px-2">
|
||||
{GLOBAL_NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
item={item}
|
||||
active={isNavItemActive(item, { pathname, activeNav })}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{scoped.length > 0 ? (
|
||||
<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}
|
||||
/>
|
||||
{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
|
||||
key={item.label}
|
||||
item={item}
|
||||
active={isNavItemActive(item, { pathname, activeNav })}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
@@ -58,10 +58,23 @@ export function ForeshadowCard({
|
||||
|
||||
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
|
||||
{typeof item.planted_at === "number" ? (
|
||||
<div>埋于 {item.planted_at} 章</div>
|
||||
<div className="flex gap-1">
|
||||
<dt>埋于</dt>
|
||||
<dd>{item.planted_at} 章</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{windowText ? (
|
||||
<div className="flex gap-1">
|
||||
<dt>窗口</dt>
|
||||
<dd>{windowText}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{item.importance ? (
|
||||
<div className="flex gap-1">
|
||||
<dt className="sr-only">重要度</dt>
|
||||
<dd>{item.importance}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{windowText ? <div>窗口 {windowText}</div> : null}
|
||||
{item.importance ? <div>{item.importance}</div> : null}
|
||||
</dl>
|
||||
|
||||
{item.progress && item.progress.length > 0 ? (
|
||||
|
||||
@@ -66,6 +66,9 @@ import { ReviewSummaryRail } from "./ReviewSummaryRail";
|
||||
import { StylePanel } from "@/components/style/StylePanel";
|
||||
import { RefineView } from "@/components/style/RefineView";
|
||||
|
||||
// 命中区闪烁高亮持续时长(朱砂脉冲)。
|
||||
const FLASH_DURATION_MS = 900;
|
||||
|
||||
interface ReviewReportProps {
|
||||
project: ProjectResponse;
|
||||
chapterNo: number;
|
||||
@@ -112,6 +115,16 @@ export function ReviewReport({
|
||||
null,
|
||||
);
|
||||
const seededRef = useRef(false);
|
||||
// flashMark 的收尾定时器句柄:组件卸载或重复触发时清除,避免定时器泄漏。
|
||||
const flashTimerRef = useRef<number | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flashTimerRef.current !== null) {
|
||||
window.clearTimeout(flashTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
|
||||
const seededConflicts = useMemo(
|
||||
@@ -333,7 +346,13 @@ export function ReviewReport({
|
||||
const mark = document.getElementById(`draft-mark-${index}`);
|
||||
if (!mark) return;
|
||||
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,
|
||||
}: RefineViewProps) {
|
||||
const refiner = useRefine();
|
||||
const { refine, abort } = refiner;
|
||||
|
||||
// 进入即触发回炉(段非空)。
|
||||
// 进入即触发回炉(段非空);清理时取消在途请求,避免旧段迟到响应覆盖新段(CR-H12)。
|
||||
useEffect(() => {
|
||||
if (segment.trim().length > 0) {
|
||||
void refiner.refine(projectId, chapterNo, segment);
|
||||
void refine(projectId, chapterNo, segment);
|
||||
}
|
||||
// 仅在段变化时触发。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segment, projectId, chapterNo]);
|
||||
return () => {
|
||||
abort();
|
||||
};
|
||||
}, [segment, projectId, chapterNo, refine, abort]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, Database, Sparkles } from "lucide-react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
@@ -23,13 +23,25 @@ import {
|
||||
type PreviewItem,
|
||||
} from "@/lib/toolbox/toolbox";
|
||||
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 type { AiTurnInput } from "@/lib/workbench/useAiConversation";
|
||||
import { TemplateFiller } from "./TemplateFiller";
|
||||
|
||||
interface GeneratorRunnerProps {
|
||||
projectId: string;
|
||||
tool: ToolDescriptorView;
|
||||
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 →
|
||||
@@ -39,6 +51,10 @@ export function GeneratorRunner({
|
||||
projectId,
|
||||
tool,
|
||||
onClose,
|
||||
onInsertText,
|
||||
manuscriptText,
|
||||
appendMessages,
|
||||
logChapterNo,
|
||||
}: GeneratorRunnerProps) {
|
||||
const gen = useGenerator();
|
||||
const toast = useToast();
|
||||
@@ -47,6 +63,8 @@ export function GeneratorRunner({
|
||||
);
|
||||
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
// 本 runner 一次会话 = 一个 thread;多次生成归此组。
|
||||
const threadId = useRef(crypto.randomUUID()).current;
|
||||
|
||||
const setField = useCallback((name: string, value: string): void => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
@@ -64,6 +82,26 @@ export function GeneratorRunner({
|
||||
[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 missing = missingRequiredFields(tool.input_fields, values);
|
||||
if (missing.length > 0) {
|
||||
@@ -71,8 +109,36 @@ export function GeneratorRunner({
|
||||
return;
|
||||
}
|
||||
setSelected(new Set());
|
||||
await gen.generate(projectId, tool.key, buildGenerateRequest(values));
|
||||
}, [gen, projectId, tool, values, toast]);
|
||||
const artifact = await gen.generate(
|
||||
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(
|
||||
() => previewRows(gen.rawPreview),
|
||||
@@ -163,6 +229,22 @@ export function GeneratorRunner({
|
||||
targetLabel={fillLabel}
|
||||
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) => (
|
||||
<FormField
|
||||
key={field.name}
|
||||
@@ -202,6 +284,11 @@ export function GeneratorRunner({
|
||||
selectable={canIngest && !singleObject}
|
||||
checked={selected.has(i)}
|
||||
onToggle={toggle}
|
||||
onInsert={
|
||||
onInsertText
|
||||
? () => onInsertText(item.body ?? item.heading ?? "")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -276,6 +363,8 @@ interface PreviewRowProps {
|
||||
selectable: boolean;
|
||||
checked: boolean;
|
||||
onToggle: (index: number) => void;
|
||||
// 可选:把本条插入正文(编辑器内联工具箱)。有可插入文本时才渲染按钮。
|
||||
onInsert?: () => void;
|
||||
}
|
||||
|
||||
// 统一预览行渲染(heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
|
||||
@@ -285,7 +374,9 @@ function PreviewRow({
|
||||
selectable,
|
||||
checked,
|
||||
onToggle,
|
||||
onInsert,
|
||||
}: PreviewRowProps) {
|
||||
const canInsert = onInsert && (item.body || item.heading);
|
||||
return (
|
||||
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
|
||||
{selectable ? (
|
||||
@@ -310,6 +401,17 @@ function PreviewRow({
|
||||
{item.body ? (
|
||||
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
|
||||
) : 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>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
|
||||
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
|
||||
import { useAiConversation } from "@/lib/workbench/useAiConversation";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { GeneratorRunner } from "./GeneratorRunner";
|
||||
|
||||
@@ -27,6 +28,8 @@ export function ToolboxPage({
|
||||
initialOpenKey,
|
||||
}: ToolboxPageProps) {
|
||||
const router = useRouter();
|
||||
// 工具箱页无抽屉:silent 直投——项目级(chapter_no=null)留痕,不 GET、失败不 toast。
|
||||
const conversation = useAiConversation(project.id, 0, { silent: true });
|
||||
|
||||
const findOpenable = useCallback(
|
||||
(key: string | undefined): ToolDescriptorView | null => {
|
||||
@@ -76,6 +79,8 @@ export function ToolboxPage({
|
||||
<GeneratorRunner
|
||||
projectId={project.id}
|
||||
tool={active}
|
||||
appendMessages={conversation.appendMessages}
|
||||
logChapterNo={null}
|
||||
onClose={() => setActive(null)}
|
||||
/>
|
||||
) : 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";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
@@ -45,11 +44,11 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
return (
|
||||
<div className="px-4">
|
||||
<SectionHeader
|
||||
title="本章助手"
|
||||
title="本章参考"
|
||||
action={
|
||||
injection.saving ? (
|
||||
<ThinkingIndicator
|
||||
label="助手保存中"
|
||||
label="保存中"
|
||||
className="text-xs text-ink-soft"
|
||||
/>
|
||||
) : null
|
||||
@@ -58,7 +57,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
|
||||
<section className="mt-4">
|
||||
<SectionHeader
|
||||
title="本章注入(透明)"
|
||||
title="AI 写这章会参考"
|
||||
action={
|
||||
data ? (
|
||||
<RecentStepper
|
||||
@@ -71,10 +70,10 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
/>
|
||||
|
||||
{injection.loading ? (
|
||||
<p className="mt-2 text-xs text-ink-soft">加载注入信息…</p>
|
||||
<p className="mt-2 text-xs text-ink-soft">加载参考信息…</p>
|
||||
) : !data ? (
|
||||
// 首次加载失败:data 仍为空 → 满屏错误框 + 重试。
|
||||
<StatusNote variant="danger" className="mt-2 text-xs" title="注入信息暂不可用">
|
||||
<StatusNote variant="danger" className="mt-2 text-xs" title="参考信息暂不可用">
|
||||
<p>请检查后端连接后重试。</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -115,7 +114,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
|
||||
{selected.length === 0 ? (
|
||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
||||
本章暂无注入实体(先在大纲点名设定/角色,或从下方排除项恢复)。
|
||||
这一章 AI 还没有要参考的设定/角色。可在大纲里点名,或从下方「已排除」里恢复。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
@@ -139,7 +138,7 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
|
||||
{excluded.length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<SectionHeader title="已排除(不注入)" />
|
||||
<SectionHeader title="已排除(本章不参考)" />
|
||||
<ul className="mt-1 space-y-1">
|
||||
{excluded.map((ref) => (
|
||||
<ExcludedRow
|
||||
@@ -156,15 +155,9 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
|
||||
|
||||
<section className="mt-6">
|
||||
<SectionHeader
|
||||
title="四审"
|
||||
description="一致性 / 伏笔 / 文风 / 节奏,在审稿页逐条裁决。"
|
||||
title="写完后检查"
|
||||
description="写完这章,AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。"
|
||||
/>
|
||||
<Link
|
||||
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
||||
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
|
||||
>
|
||||
去审稿 →
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -194,7 +187,7 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
|
||||
disabled={disabled}
|
||||
aria-pressed={pinned}
|
||||
aria-label={
|
||||
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}(强制注入)`
|
||||
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}(一定参考)`
|
||||
}
|
||||
onClick={() => onTogglePin(ref)}
|
||||
className={pinned ? "text-cinnabar" : undefined}
|
||||
@@ -209,7 +202,7 @@ function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityR
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={`排除 ${entity.name}(不注入本章)`}
|
||||
aria-label={`排除 ${entity.name}(本章不参考)`}
|
||||
onClick={() => onExclude(ref)}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
|
||||
import { buildChapterEntries, type ChapterEntry } from "@/lib/workbench/chapter";
|
||||
import { focusRing } from "@/lib/ui/variants";
|
||||
|
||||
interface ChapterListProps {
|
||||
projectId: string;
|
||||
@@ -9,36 +11,83 @@ interface ChapterListProps {
|
||||
currentChapterNo: number;
|
||||
}
|
||||
|
||||
// 左栏目录(UX §6.3):列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。
|
||||
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 ChapterListContent。
|
||||
interface DesktopChapterListProps extends ChapterListProps {
|
||||
// 目录列收起态(P2-2):收起时只留一条带「展开」按钮的窄轨,让宽给正文。
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
// 左栏目录(UX §6.3 / P2-2):列出大纲全部章节 + 当前章,点击切换写作章号(`?chapter=N`)。
|
||||
// 桌面 aside 包裹,可收起为窄轨;移动端经 Workbench 抽屉复用 ChapterListContent(不带折叠)。
|
||||
export function ChapterList({
|
||||
projectId,
|
||||
chapters,
|
||||
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 (
|
||||
<aside className="hidden border-r border-line bg-panel py-4 xl:block">
|
||||
<ChapterListContent
|
||||
projectId={projectId}
|
||||
chapters={chapters}
|
||||
currentChapterNo={currentChapterNo}
|
||||
onCollapse={onToggle}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChapterListContentProps extends ChapterListProps {
|
||||
// 桌面列传入 → 目录标题旁显示「收起」按钮;移动抽屉不传(抽屉自带关闭)。
|
||||
onCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function ChapterListContent({
|
||||
projectId,
|
||||
chapters,
|
||||
currentChapterNo,
|
||||
}: ChapterListProps) {
|
||||
onCollapse,
|
||||
}: ChapterListContentProps) {
|
||||
const entries = buildChapterEntries(chapters, currentChapterNo);
|
||||
const hasOutline = chapters.length > 0;
|
||||
return (
|
||||
<>
|
||||
<h2 className="px-4 text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
目录
|
||||
</h2>
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
目录
|
||||
</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">
|
||||
{entries.map((entry) => {
|
||||
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";
|
||||
|
||||
export interface EditorSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface EditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
streaming: boolean;
|
||||
// 选区变化上报(供「润色选段」等选区级 AI 动作取材)。空选区也会上报(start===end)。
|
||||
onSelectionChange?: (selection: EditorSelection) => void;
|
||||
}
|
||||
|
||||
// 中栏正文编辑器:宋体、720px 居中、行高 1.9(UX §2.3 / §6.3)。
|
||||
// 流式中显示朱砂光标提示(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 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 不内部滚动),由外层写作区
|
||||
// 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。
|
||||
// min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。
|
||||
@@ -35,6 +55,7 @@ export function Editor({ value, onChange, streaming }: EditorProps) {
|
||||
id="chapter-editor"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onSelect={(e) => reportSelection(e.currentTarget)}
|
||||
readOnly={streaming}
|
||||
aria-busy={streaming}
|
||||
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 { useEffect, useId, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
BookMarked,
|
||||
ChevronDown,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
Library,
|
||||
HelpCircle,
|
||||
MessagesSquare,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
PenLine,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Square,
|
||||
Wand2,
|
||||
WrapText,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Drawer } from "@/components/Drawer";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
@@ -29,11 +35,31 @@ import {
|
||||
WORKBENCH_CHAPTER_NO,
|
||||
type ChapterEntry,
|
||||
} from "@/lib/workbench/chapter";
|
||||
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
|
||||
import { buttonClass } from "@/lib/ui/variants";
|
||||
import {
|
||||
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 { 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 {
|
||||
project: ProjectResponse;
|
||||
@@ -64,11 +90,32 @@ export function Workbench({
|
||||
// 本章指令(T4-b):自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
|
||||
const [directive, setDirective] = useState("");
|
||||
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 stream = useDraftStream();
|
||||
const lastStreamText = useRef("");
|
||||
const canRefine = selection !== null && selection.text.trim().length > 0;
|
||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const contextTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
|
||||
useEffect(() => {
|
||||
@@ -85,12 +132,23 @@ export function Workbench({
|
||||
}
|
||||
}, [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 => {
|
||||
void stream.start(
|
||||
project.id,
|
||||
chapterNo,
|
||||
composeDirective(directive, presetIds),
|
||||
);
|
||||
// 无题材且未点选 → 引导先点上方常驻「题材」条再写(HITL:不静默生成无题材 slop)。
|
||||
// 选择条一直可见,理想路径是发送前已选好,第一次「写本章」即顺滑流式、不被弹窗打断。
|
||||
if (genreGate.needsGenre && genreGate.chosenGenre === null) {
|
||||
toast("先在上方选个题材,AI 写得更贴合、少套话。", "info");
|
||||
return;
|
||||
}
|
||||
startWrite(genreGate.effectiveGenre);
|
||||
};
|
||||
|
||||
const togglePreset = (id: string): void => {
|
||||
@@ -104,6 +162,111 @@ export function Workbench({
|
||||
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 closePanel = (): void => setMobilePanel(null);
|
||||
@@ -132,11 +295,19 @@ export function Workbench({
|
||||
projectId={project.id}
|
||||
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
|
||||
projectId={project.id}
|
||||
chapters={chapters}
|
||||
currentChapterNo={chapterNo}
|
||||
collapsed={chapterList.collapsed}
|
||||
onToggle={chapterList.toggle}
|
||||
/>
|
||||
|
||||
<section className="flex min-w-0 flex-col bg-bg">
|
||||
@@ -147,17 +318,85 @@ export function Workbench({
|
||||
chapterTriggerRef={chapterTriggerRef}
|
||||
assistantTriggerRef={assistantTriggerRef}
|
||||
/>
|
||||
{genreGate.needsGenre ? (
|
||||
// 项目无题材:正文上方常驻内联题材条(非弹窗),发送前选好即顺滑写章(P1-5)。
|
||||
<GenrePicker
|
||||
genres={GENRES}
|
||||
value={genreGate.chosenGenre}
|
||||
onPick={genreGate.setChosenGenre}
|
||||
/>
|
||||
) : null}
|
||||
<DirectivePanel
|
||||
directive={directive}
|
||||
onDirectiveChange={setDirective}
|
||||
presetIds={presetIds}
|
||||
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">
|
||||
{chapters.length === 0 ? (
|
||||
<StatusNote variant="info" className="mb-4">
|
||||
尚无大纲,将按设定自由生成。
|
||||
</StatusNote>
|
||||
) : null}
|
||||
<Editor
|
||||
value={text}
|
||||
onChange={onEditorChange}
|
||||
streaming={stream.isStreaming}
|
||||
onSelectionChange={setSelection}
|
||||
/>
|
||||
</div>
|
||||
<Toolbar
|
||||
@@ -169,8 +408,11 @@ export function Workbench({
|
||||
streaming={stream.isStreaming}
|
||||
streamError={stream.state.error}
|
||||
liveMessage={liveMessage}
|
||||
onWrite={onWrite}
|
||||
onStop={stream.stop}
|
||||
onOpenContext={() => setContextOpen(true)}
|
||||
contextTriggerRef={contextTriggerRef}
|
||||
onOpenConversation={() => setConversationOpen(true)}
|
||||
conversationTriggerRef={conversationTriggerRef}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -195,11 +437,33 @@ export function Workbench({
|
||||
open={mobilePanel === "assistant"}
|
||||
onClose={closePanel}
|
||||
side="right"
|
||||
label="本章助手"
|
||||
label="本章参考"
|
||||
triggerRef={assistantTriggerRef}
|
||||
>
|
||||
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -251,73 +515,280 @@ interface DirectivePanelProps {
|
||||
onDirectiveChange: (value: string) => void;
|
||||
presetIds: readonly string[];
|
||||
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({
|
||||
directive,
|
||||
onDirectiveChange,
|
||||
presetIds,
|
||||
onTogglePreset,
|
||||
defaultOpen,
|
||||
streaming,
|
||||
onWrite,
|
||||
onStop,
|
||||
}: DirectivePanelProps) {
|
||||
const panelId = useId();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
|
||||
return (
|
||||
<details className="group border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<summary
|
||||
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-serif text-base text-ink">
|
||||
<section className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
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}`}
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
本章写作指令
|
||||
</span>
|
||||
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
|
||||
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
|
||||
</span>
|
||||
</summary>
|
||||
<div id={panelId} className="mt-3 space-y-3">
|
||||
<SectionHeader
|
||||
title="写作指令"
|
||||
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="sr-only">本章指令</span>
|
||||
<TextArea
|
||||
value={directive}
|
||||
onChange={(e) => onDirectiveChange(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
|
||||
{STYLE_PRESETS.map((preset) => {
|
||||
const active = presetIds.includes(preset.id);
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onTogglePreset(preset.id)}
|
||||
variant={active ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<span className="truncate">告诉 AI 这章想怎么写</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">
|
||||
{activeCount} 项指令
|
||||
</span>
|
||||
) : 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>
|
||||
{activeCount > 0 ? (
|
||||
<StatusNote variant="info">
|
||||
写本章时会把已选预设和自由指令合并进本次生成请求。
|
||||
</StatusNote>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
AI 给草稿,你决定采不采用,原文永远你说了算。
|
||||
</p>
|
||||
{open ? (
|
||||
<div id={panelId} className="mt-3 space-y-3">
|
||||
<SectionHeader
|
||||
title="你的写作要求"
|
||||
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
|
||||
/>
|
||||
<PresetGroup
|
||||
label="文风"
|
||||
presets={STYLE_PRESETS}
|
||||
presetIds={presetIds}
|
||||
onToggle={onTogglePreset}
|
||||
/>
|
||||
<PresetGroup
|
||||
label="剧情需求"
|
||||
presets={PLOT_PRESETS}
|
||||
presetIds={presetIds}
|
||||
onToggle={onTogglePreset}
|
||||
/>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-soft">自己的要求</span>
|
||||
<TextArea
|
||||
value={directive}
|
||||
onChange={(e) => onDirectiveChange(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="写点要求,AI 更懂你(留空则按大纲写)"
|
||||
/>
|
||||
</label>
|
||||
{activeCount > 0 ? (
|
||||
<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);
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onToggle(preset.id)}
|
||||
variant={active ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -346,6 +817,8 @@ function StreamErrorNote({
|
||||
);
|
||||
}
|
||||
|
||||
// 底栏瘦身(P1-1):act-on-chapter 动词与纯导航都已上移/移除,底栏只余
|
||||
// 状态(字数/保存/播报)+ 流式时的「停」+ 唯一强调的「审稿」前进 CTA + 速查/对话记录抽屉入口。
|
||||
interface ToolbarProps {
|
||||
projectId: string;
|
||||
chapterNo: number;
|
||||
@@ -356,8 +829,14 @@ interface ToolbarProps {
|
||||
streamError: { code: string; message: string } | null;
|
||||
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
|
||||
liveMessage: string;
|
||||
onWrite: () => void;
|
||||
// 流式时的「停」:底栏 sticky,滚到长正文下方也够得着(中央条会滚出视野)。
|
||||
onStop: () => void;
|
||||
// 打开上下文速查抽屉(WFW-6)。
|
||||
onOpenContext: () => void;
|
||||
contextTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||
// 打开对话记录抽屉(AC-3)。
|
||||
onOpenConversation: () => void;
|
||||
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
@@ -369,8 +848,11 @@ function Toolbar({
|
||||
streaming,
|
||||
streamError,
|
||||
liveMessage,
|
||||
onWrite,
|
||||
onStop,
|
||||
onOpenContext,
|
||||
contextTriggerRef,
|
||||
onOpenConversation,
|
||||
conversationTriggerRef,
|
||||
}: ToolbarProps) {
|
||||
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">
|
||||
@@ -382,12 +864,7 @@ function Toolbar({
|
||||
<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" />
|
||||
写本章
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
{streaming ? (
|
||||
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
|
||||
// 易变字数 aria-hidden,仅供视觉,不逐 token 打扰屏读。
|
||||
@@ -409,24 +886,31 @@ function Toolbar({
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
{liveMessage}
|
||||
</span>
|
||||
<Link
|
||||
href={`/projects/${projectId}/outline`}
|
||||
className={buttonClass({ variant: "secondary", size: "sm" })}
|
||||
>
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
大纲
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${projectId}/foreshadow`}
|
||||
className={buttonClass({
|
||||
variant: "secondary",
|
||||
size: "sm",
|
||||
className: "hidden sm:inline-flex",
|
||||
})}
|
||||
>
|
||||
<Library className="h-4 w-4" aria-hidden="true" />
|
||||
伏笔
|
||||
</Link>
|
||||
{CONTEXT_DRAWER_ENABLED ? (
|
||||
<Button
|
||||
ref={contextTriggerRef}
|
||||
onClick={onOpenContext}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
|
||||
>
|
||||
<BookMarked className="h-4 w-4" aria-hidden="true" />
|
||||
速查
|
||||
</Button>
|
||||
) : null}
|
||||
{AI_CONVERSATION_ENABLED ? (
|
||||
<Button
|
||||
ref={conversationTriggerRef}
|
||||
onClick={onOpenConversation}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志)"
|
||||
>
|
||||
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
|
||||
对话记录
|
||||
</Button>
|
||||
) : null}
|
||||
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA(导航去重后仅此一处强调,左栏另有入口)。 */}
|
||||
<Link
|
||||
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
||||
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;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Projects */
|
||||
/**
|
||||
* List Projects
|
||||
* @description 作品列表(按 created_at 升序,分页)。`?limit=&offset=`,默认第一页。
|
||||
*/
|
||||
get: operations["list_projects_projects_get"];
|
||||
put?: never;
|
||||
/** Create Project */
|
||||
@@ -157,6 +160,58 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -406,6 +461,33 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -415,7 +497,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* List Templates
|
||||
* @description 列出当前用户(stub)的模板。
|
||||
* @description 列出当前用户(stub)的模板(分页;`?limit=&offset=`,默认第一页)。
|
||||
*/
|
||||
get: operations["list_templates_templates_get"];
|
||||
put?: never;
|
||||
@@ -450,6 +532,30 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -845,6 +951,95 @@ export interface components {
|
||||
*/
|
||||
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
|
||||
* @description 探测得到的能力矩阵(镜像网关 `Capabilities`)。
|
||||
@@ -1086,6 +1281,81 @@ export interface components {
|
||||
/** Note */
|
||||
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
|
||||
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
|
||||
@@ -1757,6 +2027,23 @@ export interface components {
|
||||
/** Tier Routing */
|
||||
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
|
||||
* @description 回炉:重写选中段(可选改写指令)。
|
||||
@@ -1868,6 +2155,36 @@ export interface components {
|
||||
/** Draft */
|
||||
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
|
||||
* @description POST /projects/:id/rules:新增一条规则。
|
||||
@@ -2437,7 +2754,10 @@ export interface operations {
|
||||
};
|
||||
list_projects_projects_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
@@ -2453,6 +2773,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["ProjectListResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_project_projects_post: {
|
||||
@@ -2707,13 +3036,14 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
/** @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 */
|
||||
@@ -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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2743,13 +3164,14 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
/** @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 */
|
||||
@@ -3237,10 +3659,67 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_templates_templates_get: {
|
||||
clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post: {
|
||||
parameters: {
|
||||
query?: 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;
|
||||
cookie?: never;
|
||||
};
|
||||
@@ -3255,6 +3734,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["TemplateResponse"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -66,3 +66,21 @@ describe("fetchDraft", () => {
|
||||
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 条目)。
|
||||
export type 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"]),
|
||||
progress: asProgress(dict["progress"]),
|
||||
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 {
|
||||
GLOBAL_NAV_ITEMS,
|
||||
isNavItemActive,
|
||||
navSections,
|
||||
projectNavItems,
|
||||
type NavItem,
|
||||
} 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", () => {
|
||||
const write: NavItem = {
|
||||
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 {
|
||||
pathname: string;
|
||||
activeNav?: ActiveNav;
|
||||
|
||||
@@ -4,12 +4,26 @@ import { applyConflictFix, hasApplicableFix } from "./applyFix";
|
||||
|
||||
describe("hasApplicableFix", () => {
|
||||
it("requires non-empty original and string replacement", () => {
|
||||
expect(hasApplicableFix("迈巴赫", "奔驰")).toBe(true);
|
||||
expect(hasApplicableFix("删我", "")).toBe(true); // 空串=删除,仍可应用
|
||||
expect(hasApplicableFix(null, "奔驰")).toBe(false);
|
||||
expect(hasApplicableFix("", "奔驰")).toBe(false);
|
||||
expect(hasApplicableFix("迈巴赫", null)).toBe(false);
|
||||
expect(hasApplicableFix(undefined, undefined)).toBe(false);
|
||||
expect(hasApplicableFix({ original: "迈巴赫", replacement: "奔驰" })).toBe(true);
|
||||
// 空串=删除,仍可应用
|
||||
expect(hasApplicableFix({ original: "删我", replacement: "" })).toBe(true);
|
||||
expect(hasApplicableFix({ original: null, replacement: "奔驰" })).toBe(false);
|
||||
expect(hasApplicableFix({ original: "", replacement: "奔驰" })).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: "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 为字符串(容许空串=删除)。
|
||||
export function hasApplicableFix(
|
||||
original: string | null | undefined,
|
||||
replacement: string | null | undefined,
|
||||
): boolean {
|
||||
// 类型谓词:命中即把 original/replacement 收窄为 string,调用方无需强转。
|
||||
export function hasApplicableFix(patch: LoosePatch): patch is ApplicablePatch {
|
||||
return (
|
||||
typeof original === "string" &&
|
||||
original.length > 0 &&
|
||||
typeof replacement === "string"
|
||||
typeof patch.original === "string" &&
|
||||
patch.original.length > 0 &&
|
||||
typeof patch.replacement === "string"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,18 +35,20 @@ export function applyConflictFix(
|
||||
original: string | null | undefined,
|
||||
replacement: string | null | undefined,
|
||||
): FixOutcome {
|
||||
if (!hasApplicableFix(original, replacement)) {
|
||||
const patch: LoosePatch = { original, replacement };
|
||||
if (!hasApplicableFix(patch)) {
|
||||
return { status: "no-patch", text };
|
||||
}
|
||||
// hasApplicableFix 已收窄为 string。
|
||||
const from = original as string;
|
||||
const to = replacement as string;
|
||||
const idx = text.indexOf(from);
|
||||
// 谓词已把 patch 收窄为 ApplicablePatch,original/replacement 皆为 string。
|
||||
const idx = text.indexOf(patch.original);
|
||||
if (idx === -1) {
|
||||
return { status: "not-found", text };
|
||||
}
|
||||
return {
|
||||
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 { useToast } from "@/components/Toast";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import type { AcceptResponse } from "@/lib/api/types";
|
||||
import { buildAcceptRequest, type DecisionDraft } from "./decisions";
|
||||
|
||||
@@ -29,12 +30,13 @@ export interface UseAccept {
|
||||
) => Promise<AcceptOutcome>;
|
||||
}
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: { missing_conflict_indices?: number[] } | null;
|
||||
};
|
||||
// 错误信封形状从后端 OpenAPI 生成的 schema 派生,避免与契约漂移(不再手抄字段)。
|
||||
type ApiErrorEnvelope = components["schemas"]["ErrorEnvelope"];
|
||||
|
||||
// accept 端点 409 CONFLICT_UNRESOLVED 在 details 里携带缺判下标;
|
||||
// 生成 schema 的 details 为松散 `{[k]: unknown}`,此处就地收窄该端点专属字段。
|
||||
interface ConflictErrorDetails {
|
||||
missing_conflict_indices?: number[];
|
||||
}
|
||||
|
||||
// 验收:乐观置 accepting → 成功置 accepted(呈现「本次将更新」清单);
|
||||
@@ -59,7 +61,8 @@ export function useAccept(): UseAccept {
|
||||
setStatus("error");
|
||||
const env = error as ApiErrorEnvelope | undefined;
|
||||
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") {
|
||||
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;
|
||||
// 块之间以空行(\n\n,兼容 \r\n\r\n)分隔。
|
||||
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));
|
||||
const evt = parseSseBlock(block);
|
||||
if (evt) events.push(evt);
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("useDraftStream", () => {
|
||||
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.headers["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" });
|
||||
@@ -99,7 +99,7 @@ describe("useDraftStream", () => {
|
||||
await result.current.start("p1", 2, " ");
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(init.body).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.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";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { errorCode } from "@/lib/generation/cards";
|
||||
import { buildRefineRequest } from "./style";
|
||||
|
||||
export type RefineStatus = "idle" | "refining" | "done" | "error";
|
||||
@@ -23,24 +24,25 @@ export interface UseRefine {
|
||||
segment: string,
|
||||
instruction?: string,
|
||||
) => Promise<RefineResult | null>;
|
||||
// 取消在途回炉(段切换/卸载时调用)——不触碰 state,安全用于 effect 清理。
|
||||
abort: () => 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)。
|
||||
// 503 LLM_UNAVAILABLE(无凭据)优雅提示去设置;其余失败 toast。
|
||||
export function useRefine(): UseRefine {
|
||||
const [status, setStatus] = useState<RefineStatus>("idle");
|
||||
const [result, setResult] = useState<RefineResult | null>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const refine = useCallback<UseRefine["refine"]>(
|
||||
async (projectId, chapterNo, segment, instruction) => {
|
||||
// 取消上一次在途请求,换新 controller(段切换竞态防护,CR-H12)。
|
||||
controllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
setStatus("refining");
|
||||
setResult(null);
|
||||
try {
|
||||
@@ -51,8 +53,11 @@ export function useRefine(): UseRefine {
|
||||
path: { project_id: projectId, chapter_no: chapterNo },
|
||||
},
|
||||
body: buildRefineRequest(segment, instruction),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
// 已被后续请求/卸载中止:丢弃迟到响应,不写状态。
|
||||
if (controller.signal.aborted) return null;
|
||||
if (error || !data) {
|
||||
setStatus("error");
|
||||
const code = errorCode(error);
|
||||
@@ -72,6 +77,8 @@ export function useRefine(): UseRefine {
|
||||
setStatus("done");
|
||||
return outcome;
|
||||
} catch {
|
||||
// 主动中止(abort)触发的异常不该报错 toast。
|
||||
if (controller.signal.aborted) return null;
|
||||
setStatus("error");
|
||||
toast("回炉请求异常,请检查网络。", "error");
|
||||
return null;
|
||||
@@ -80,10 +87,18 @@ export function useRefine(): UseRefine {
|
||||
[toast],
|
||||
);
|
||||
|
||||
// 仅取消在途请求,不触碰 state——可安全用于 effect 清理 / 卸载。
|
||||
const abort = useCallback((): void => {
|
||||
controllerRef.current?.abort();
|
||||
controllerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
controllerRef.current?.abort();
|
||||
controllerRef.current = null;
|
||||
setStatus("idle");
|
||||
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 { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
import { styleLearnResult } from "@/lib/jobs/job";
|
||||
import { errorCode } from "@/lib/generation/cards";
|
||||
import {
|
||||
buildLearnRequest,
|
||||
normalizeFingerprint,
|
||||
@@ -27,12 +28,6 @@ export interface UseStyleLearn {
|
||||
) => 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 展示指纹。
|
||||
export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
|
||||
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());
|
||||
let returned: unknown;
|
||||
await act(async () => {
|
||||
await result.current.generate("p1", "idea", { brief: "脑洞" });
|
||||
returned = await result.current.generate("p1", "idea", { brief: "脑洞" });
|
||||
});
|
||||
|
||||
expect(result.current.genStatus).toBe("preview");
|
||||
@@ -65,6 +66,10 @@ describe("useGenerator", () => {
|
||||
expect(result.current.preview?.kind).toBe("IdeaListResult");
|
||||
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
|
||||
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 () => {
|
||||
|
||||
@@ -16,6 +16,13 @@ import type { ToolGenerateRequest } from "@/lib/api/types";
|
||||
export type GenStatus = "idle" | "generating" | "preview" | "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 {
|
||||
genStatus: GenStatus;
|
||||
preview: PreviewModel | null;
|
||||
@@ -30,7 +37,7 @@ export interface UseGenerator {
|
||||
projectId: string,
|
||||
toolKey: string,
|
||||
body: ToolGenerateRequest,
|
||||
) => Promise<void>;
|
||||
) => Promise<GeneratorArtifact | null>;
|
||||
// 入库选中行;acknowledge 用于过 409(已查看冲突后重发)。
|
||||
ingest: (
|
||||
projectId: string,
|
||||
@@ -74,15 +81,22 @@ export function useGenerator(): UseGenerator {
|
||||
if (error || !data) {
|
||||
setGenStatus("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);
|
||||
setOutputKind(data.output_kind);
|
||||
setGenStatus("preview");
|
||||
return {
|
||||
preview: mapped,
|
||||
rawPreview: data.preview,
|
||||
outputKind: data.output_kind,
|
||||
};
|
||||
} catch {
|
||||
setGenStatus("error");
|
||||
toast("生成请求异常,请检查网络。", "error");
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[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