feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复

M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-20 10:39:58 +02:00
parent 5fb7bfb1de
commit 765dbdfbd4
161 changed files with 17330 additions and 208 deletions

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ apps/web/lib/api/openapi.json
.claude/settings.local.json .claude/settings.local.json
# os # os
.DS_Store .DS_Store
.gstack/

View File

@@ -774,6 +774,7 @@ def assemble(project_id, chapter_no):
| POST | `/projects/:id/characters/generate` | `{需求, count?, role?}` | 角色卡(预览,待入库) | 200 | | POST | `/projects/:id/characters/generate` | `{需求, count?, role?}` | 角色卡(预览,待入库) | 200 |
| POST | `/projects/:id/characters` | 角色卡 | 入库结果 | 201 | | POST | `/projects/:id/characters` | 角色卡 | 入库结果 | 201 |
| POST | `/projects/:id/style` | 样本(`mode=update?`) | `{job_id}`(异步见 7.4) | 202 | | POST | `/projects/:id/style` | 样本(`mode=update?`) | `{job_id}`(异步见 7.4) | 202 |
| GET | `/projects/:id/style` | — | 最新文风指纹 `{dimensions, evidence, version}` | 200 (无指纹 404) |
| GET | `/jobs/:id` | — | 任务状态/进度/结果 | 200 | | GET | `/jobs/:id` | — | 任务状态/进度/结果 | 200 |
| POST | `/projects/:id/outline` | `{范围}` | 大纲(含伏笔窗口) | 200 | | POST | `/projects/:id/outline` | `{范围}` | 大纲(含伏笔窗口) | 200 |
| POST | `/projects/:id/chapters/:no/draft` | `{}` | **SSE 流**草稿 | 200(stream) | | POST | `/projects/:id/chapters/:no/draft` | `{}` | **SSE 流**草稿 | 200(stream) |

View File

@@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Repository status ## Repository status
**This is a spec-only repository at the pre-implementation stage.** There is no application code yet — only four design documents. The next step is Phase 0 / T0.1 in `DEV_PLAN.md` (scaffold the monorepo). Do not assume any build/test tooling exists until you create it. **Phase 05 (M1M5) are all shipped; there is no active phase.** The monorepo is scaffolded and the full MVP loop runs end-to-end: 立项 → 设定库 → 大纲 → 写章(SSE) → 四审(一致性/伏笔/文风/节奏) → 验收事务,底层带多 provider 网关(路由/回退/熔断) + 声明式 skill 注册表(§5.5) + Kimi Code OAuth(K1)。Last green gate: backend ruff/format clean · mypy **154 files** · alembic **no drift** · pytest **434 passed**; frontend lint/typecheck clean · vitest **155 passed** · build OK. **The build/test tooling exists — see §Toolchain below; do not re-scaffold.**
`PROGRESS.md` is the authoritative status ledger (current phase + archived M1M5 detail + changelog). Read it before any task. **Next work if resumed**: DEV_PLAN §4 P2/后续 (向量检索 / 真任务队列 / 全书一致性扫描 / 社区市场), or the 对标竞品功能补齐 plan (`~/.claude/plans/streamed-giggling-fiddle.md` — AI续写/扩写、AI拆书、通用生成器框架、细纲、模板库).
Product: an AI-assisted **Chinese web-novel writing workflow**, delivered as a web app. The core thesis (`PRODUCT_SPEC.md §1.5`) is to treat long-form novel writing like software engineering — establish architecture (worldbuilding/characters/outline), generate against spec, test every chapter, maintain a single source of truth. Product: an AI-assisted **Chinese web-novel writing workflow**, delivered as a web app. The core thesis (`PRODUCT_SPEC.md §1.5`) is to treat long-form novel writing like software engineering — establish architecture (worldbuilding/characters/outline), generate against spec, test every chapter, maintain a single source of truth.

View File

@@ -506,6 +506,7 @@ skills(id, scope, name, description, model, system_prompt,
| `POST /projects/:id/world/generate` | 设计世界观 | 立项/写作 | worldbuilder 生成/扩展世界观、力量体系、势力、地理 | | `POST /projects/:id/world/generate` | 设计世界观 | 立项/写作 | worldbuilder 生成/扩展世界观、力量体系、势力、地理 |
| `POST /projects/:id/characters/generate` | AI 生成角色 | 立项/写作 | 一句话生成完整角色卡,入库;批量传 `count` + 定位 | | `POST /projects/:id/characters/generate` | AI 生成角色 | 立项/写作 | 一句话生成完整角色卡,入库;批量传 `count` + 定位 |
| `POST /projects/:id/style` | 学文风 | 立项 | 学习作者文风,生成文风指纹(`mode=update` 增量补样本) | | `POST /projects/:id/style` | 学文风 | 立项 | 学习作者文风,生成文风指纹(`mode=update` 增量补样本) |
| `GET /projects/:id/style` | 查看文风 | 立项/写作 | 读取最新文风指纹(完整 16 维 + 原文证据 + 版本号;无指纹 404 |
| `POST /projects/:id/outline` | 排大纲 | 规划 | 生成/更新分卷分章大纲,提示伏笔回收窗口 | | `POST /projects/:id/outline` | 排大纲 | 规划 | 生成/更新分卷分章大纲,提示伏笔回收窗口 |
| `POST /projects/:id/chapters/:no/draft` | 写本章 | 写作 | 注入记忆+文风产出本章草稿streaming | | `POST /projects/:id/chapters/:no/draft` | 写本章 | 写作 | 注入记忆+文风产出本章草稿streaming |
| `POST /projects/:id/chapters/:no/review` | 审稿 | 审稿 | 四审并行,输出冲突/漂移/节奏报告 | | `POST /projects/:id/chapters/:no/review` | 审稿 | 审稿 | 四审并行,输出冲突/漂移/节奏报告 |

View File

@@ -8,33 +8,60 @@
--- ---
## 当前阶段:Phase 3 (M3) · 伏笔 + 节奏 ## 当前阶段:K1 · Kimi Code OAuth订阅 plan 接入)
> 目标:伏笔账本/到期提醒/看板 + 节奏引擎 + 大纲。**契约先行**C6 扩(foreshadow-analyst/pace-checker/outliner 三 spec + 输出 schema) → C4 扩(审图加两审分支 + outline 节点) → C3 扩(outline/foreshadow 端点) > **背景**:用户要求接入 Kimi Code 订阅 plandevice-flow OAuth。**已知情接受 ToS/封号风险**——研究确认:用订阅 plan 调用需伪造官方客户端 `User-Agent` + `X-Msh-*` 头Kimi ToS 视为违规、可能封停会员(见变更日志研究结论)。用户明确选择此路径(非合规的 API Key 路径)。与既有 API-key провайдер `kimi` **分离**,新增独立 provider `kimi-code` 以便切换
> 执行波次依赖驱动A(T3.1‖T3.4) → B(T3.2‖T3.3) → C(T3.5) → D(T3.6) → E(T3.7)。 > **集成契约(研究确认,实现时对照 `github.com/picassio/pi-kimi-coder` 校验)**
> 计划已定的衔接决策: > - device flow`POST https://auth.kimi.com/api/oauth/device_authorization`client_id `17e5f671-d194-4dfb-9706-5516cb48c098`env `KIMI_CLIENT_ID` 可覆盖scope `kimi-code`)→ 返回 user_code/verification_uri/device_code/interval轮询 `POST https://auth.kimi.com/api/oauth/token`grant device_code处理 authorization_pending/slow_down。refresh同 token 端点 grant refresh_token。
> **M3-a** `foreshadow` 表已在 T0.2 建齐(含 status/expected_close_from/to/progress/links 列)→ **M3 无新建表迁移**T3.1 仅 Repository + 纯函数状态机,落 `core/ww_core/domain/`@backend owned同 T2.3 先例,故 T3.1 负责据实记 @backend > - 推理base `https://api.kimi.com/coding/v1`OpenAI 兼容model `kimi-for-coding``Authorization: Bearer <access_token>` + **必需伪造头**`User-Agent`(如 `KimiCLI/1.x`) + `X-Msh-Platform`/`X-Msh-Version`/`X-Msh-Device-Name`/`X-Msh-Device-Model`/`X-Msh-Os-Version`/`X-Msh-Device-Id`(持久 UUID)。缺头→403
> **M3-b** 到期扫描T3.2)挂在**验收后**触发——接 M2 验收事务 §5.5 步骤 3 当前留的 `TODO(M3)`(人物 latest_state/伏笔更新)占位 > - tokenaccess ~15min / refresh ~30d → **建网关时按需刷新**(临近过期刷新并持久化)。存储:`provider_credentials` 加 `auth_type`(api_key|oauth) + `oauth_enc`(Fernet 加密 JSON `{access_token,refresh_token,expires_at}`)
> **M3-c** 第三审foreshadow/pace复用 M2 `build_review_graph` 的 `review_specs` 扩展点并入并行分支C4 已稳定);**文风第四审属 M4/T4.2**M3 先到三审齐 > **执行波次**A(@db 加列+迁移 ‖ @llm Kimi coding 适配器) → B(@backend OAuth device 服务 + 端点 + store + `_build_provider_adapter` 接线) → C(@frontend 设置页连接/轮询/断开 + 路由档位) → D(@qa E2E device-flow mock + 头断言)
> **M3-d** 到期判据为纯函数确定性代码(非 Agent`current_ch > expected_close_to AND status≠CLOSED → OVERDUE` > **测试纪律**device flow 注入 fake httpx零联网适配器断言伪造头集token 刷新逻辑单测E2E mock auth+coding 两端点
| 任务 | 状态 | 负责 | 依赖 | 契约 | 备注 | | 任务 | 状态 | 负责 | 依赖 | 契约 | 备注 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| T3.1 foreshadow Repository + 纯函数状态机 | ✅ @backend 2026-06-18 | @backend | T0.2✅ | — | `foreshadow_state.py`(ForeshadowStatus+transition/is_overdue/apply_overdue_scan/InvalidTransition)+`foreshadow_repo.py`(`ForeshadowLedger*`register/list_by_status/transition/record_progress/scan_overdue只 flush)37 测;写侧加 Ledger 前缀避撞 C5 读侧 | | K1.1 provider_credentials 加 auth_type + oauth_enc 列 | ✅ | @db | — | C2扩 | 完成models 加 auth_type/oauth_enc + api_key_enc 改 nullable迁移 `1f011c42bd4d`alembic check 无漂移。⚠️ K1.3 @backend 须把 `StoredCredential.api_key_enc``bytes\|None`(见下方请求 + memory|
| T3.2 到期扫描(BackgroundTask) + 登记/状态接口 | ✅ @backend 2026-06-18 | @backend | T2.4✅, T3.1✅ | C3✅扩 | accept 后 BackgroundTask `run_overdue_scan`(自建 session 置 OVERDUE)`POST/PATCH .../foreshadow` 登记/转移(重复/非法→VALIDATION 422)9 测全仓 205 passed | | K1.2 Kimi coding 适配器(伪造头 + OAuth bearer | ✅ | @llm | — | C1扩 | 完成:`adapters/kimi_code.py`(KimiCodeAdapter 子类化 OpenAICompatAdapter + 伪造头 + 稳定 device_id)`build_adapter``kimi-code` 分支UA 验证为 `KimiCLI/1.5`pi-kimi-coder 仅设 UAX-Msh-* 为契约附带,见 C1扩 校正11 测全绿 |
| T3.3 foreshadow-analyst + pace-checker 节点 | ✅ @llm 2026-06-18 | @llm | T2.2 | C6✅扩/C4✅扩 | `foreshadow_spec`+`pace_spec`(+schema)并入 `REVIEW_SPECS`(三审齐)collect 分列落 conflicts/foreshadow_sug/pace`normalize_review``foreshadow`/`pace` 事件;~30 测;文风审留 M4 | | K1.3 OAuth device 服务 + 端点 + store + 接线 | ✅ | @backend | K1.1,K1.2 | C3扩 | 完成:`services/kimi_oauth.py`(start/poll/refresh,注入 httpx Protocol)+3 端点(start 202→jobs 后台轮询/disconnect/status,复用 run_job)+`StoredCredential` 收口(api_key_enc→nullable+auth_type/oauth_encK1.1 的 2 mypy red 已消)+store `upsert_oauth_credential`/`delete_credential`+`_build_provider_adapter` OAuth 按需刷新(300s 缓冲)。**token 不进 job 结果/日志/响应**。全仓门禁绿(ruff/format/mypy **154**/alembic 无漂移/pytest **430**)。OpenAPI 加 3 端点→**@frontend K1.4 须 `pnpm gen:api`** |
| T3.4 outliner Agent + 伏笔窗口 | ✅ @llm 2026-06-18 | @llm | T1.1✅ | C6✅扩 | `packages/agents/`(outliner_spec+OutlineResult/OutlineChapter/ForeshadowWindow)+`orchestrator/outline_node.py`(run_outline 只读不写库)20 测C6 扩稳定 | | K1.4 设置页 Kimi Code 连接流 | ✅ | @frontend | K1.3 | — | 完成:`gen:api` 纳入 3 OAuth 端点;`KimiCodeOauth` 连接区(显 user_code+开授权页+轮询 job→已连接/断开)+ 可编辑档位路由(含 `kimi-code:kimi-for-coding`);纯逻辑 `lib/settings/{kimiOauth,providers}` + 25 新 vitest前端门禁全绿gen:api/lint/typecheck/vitest 155/build。运行时验真 auth.kimi.com 留 K1.5 |
| T3.5 APIoutline + foreshadow | ✅ @backend 2026-06-18 | @backend | T3.1✅, T3.4 | C3✅扩 | `GET .../foreshadow?status=`(看板,续 foreshadow.router) + `POST .../outline`(独立 outline.pyrun_outline analyst→upsert outline 表+commit)outline 写侧 repo+15 测全仓 220 passed | | K1.5 K1 E2E | ✅ | @qa | K1.4 | | `tests/test_k1_kimi_oauth_e2e.py`(4 用例,真 pg)device-flow(pending×2→成功)→加密 token 真落 pg(无明文+回环)→建网关断言 KimiCodeAdapter coding base+伪造头+bearer→refresh-on-build 更新存量 token(DB 真源)→disconnect 清除token 全程不进 job 结果/状态/start 响应。全仓门禁绿(ruff/format/mypy **154**/alembic 无漂移/pytest **434**)。**K1 完成,可封板** |
| T3.6 伏笔看板 + 大纲编辑器 + 节奏报告 | ✅ @frontend 2026-06-18 | @frontend | T3.3✅, T3.5✅ | — | `/foreshadow`(四泳道+登记/转移) + `/outline`(beats+窗口徽标) + 审稿页扩消费 foreshadow/pace(节拍图▁▃▅)gen:api 纳 M3 端点vitest 69(+24) |
| T3.7 M3 E2E | ✅ @qa 2026-06-18 | @qa | T3.6✅ | — | `tests/test_m3_e2e.py` 3 用例真跑(非 skip):伏笔埋设→进展→验收后扫描 OVERDUE✓ + 大纲 upsert 含窗口✓ + 三审 SSE(**strict-xfail 钉住并发记账 bug**)DB 真源断言 |
| T3.8 [bugfix] 并行审记账并发安全 | ✅ @llm 2026-06-18 | @llm | T3.7✅ | — | `SqlAlchemyLedgerSink.record` 改 add-only(去 `await flush`);并发回归测试(RED 已验旧实现炸)E2E case3 转 XPASS(三审齐落库 foreshadow_sug=2/pace✓)。**待 @qa 解钉 T3.7 strict-xfail 收尾** |
**M3 出口DoD✅ 全部达成**伏笔账本/到期提醒/看板;大纲含回收窗口;节奏报告(三审齐真持久化)。后端门禁绿ruff / mypy **111 文件** / pytest **228 passed** / alembic **无漂移**前端门禁绿gen:api / lint / tsc / vitest **69** / buildE2E 真实 DB + mock 网关零 token 走通 伏笔埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕DB 真源逐项断言。T3.7 E2E 暴露并发记账 bug → T3.8 add-only 修复 → 解钉 xfail。**M3 完成。** 下一阶段 M4文风见 DEV_PLAN进入时拉入本表。 **K1 出口DoD✅ 全部达成**作者在设置页用 Kimi Code OAuth 登录device flow显 user_code + 开授权页 + 轮询 job→ token Fernet 加密入库(`oauth_enc`,全程不进 job 结果/日志/响应)→ 把档位路由到 `kimi-code:kimi-for-coding` → 建网关时构 `KimiCodeAdapter`coding base + 伪造 UA/X-Msh-* 头 + bearer→ token 临近过期建网关时自动刷新并回写。后端门禁绿ruff/format 干净 / mypy **154** / alembic **无漂移** / pytest **434**前端门禁绿gen:api/lint/tsc/vitest **155**/buildE2E`tests/test_k1_kimi_oauth_e2e.py` 4 用例device-flow mock 零联网走通DB 真源逐项断言(含 token 不泄漏 + refresh-on-build + disconnect 清除)。**K1 完成。** **⚠️ 仍需用户用真账号对真 auth.kimi.com/api.kimi.com 做一次联网冒烟device-flow 需伪造官方客户端 UA → ToS 视为违规、可能封停会员用户已知情自担E2E/单测仅验逻辑、不联网。**
---
## 状态M1M5 全部完成(含 M5 三项收尾余项已清理)
> **当前无活跃阶段。** Phase 05M1M5已全部交付M5 的三项非阻塞收尾余项①Codex 读端点、②Anthropic/Gemini 真实适配器选择、③`packages/skills` 纳入 workspace亦已清理。各阶段详情见下方「已归档阶段」+「变更日志」。
> 最终门禁(本次全绿):后端 ruff/format 干净、mypy **144 文件**、alembic **无漂移**、pytest **400 passed**;前端 lint/typecheck 干净、vitest **130 passed**、build OK。
> **下一步若继续**DEV_PLAN §4 的 P2/后续(向量检索 / 真任务队列 / 全书一致性扫描 / 社区市场),进入时把对应任务拉入本表。
> **仍开放的非阻塞备注**@devops 复核根 `uv.lock`@backend 改了 `packages/skills/pyproject.toml` deps 后已 `uv sync` 重建 ww-skillsAnthropic/Gemini 真实回退已接线(`build_adapter` 工厂 + 按 provider 选适配器)但尚未对真 SDK 联网验证(单测/E2E 用 OpenAI 兼容 mock
--- ---
## 已归档阶段 ## 已归档阶段
<details><summary>Phase 5 · 生成 + 多 provider + 扩展 (M5) — ✅ 全部完成2026-06-19</summary>
T5.1 worldbuilder + character-gen 节点C6✅扩群像防雷同注入已生成+已有 + 入库前 continuity 校验缝 `precheck_generated_cards`@llm · T5.4 网关多 provider 韧性C1✅扩Anthropic/Gemini 适配器 + 回退链 + `CircuitBreaker` + tenacity 退避 + 能力协商/降级 `served_by.degraded`,无 OpenAPI 形变)@llm · T5.5 Skill 运行时 + 规则C8 + C3✅扩`SkillRegistry.load` 越权声明拒绝 + 表权限沙箱 filter_reads/partition_writes + `POST /rules`@backend · T5.2 生成/入库 APIC3✅扩world/characters generate 预览 + POST characters 入库双 gatecontinuity 预检 409 + partition_writes 白名单 + schema→JSONB 形变 + GET rules/skills`build_gateway_for_tier` 接线 chain_from_routing 回退)@backend · T5.3+T5.6 角色生成器 + 世界观设计器 + 设定库 Codex + 规则页 + 技能库 + 命令面板⌘K@frontend · T5.7 M5 E2E + 切 provider真 Gateway deepseek 失败→openai 回退、DB ledger 记实际服务方、能力降级)@qa
出口达成后端门禁绿ruff/format 干净 / mypy 142 / pytest 391 / alembic 无漂移,复用 T0.2 既有表零建表迁移前端门禁绿gen:api / lint / tsc / vitest 125 / buildE2E 真 DB + mock 多 provider 零 token 走通。**M5 完成 → M1M5 全部交付。** 三非阻塞余项①Codex 读端点、②Anthropic/Gemini 真实适配器选择、③`packages/skills` 纳入 workspace随后于同日全部清理见变更日志 follow-up #1#4)。
</details>
<details><summary>Phase 4 · 文风 (M4) — ✅ 全部完成2026-06-19</summary>
T4.2 style-auditor 双轨 + 第四审C6✅扩/C4✅扩`style_extract_spec`(analyst,16 维指纹带证据,不进 review 图) + `style_drift_spec`(name="style",light,第四审并入 REVIEW_SPECS) + `refiner_spec`(writer 纯文本)REVIEW_SPECS 四审齐 + collect/SSE 加 style@llm · T4.1 jobs 长任务基建C3.5✅;`JobRepo`/`SqlJobRepo` + `run_job` 独立 session + lifespan zombie reaper@backend · T4.3 API style + refineC3✅扩`POST /style` 202+jobs BackgroundTask、`GET /style` 最新指纹、`POST /refine` 同步 diff 不写库)@backend · T4.4 文风页 + 漂移 + 回炉 + job 轮询(`useJobPoll` 状态机 + StyleUpload/FingerprintView + 审稿页第四审 StylePanel + RefineView diff 采纳合入)@frontend · T4.5 M4 E2E学文风→202 job→后台跑完→指纹真落 pg version+1 + 第四审 SSE/`chapter_reviews.style` 真填 + 回炉不写库)@qa
出口达成后端门禁绿ruff/format 干净 / mypy 115 / pytest 296 / alembic 无漂移,全程零建表迁移,复用 T0.2 预留的 `style_fingerprint`/`chapter_reviews.style`/`jobs`前端门禁绿gen:api / lint / tsc / vitest 96 / buildE2E 真 DB + mock 网关零 token 走通 学文风→第四审打分→回炉DB 真源逐项断言。@docs 已把 `GET /style` 回写 ARCH §7.2/PRODUCT_SPEC §6。
</details>
<details><summary>Phase 3 · 伏笔 + 节奏 (M3) — ✅ 全部完成2026-06-18</summary>
T3.1 foreshadow Repository + 纯函数状态机OPEN/PARTIAL/CLOSED/OVERDUE 全转移 + 到期判据 + `ForeshadowLedger*` 写侧 repo@backend · T3.4 outliner Agent + 伏笔窗口C6✅扩`outliner_spec` + `OutlineResult`(含 foreshadow_windows) + `run_outline` 只读节点)@llm · T3.3 foreshadow-analyst + pace-checker 节点C6✅扩/C4✅扩并入 REVIEW_SPECS 三审齐 + collect 分列 + SSE foreshadow/pace 事件)@llm · T3.2 到期扫描 BackgroundTask + 登记/状态接口(验收后 `run_overdue_scan` 自建 session 置 OVERDUE + `POST/PATCH .../foreshadow`,重复/非法→VALIDATION 422@backend · T3.5 API outline + foreshadowC3✅扩`GET .../foreshadow?status=` 看板 + `POST .../outline` upsert outline 表含窗口)@backend · T3.6 伏笔看板 + 大纲编辑器 + 节奏报告(四泳道 + beats+窗口徽标 + 审稿页节拍图▁▃▅)@frontend · T3.7 M3 E2E伏笔埋设→进展→验收后扫描 OVERDUE + 大纲含窗口 + 三审 SSEstrict-xfail 钉住并发记账 bug@qa · T3.8 [bugfix] 并行审记账并发安全(`SqlAlchemyLedgerSink.record` 改 add-only 去 `await flush`,解 AsyncSession 并发 flush 冲突xfail 转 XPASS 后解钉)@llm
出口达成后端门禁绿ruff / mypy 111 / pytest 228 passed0 xfailed/0 failed/ alembic 无漂移,`foreshadow` 表已 T0.2 建齐无新建表前端门禁绿gen:api / lint / tsc / vitest 69 / buildE2E 真 DB + mock 网关零 token 走通DB 真源逐项断言。T3.7 暴露并发记账 bug → T3.8 修复 → 解钉 xfail。
</details>
<details><summary>Phase 2 · 一致性 + 验收 (M2) — ✅ 全部完成2026-06-18</summary> <details><summary>Phase 2 · 一致性 + 验收 (M2) — ✅ 全部完成2026-06-18</summary>
T2.1 续审 AgentSpec+结构化输出契约+网关 instructor 接线C6✅稳定改 C1`run()``parsed`@llm · T2.2 LangGraph 并行审+collect 留痕+review SSEC4✅扩@llm · T2.3 摘要/审稿留痕 Repository+章节 version@backend,表已 T0.2 建)· T2.4 验收事务+冲突 gate单原子事务 + R2 事务外提炼 digest@backend · T2.5 review API+历史C3✅扩@backend · T2.6 审稿报告页+冲突裁决+验收 gate @frontend · T2.7 M2 E2E真 DB+多档位 mock 网关零 token@qa T2.1 续审 AgentSpec+结构化输出契约+网关 instructor 接线C6✅稳定改 C1`run()``parsed`@llm · T2.2 LangGraph 并行审+collect 留痕+review SSEC4✅扩@llm · T2.3 摘要/审稿留痕 Repository+章节 version@backend,表已 T0.2 建)· T2.4 验收事务+冲突 gate单原子事务 + R2 事务外提炼 digest@backend · T2.5 review API+历史C3✅扩@backend · T2.6 审稿报告页+冲突裁决+验收 gate @frontend · T2.7 M2 E2E真 DB+多档位 mock 网关零 token@qa
@@ -63,6 +90,45 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-06-20] @frontend**✅ 接上两个新读端点:大纲页 / 写作工作台重访时回显已存内容**。`pnpm gen:api` 纳入 `GET .../outline` + `GET .../chapters/{no}/draft` + schema `DraftView``lib/api/types.ts``DraftView` 别名。新增两个 RSC 读 helper仿 `fetchForeshadow`/`fetchProject` 服务端读、无 CORS`fetchOutline(projectId)``OutlineChapterView[]`200 解包 chapters**任何错误/空降级为 `[]`** 不阻塞进页),`fetchDraft(projectId,chapterNo)``DraftView|null`(复用 `getJsonOrNull`**404→null 当空编辑器**,不抛)。**大纲页**(`outline/page.tsx`) 进页 `fetchOutline``initialChapters` 种入 `OutlineEditor``useOutline` 已据非空初值置 `ready`)→ 重访显已生成大纲;生成流照常覆盖所显章节。**写作工作台**(`write/page.tsx` + `Workbench`) 进页 `fetchDraft``content` 作编辑器初值(`useState(initialText)`)→ 重访重载已写正文404/新章 → `""` 空编辑器;**SSE 流式写 + AbortController + PUT 自动保存全不动**(流起始空、仅 streaming/done/aborted 变化才覆盖,初值不反扑活跃流);导出 `WORKBENCH_CHAPTER_NO`(原 `M1_CHAPTER_NO`)供页面拉对应章草稿。新增 `lib/api/server.test.ts`(5 vitestmock fetch 验解包/降级/404→null)。前端门禁全绿gen:api / lint / typecheck / vitest **160**(155→+5) / build。文件`lib/api/{types,server,server.test}.ts` · `app/projects/[id]/{outline,write}/page.tsx` · `components/workbench/Workbench.tsx`。消费 contracts C3扩@backend 两读端点)。
- [2026-06-20] @backend**`GET /projects/{id}/chapters/{no}/draft` 草稿读端点(补缺口,镜像 GET /outline**。写作工作台先前只有 `POST .../draft`(SSE 流写)+`PUT .../draft`(自动保存) 无读端点 → 重访工作台编辑器空白、看似「未保存」(其实 `chapters` 行在库)。补对称读侧:`GET /projects/{id}/chapters/{no}/draft` → 200 `DraftView{project_id,chapter_no,volume,status,version,content,length}`**比 PUT 的 `DraftResponse``content`**——读侧需正文重建编辑器);无草稿行/正文空白 → **404 `NOT_FOUND`**(工作台据此呈现空编辑器,仿其它缺资源端点)。**直接复用既有 `chapter_repo.get_draft`**(同续审 `_resolve_review_draft` 读缝,`ChapterDraftView` 已含全字段,无新 repo 方法/view只读不写库多版本固定返草稿版次(`DRAFT_VERSION=1`)。文件:`schemas/projects.py`(+`DraftView`) · `routers/projects.py`(+GET endpoint+import) · `tests/test_projects.py`(+2回灌正文 / 无草稿 404)。**全仓门禁绿**ruff/format 干净、mypy **154 Success**、alembic **无漂移**、pytest **444 passed**442→+2。OpenAPI 新增 1 端点 → **@frontend`cd apps/web && pnpm gen:api`** + 工作台初次加载拉已保存草稿回灌编辑器。记 contracts C3扩。
- [2026-06-20] @backend**`GET /projects/{id}/outline` 大纲读端点(补缺口,仿 Codex 读端点)**。大纲页先前只有 `POST .../outline`(生成+落库)无读端点 → 重访页面已落库大纲不回显、看似「未保存」。补对称读侧:`GET /projects/{id}/outline` → 200 `OutlineResponse{chapters:[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows}]}`(与 POST **同形**,按 chapter_no 升序);项目存在无大纲 → **200 空列表**(非 404项目不存在 → 404 `NOT_FOUND`。**复用 C5 读侧**:扩 `OutlineRepo`(domain) + `SqlOutlineRepo`(assemble 读侧) 加 `list_for_project`(仿 Character/WorldEntityRepoorder_by chapter_no既有 `get` 不动);新注入缝 `get_outline_read_repo`(仿 `get_rules_read_repo`)。beats DB JSONB `{"beats":[...]}` → 解包裸 `list[str]`(同 POST。文件`routers/outline.py`(+GET) · `services/project_deps.py`(+dep) · `domain/repositories.py`(+protocol method) · `memory/sql_repositories.py`(+impl) · `tests/{test_outline.py(+3),fakes_projects.py(+FakeOutlineReadRepo)}` + 补 2 既有 outline faketest_memory/test_projects满足扩展后的 protocol。**全仓门禁绿**ruff/format 干净、mypy **154 Success**、alembic **无漂移**、pytest **442 passed**439→+3。OpenAPI 新增 1 端点 → **@frontend`cd apps/web && pnpm gen:api`** + 大纲页初次加载拉已持久化大纲。记 contracts C3扩。
- [2026-06-19] @llm**K1.2 修头校正:恢复 opencode 规范的完整 7 头UA `KimiCLI/1.37.0`**。先前误信 `picassio/pi-kimi-coder`UA-only把头集裁成仅 UA是分歧/错误参考导致的误修——真源改为 `ooojustin/opencode-kimi``src/headers.ts`+`src/constants.ts`1:1 镜像 kimi-cli v1.37.0,已重新拉取 master 核对)。`kimi_code_headers()` 现发 7 头:`User-Agent=KimiCLI/1.37.0` + `X-Msh-Platform=kimi_cli`(字面常量)+ `X-Msh-Version=1.37.0` + `X-Msh-Device-Name`hostname ASCII 化)+ `X-Msh-Device-Model``kimi_device_model()`macOS=`macOS {mac_ver} {machine}`+ `X-Msh-Os-Version``platform.version()`+ `X-Msh-Device-Id`(稳定 32 位无连字符 `uuid4().hex`env `KIMI_DEVICE_ID` 优先→否则持久化 `~/.kimi/device_id`)。恢复 `kimi_device_id()`/`kimi_device_model()` 助手 + 重加 importsos/uuid/socket/platform/pathlib+ `__init__` 重导出。文件:`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py` + `__init__.py` + 两测(断全 7 头键 + 固定字面值 + device-id 32 位小写 hex 稳定 + host 派生值非空 ASCIIenv 注入保 CI 确定)。更 contracts C1扩7 头 + opencode 真源)+ gotchapi-kimi-coder 分歧→回退。门禁绿ruff/format 干净、mypy 干净27 文件、pytest **60 passed**packages/llm_gateway。无 OpenAPI 形变。
- [2026-06-19] @backend**K1.3 修正device authorization 重新带 `scope=kimi-code`**(实测 401 后修。K1.5 联网实测登录拿到真 JWT 但调 `api.kimi.com/coding/v1``401 Invalid Authentication`——根因 token 缺 coding entitlement省略了 scope`start_device_authorization` 请求体加 `scope=kimi-code`=`{"client_id":<id>,"scope":"kimi-code"}`,模块常量 `KIMI_CODE_SCOPE`scope 只放 device-authtoken 交换/刷新不带。文件:`apps/api/ww_api/services/kimi_oauth.py` + 单测 `test_kimi_oauth_service.py``test_start_device_authorization_parses_response_and_sends_scope``data["scope"]=="kimi-code"`)。修正 C3扩 contract + decision「省略 scope」表述 + 加 gotcha。无 OpenAPI 形变,前端无需 re-gen。门禁绿ruff/format 干净、mypy 干净、pytest **101 passed**apps/api
- [2026-06-19] @orchestrator**🎉 K1 完成 + 独立复核Kimi Code OAuth 订阅 plan 接入)**。波次 A(K1.1 @db 加列+迁移 ‖ K1.2 @llm KimiCodeAdapter)→B(K1.3 @backend OAuth device 服务+端点+store+接线)→C(K1.4 @frontend 连接流+可编辑路由)→D(K1.5 @qa E2E) 全绿,契约先行、零返工、无新 bug。研究先行device flow at auth.kimi.com、client_id、伪造 UA/X-Msh-*、coding base、kimi-for-coding、access~15m/refresh~30d+ 对照 pi-kimi-coder/ooojustin/lemon07r 校验(省略 scope 匹配 kimi-cli v1.41.0+)。最终独立复跑确认:后端 ruff/format 干净、mypy **154 文件**、alembic **无漂移**(首个新迁移=provider_credentials 加 auth_type/oauth_enc)、pytest **434 passed**;前端 vitest **155**/build。能力闭环设置页 device-flow 登录→token Fernet 加密入库(`oauth_enc`,不泄漏)→路由 `kimi-code:kimi-for-coding`→网关构 KimiCodeAdapter(伪造头+bearer)→临过期自动刷新。**封号风险用户已知情接受**;与既有 API-key provider `kimi` 分离可切换。**唯一未尽**:对真 auth.kimi.com/api.kimi.com 的真账号联网冒烟(含封号风险,用户自担)——代码/E2E 仅验逻辑零联网。
- [2026-06-19] @qa**K1.5 ✅ K1 E2EKimi Code OAuth device-flow 全 mock → 真 pg**。新增 `tests/test_k1_kimi_oauth_e2e.py`4 用例,零联网,真 pg`POST .../oauth/start`→202 `{job_id,user_code,verification_uri,interval}`fake httpx 跑 device-auth + token(pending×2→成功) 全程;后台 `run_job` 轮询跑在**真 e2e_sm session**override `get_session_factory`→e2e_sm + monkeypatch 模块级 `kimi_oauth._default_http_client`→同一 scripted fake + `kimi_oauth.asyncio.sleep`→no-op端点 device-auth 走 `_default_http_client` dep 同样指 fake`GET /jobs/{id}` doneresult `{connected:true,provider:"kimi-code"}`**DB 真源**`provider_credentials``auth_type="oauth"`/`oauth_enc` 非空/`api_key_enc` null密文不含明文 token + 解密回环 access/refresh`GET .../oauth/status` connected 无 token。② 存 oauth + `tier_routing` writer→`kimi-code:kimi-for-coding``build_gateway_for_tier``KimiCodeAdapter`,其 `_client` 带 coding base `https://api.kimi.com/coding/v1` + UA `KimiCLI/1.5` + 全 X-Msh-* + `api_key`(=bearer)=存量 access token。③ 临近过期 oauth 包 → `_build_provider_adapter` 触发刷新fake `project_deps.kimi_refresh` + 字符串 target monkeypatch `project_deps.httpx.AsyncClient`)→ 适配器用新 token + `oauth_enc`(DB 真源)更新为新包。④ `POST .../oauth/disconnect`→凭据行清除DB 真源无 oauth。**token 全程负向断言:不进 job 结果/状态响应/start 响应/密文明文**。全仓门禁绿ruff/format 干净、mypy **154 Success**、alembic 无漂移、pytest **434 passed**430→+4。memory 记 1 gotcha。**K1Kimi Code OAuth 订阅 plan 接入)全 5 子任务 ✅,可封板。⚠️ 仍未对真 auth.kimi.com/api.kimi.com 联网验证(含封号风险,用户自担)。**
- [2026-06-19] @frontend**K1.4 ✅ 设置页 Kimi Code OAuth 连接流 + 可编辑档位路由**`pnpm gen:api` 纳入 K1.3 的 3 端点OAuthStart/Disconnect/StatusResponse`lib/api/types.ts` 加 3 别名。纯逻辑node-env vitest`lib/settings/kimiOauth.ts``connectPhase` 状态机 idle/awaiting/connected/error + `toDeviceDisplay`/`authOpenUrl`/`toConnectionStatus`/`jobConnected`/`formatExpiresAt`16 测)+ `lib/settings/providers.ts` 扩(`KNOWN_PROVIDERS``kimi-code`(auth:oauth, model:kimi-for-coding) + `API_KEY_PROVIDERS`/`TIERS`/`defaultModelFor` + 路由草稿纯函数 `toRoutingDrafts`/`applyProviderChange`/`draftsToRoutingInput`9 测)。组件:`useKimiOauth` hook**复用 `useJobPoll`**connect=POST start→展示 user_code+`window.open` 授权页+轮询 job→done 拉 statusdisconnect=POST→复位+ `KimiCodeOauth.tsx`连接区user_code 用 `<output select-all tabIndex=0>` 可读可选可聚焦 + motion-safe`ProvidersSettings.tsx` 改:档位路由从只读→**可编辑**per-tier select provider + model input + 保存 PUTAPI-key 凭据行只列 `API_KEY_PROVIDERS`OAuth 提供商有独立连接区,无 key 输入),挂入 `KimiCodeOauth``server.ts``fetchKimiOauthStatus``page.tsx` `Promise.all` 拉 providers+oauth 状态传 `kimiOauth` prop进页即显连接态。**job 结果无 token只读 connected**。前端门禁全绿gen:api / lint / typecheck / vitest **155 passed**130→+25/ build。memory 记 1 gotcha。**⚠️ 运行时对真 auth.kimi.com 设备授权 + 真 api.kimi.com 调用验证留 K1.5/手动(含封号风险,用户自担)。**
- [2026-06-19] @backend**K1.3 ✅ Kimi Code OAuth device-flow 服务 + 端点 + store + 网关接线C3 扩稳定)**。新增 `apps/api/ww_api/services/kimi_oauth.py``start_device_authorization`/`poll_token`/`refresh`,注入 `AsyncHttpClient` Protocol 零联网;`TokenSet`+`encrypt_oauth_bundle`/`decrypt_oauth_bundle` 复用 Fernet`needs_refresh` 300s 缓冲client_id env `KIMI_CLIENT_ID` 可覆盖、device authorization 省略 scope+ `routers/kimi_oauth.py``POST .../oauth/start`→202 `{job_id,user_code,verification_uri,...}`+ BackgroundTask `run_job` 后台循环轮询→成功存加密 oauth_enc+job done(结果只 `{connected,provider}`)`POST .../oauth/disconnect``{disconnected}``GET .../oauth/status``{connected,expires_at}` 无 token+ `schemas/kimi_oauth.py`3 schema+ 注册 main.py。`services/credentials.py``StoredCredential``auth_type`/`oauth_enc``api_key_enc``bytes|None`**K1.1 的 2 处 mypy red 消除**`SqlCredentialStore` 读新列 + `upsert_oauth_credential`/`delete_credential``upsert_credential` 显式置 api_key 态。`services/project_deps.py``_build_provider_adapter``kimi-code`/`auth_type=oauth``_resolve_kimi_code_token`(解密→临近过期则经 httpx 刷新+持久化→access token 喂 `build_adapter("kimi-code",…)``provider_deps.py` `_PROVIDER_BASE_URLS``kimi-code`。**token 全程不进 job 结果/日志/响应**(仅 Fernet 密文存 oauth_enc。TDD 先红后绿:`tests/test_kimi_oauth_service.py`(11) + `test_kimi_oauth_endpoints.py`(5) + `test_kimi_code_wiring.py`(3)fake httpx 零联网)。**全仓门禁绿**ruff/format 干净、mypy **154 源文件 Success**、alembic **无漂移**、pytest **430 passed**400→+30。OpenAPI 新增 3 端点 + 3 schema → **@frontend K1.4 须 `pnpm gen:api`**。memory 记 C3 扩K1.3+ 1 decision + 2 gotcha。**⚠️ 真 auth.kimi.com/api.kimi.com 联网验证(含封号风险)留 K1.5,用户自担。**
- [2026-06-19] @llm**K1.2 ✅ Kimi Code 订阅 plan 适配器C1 扩稳定)**`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py``KimiCodeAdapter` **子类化 `OpenAICompatAdapter`**coding 端点 OpenAI 兼容,复用 complete/stream/结构化/瞬时错误包装/capabilitiesprovider 固定 `kimi-code`(与 API-key `kimi` 分离);`build_kimi_code_client(access_token, base_url?)``AsyncOpenAI(api_key=access_token, base_url="https://api.kimi.com/coding/v1", default_headers=kimi_code_headers())`——access_token 当 bearer伪造头随客户端发。`kimi_code_headers()``User-Agent` + 全 `X-Msh-*`(Platform/Version/Device-Name/Device-Model/Os-Version/Device-Id)。`kimi_device_id()`env `KIMI_DEVICE_ID` 优先,否则确定性 `uuid5` 缺省(**绝不每次随机**,稳定且来源可测/可配。model `kimi-for-coding` **不硬编码**(经 `.complete(req, model)` 传入,路由关注点)。`factory.build_adapter``provider=="kimi-code"` 分支;包 `__init__` 导出全部符号。**契约校正(已对照 `github.com/picassio/pi-kimi-coder` `extensions/index.ts`**:参考实现对 coding API **仅**设 `User-Agent: "KimiCLI/1.5"`(精确值,已采用)、**未**设 X-Msh-* 头——与 PROGRESS 契约「缺 X-Msh→403」不符本实现以验证到的 UA 为准 + 附带 X-Msh-* 客户端标识头(真客户端会发、额外标识无害、最大化避免 403**真 api.kimi.com 联网验证头集是否足够留 K1.3/K1.5(含封号风险,用户自担)**。TDD 先红后绿;`tests/test_kimi_code_adapter.py`(8) + `tests/test_kimi_code_factory.py`(3),不联网(断 base_url + default_headers + 稳定 device_id + 工厂分派)。门禁绿(隔离 `MYPY_CACHE_DIR=.mypy_cache_k12` 已清理ruff/format 干净、mypy **27 源文件 Success**、pytest **58 passed**47→+11。**@backend 行动K1.3**`_build_provider_adapter` 对 provider `kimi-code``build_adapter("kimi-code", api_key=<刷新后的 access_token>, base_url=…)`token 刷新/获取归 K1.3。**无 OpenAPI 形变**,前端无需 re-gen。memory 记 C1 扩K1.2)契约 + 1 gotcha。
- [2026-06-19] @db**K1.1 ✅ `provider_credentials` 加 OAuth 列C2 扩稳定)**`packages/db/ww_db/models.py` `ProviderCredential`:① 加 `auth_type: Mapped[str]`(`Text, nullable=False, server_default="'api_key'"`,值 api_key|oauth);② 加 `oauth_enc: Mapped[bytes|None]`(`LargeBinary, nullable=True`,持 Fernet 加密 JSON `{access_token,refresh_token,expires_at}`);③ `api_key_enc``nullable=True`(`Mapped[bytes|None]`OAuth-only 行无 api_key、既有 api_key 行保值)`UniqueConstraint(owner_id,project_id,provider)` 不变。**首个新迁移**M1M5 全零建表)`migrations/versions/1f011c42bd4d_provider_credentials_oauth_columns.py`down_revision=`220ca2e3d53f`up=add_column auth_type/oauth_enc + alter api_key_enc→nullabledown 逆向autogenerate 仅这 3 op 无 noise手修模板重复 import + 改本仓 ruff 样式(见 gotcha`upgrade head` 已应用、`alembic check` **无漂移**。门禁ruff/format 干净、pytest **400 passed**、alembic 无漂移;**mypy 余 2 处 `arg-type` 错(均在 @backend `apps/api/ww_api/services/credentials.py`,因 api_key_enc 改 nullable 打穿K1.3 吸收,见下方 @backend 请求)**。memory 记 C2 扩 + 2 gotcha。
- **[请求 → @backendK1.3]**K1.1 把 `provider_credentials.api_key_enc` 改 nullable`bytes|None`),打穿到你拥有的 `apps/api/ww_api/services/credentials.py` mypy 报 2 处 `arg-type``StoredCredential.api_key_enc: bytes` ↳ 现 `bytes|None`dataclass line 28 + store line 75/97@db 不越权改 apps/api。K1.3 接 OAuth store 时把 `StoredCredential.api_key_enc``bytes | None`(并加 `auth_type`/`oauth_enc` 字段 + OAuth 读写路径mypy 即全绿。oauth_enc 持 Fernet 加密 JSON `{access_token,refresh_token,expires_at}`key=`settings.credential_enc_key`,同 api_key_enc
- [2026-06-19] @orchestrator**🎉 M1M5 全部交付 + M5 余项收尾完成**。五个里程碑Phase 05全部 ✅M5 三项非阻塞余项 + 本次台账归档共四项 follow-up 已清:**#1** Codex 读端点(`GET /projects/:id/characters` + `GET .../world_entities`@backend+ 前端接线CodexPage 拉全量真源,去「会话内回显」局限,@frontend**#2** `build_adapter` provider→适配器工厂 + anthropic mypy 修复(@llm`build_gateway_for_tier` 按 provider 选适配器启用 Anthropic/Gemini 真实回退(@backend**#3** `packages/skills` 纳入 uv workspace + skill implregistry/permissions`ww_core.domain` 迁入 `ww_skills`@backend@devops 加 SDK**#4** 本次把 M5/M4/M3 三段折叠进「已归档阶段」、顶部改短状态行(@orchestrator)。最终门禁全绿:后端 ruff/format 干净、mypy **144 文件**、alembic **无漂移**、pytest **400 passed**;前端 lint/typecheck 干净、vitest **130 passed**、build OK。台账已归档当前无活跃阶段。
- [2026-06-19] @frontend**M5 follow-up #1 ✅ Codex 展示真源(解余项①)**(消费 @backend C3 扩 Codex 读端点,无契约变更)。先 `pnpm gen:api` 纳入 `GET /projects/:id/characters``CharacterListResponse{characters?}`+ `GET .../world_entities``WorldEntityListResponse{world_entities?}`+ `lib/api/types` 加 2 别名。`lib/api/server``fetchCharacters`/`fetchWorldEntities`200 空列表非 404仿 `fetchForeshadow`codex routeServer Component`Promise.all` 拉 项目+人物+世界观全量 → 传 `CodexPage` 初始数据。`CodexPage` 人物 tab 渲染**跨会话持久化真源**(刷新不再空),本会话新入库卡经纯函数 `mergeCharacterCards`(按 name 去重、持久化优先、不重复合并补显generate→ingest 后仍即时见新行);世界观 tab 渲染入库真源(本期无世界观入库端点,故只真源 + 生成器预览,无 session 合并);时间线维持 P2 占位。纯逻辑 `lib/generation/cards.{mergeCharacterCards,mergeWorldEntities}` + 5 node-env 单测。前端门禁全绿gen:api/lint/tsc/**vitest 130**125→+5/build。**Codex「会话内回显」局限已去除**(解 gotcha + PROGRESS 余项①。memory 把 Codex 缺口 gotcha 标 RESOLVED。
- [2026-06-19] @backend**M5 follow-up R1 + R3 ✅**(三 fix与并行 @llm `llm_gateway` 改动 disjoint。**R1-Codex 读端点**(解余项①):加 `GET /projects/:id/characters``CharacterListResponse`+ `GET /projects/:id/world_entities``WorldEntityListResponse`),复用 C5 读侧 `SqlCharacterRepo`/`SqlWorldEntityRepo`(经 `get_memory_repos`,不动 assemble+ 反向 JSONB 解包(`{"items":[...]}`→list/`{"text":...}`→str/`{"rules":[...]}`→listregister mainC3 扩稳定。**R3-skill 迁包**(解余项③):`skill_registry.py`+`skill_permissions.py``ww_core.domain` **迁入** `packages/skills/ww_skills/``ww_skills/__init__` 改直接导出删门面importer`generation.py`/`project_deps.py`/`test_generation.py`)改 `from ww_skills import …`skill 单测移 `packages/skills/tests/`13 测);删 ww_core.domain 两模块+__init__ 导出;`packages/skills/pyproject.toml` deps 校准(去 `ww-core`、补 `ww-llm-gateway`+`sqlalchemy``uv sync` 重建。**R1#2-build_adapter 接线**(解余项②的 apps 侧;@llm`build_adapter` 工厂 + @devops 的 anthropic/google-genai SDK 均已落地import 实解非 transient`build_gateway_for_tier``_build_provider_adapter`(原 `_build_openai_compat_adapter`)改调 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`——Anthropic/Gemini 传 base_url=None 走原生适配器,去掉直接 `AsyncOpenAI`/`OpenAICompatAdapter` import。门禁绿隔离 `MYPY_CACHE_DIR=.mypy_cache_r1backend` 已清理ruff/format 净、mypy **101 文件 0 error**、pytest `packages/core packages/skills apps/api` **246 passed**226→+20+10 迁移 skill 测 +3 Codex 读端点测 + 其余)。两新端点入 OpenAPI验证 get 存在。memory 登记 C3 扩Codex 读端点)+ C8 实现位置更新 + build_adapter 接线 + 3 decisions。**@frontend 行动**`cd apps/web && pnpm gen:api` 纳入 `GET characters`/`GET world_entities`CodexPage 初始列表拉全量去掉「会话内」局限(解 gotcha Codex 缺口)。**@devops 行动**:复核根 `uv.lock`@backend 改了 `packages/skills/pyproject.toml` deps`uv sync` 重建 ww-skills。**M5 三非阻塞余项①②③ 至此全部清除。**
- [2026-06-19] @llm**R2 follow-up #2 ✅ 真多 provider 适配器选择**C1 扩 follow-up #2 稳定;接 @devops 已加 `anthropic`+`google-genai` SDK 之后)。① **修真 SDK 装上后的 mypy 报错**`instructor.from_anthropic(self._client)` 重载只收 SDK 具体 `AsyncAnthropic|...``AnthropicClient` Protocol 不匹配 → adapter 内 `cast("AsyncAnthropic", self._client)``TYPE_CHECKING` import零运行时硬依赖选中 AsyncInstructor 重载。gemini 走 `response_schema` 非 instructor**无**该类错。② **新增 provider→适配器工厂** `factory.build_adapter(provider, *, api_key, base_url=None) -> ProviderAdapter`(包 `__init__` 导出):`anthropic``AnthropicAdapter``{gemini,google}``GeminiAdapter`、其余→`OpenAICompatAdapter(provider, AsyncOpenAI(...))`;从 api_key 构真实客户端factory 处 `cast` 跨 Protocol read-only-attr 约束adapter 仍声明 Protocol 形参、测试替身缝完好)。新增 `tests/test_build_adapter_factory.py` 6 测(按 provider 名断分派,不联网)。**门禁绿**(隔离 cacheruff/format 净、mypy **24 源文件 0 错**、pytest **47 passed**41→+6全仓 `mypy packages apps` **144 文件 Success**anthropic.py 报错确认消除、gemini 无同类错)。无 OpenAPI 形变served_by 不出 API前端无需 re-gen。**@backend 可在 `build_gateway_for_tier``build_adapter` 替换「一律 OpenAICompatAdapter」启用 anthropic/gemini 真实回退。** 记 1 gotchaProtocol vs 真 SDK cast 边界)。
- [2026-06-19] @orchestrator**🎉 M5 完成 + 独立复核 ✅**Wave CT5.3+T5.6 ✅ + Wave DT5.7 ✅)。波次 A(T5.1‖T5.4‖T5.5)→B(T5.2)→C(T5.3+T5.6)→D(T5.7) 全绿,契约先行、零返工、无新 bug。最终独立复跑确认后端 ruff/format 干净、mypy **142 文件**、**alembic 无漂移**M5 复用 T0.2 既有 world_entities/characters/rules/skills 表全程零建表迁移、pytest **391 passed**;前端 tsc 干净、**vitest 125 passed**。**M5生成+多provider+扩展)出口 DoD 全达成**worldbuilder/character-gen 生成→预览→入库continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);网关多 provider 回退/熔断/能力降级(真 Gateway 回退链E2E 验主失败→fallback 服务 + 记账记实际 providerSkill registry + 表权限沙箱 + 规则页/技能库/命令面板(⌘K)。**M1M5 全部交付。** 非阻塞余项:① Codex 读端点缺口(无 GET characters/world_entities现以生成+会话内回显管理);② Anthropic/Gemini 真实回退待 @devops 加 SDK + 按 provider 选适配器类E2E 用 OpenAI 兼容 mock 验证回退);③ `packages/skills` 纳入 uv workspace现 impl 落 ww_core.domain + façade。建议下一步清理 PROGRESS 把 M3/M4/M5 折叠归档,或处理上述余项。
- [2026-06-19] @qa**M5 Wave DT5.7✅ M5 E2E + 切 provider**(真 pg + mock 多 provider零 token无契约变更`tests/test_m5_e2e.py` 3 用例:**① 群像生成→入库闭环**mock 网关按 `req.output_schema` 路由 CharacterGenResult/WorldGenResult/ContinuityReview`POST /world/generate` 预览 + `POST /characters/generate` 预览 3 卡 → `POST /characters` 入库**双 gate**——precheck 有冲突且未 `acknowledge_conflicts`**409 CONFLICT_UNRESOLVED**+`details.conflicts`(并断言此刻 characters 表无行,守不变量 #3)→ 带 `acknowledge_conflicts=true` 重发 201**DB 真源**断言 characters 行真落 pg + **schema→JSONB 形变**traits/speech_tics→`{"items":[...]}`、arc→`{"text":...}`、tags/relations 直落 list`rejected_tables`partition_writes 合法路径4 条 ledger。**② 切 provider 回退**HTTP 角色生成跑在**真 `Gateway`**(非 stubdeepseek(每次 `complete``TransientProviderError` 耗尽重试)→openai 回退链上,端点仍 200`served_by` 不出 API故经 **DB 真源 `usage_ledger`** 断言实际服务方 = **openai(fallback)≠deepseek(primary)**ARCH §4.5 记账记实际服务方)。**③ 能力降级**`structured_output=False` 唯一 provider 直测 `Gateway.run``served_by.degraded=True`§4.4)。坑:生成网关是**请求 scope 依赖**`get_worldbuilder_gateway`/`get_character_gen_gateway`/`get_precheck_gateway`),直接 `app.dependency_overrides` 注假网关即可,**无需** monkeypatch `build_gateway_for_tier`(与 M4 学文风后台自建网关不同。全仓门禁绿ruff/format 净、mypy **142 文件**、**alembic 无漂移**、pytest **391 passed**388→+3。**未发现 REAL bug**。**M5 出口 DoD 全达成** → orchestrator 可封 M5。
- [2026-06-19] @frontend**M5 Wave CT5.3+T5.6✅ 生成 + 设定库 + 规则 + 技能 + 命令面板**(消费 C3 扩 T5.2 + C8/T5.5,无契约变更)。先 `pnpm gen:api` 纳入 5 端点world/characters generate + characters ingest + GET rules + GET skills+ `lib/api/types` 加 12 别名。**T5.3**:① **角色生成器**UX §6.6`components/generation/CharacterGenerator`:一句话 brief + 数量(112) + 定位 → `POST /characters/generate` 预览卡 → `CharacterCardItem`(勾选入库 + 就地编辑名/弧光,默认全选)→ `POST /characters` 入库;**409 CONFLICT_UNRESOLVED 裁决流**`useCharacterGen` 捕 409 → `extractIngestConflicts` 收窄 `details.conflicts`(复用 ReviewConflict 形) → `ConflictAdjudication` 面板展示 continuity 冲突 → 作者「已知悉强制入库」= 带 `acknowledge_conflicts=true` 重发 / 「取消改卡」。② **世界观设计器** `WorldGenerator``POST /world/generate` 预览 type/name/rules本期无入库端点仅预览参考。③ **设定库 Codex**UX §6.5`codex/CodexPage` + `app/projects/[id]/codex`:人物/世界观/时间线三 tab时间线 P2 占位);`?gen=character|world` 命令面板跳转直开对应 tab。**T5.6**:④ **规则页** `rules/RulesPage` + `app/.../rules`:四级规则列表(`groupByLevel`) + 新增(`useRules` 乐观追加+回滚,仿 useForeshadow)。⑤ **技能库** `skills/SkillsPage`Server Component 纯读)+ `app/.../skills``GET /skills` 按 scope 分组只读注册表(name/tier/reads/writes)。⑥ **命令面板 ⌘K** `command/{CommandPalette,CommandPaletteMount}` + `lib/command/palette`⌘K/Ctrl+K 开关、模糊过滤(`filterCommands`)、上下循环高亮(`moveHighlight`)、Esc/Entera11y(role=dialog/listbox/option + 自动聚焦 + prefers-reduced-motion);挂 RootLayout 对全局生效,读 pathname 注入项目上下文。⑦ LeftNav `ActiveNav` 加 codex/rules/skills替换原 disabled 设定库占位。纯逻辑全单测node-env`lib/{generation/cards,rules/rules,skills/skills,command/palette}.test`。前端门禁全绿gen:api/lint/tsc/**vitest 125**(96→+29cards 12 + rules 5 + skills 3 + palette 9)/buildcodex/rules/skills 路由齐。memory 记 2 gotcha + 1 gapCodex 读端点缺口)。**M5 前端交付完成** → 下一步 **Wave DT5.7(@qa) M5 E2E + 切 provider**
- [2026-06-19] @orchestrator**M5 Wave B 收口 ✅**T5.2C3 扩稳定。5 端点入 OpenAPIworld/characters generate 预览 + characters 入库(双 gatecontinuity 预检 409 + partition_writes 白名单 + schema→DB 形变)+ GET rules/skills`build_gateway_for_tier` 接线回退链OpenAI 兼容齐Anthropic/Gemini 真实切换余量 deferred 给 T5.7 + @devops SDK。全仓门禁绿ruff/format 干净、mypy **142 文件**、**alembic 无漂移**、pytest **388 passed**371→+17。下一步 **Wave CT5.3+T5.6(@frontend 单 agent 顺序做)**——先 `pnpm gen:api`,做 角色生成器 + 世界观设计器 + 设定库 Codex + 规则页 + 技能库 + 命令面板⌘K入库流处理 409 裁决重发。
- [2026-06-19] @backend**M5 Wave BT5.2✅ 生成/入库 API**(契约 **C3 扩稳定**;消费 Wave A 的 C6扩/C8/C1扩与已合并 Wave A 无写冲突)。`routers/generation.py`+ `skills_router`,已注册 main落 5 端点:① `POST /world/generate`worldbuilder writer 网关 `run_worldbuilder`**预览**世界观,不入库,末尾 commit 落 ledger② `POST /characters/generate`character-gen writer 网关,注入已有角色防雷同 → **预览**角色卡);③ `POST /characters`(入库 **双 gate**`precheck_generated_cards` continuity 预检 analyst 网关——有冲突且未 `acknowledge_conflicts`**409 CONFLICT_UNRESOLVED**+冲突详情,作者裁决后放行;`partition_writes(character_gen_spec,...)` 白名单过滤越权写表;过 gate 写 `characters` 行,**schema list/str → DB JSONB dict 形变**);④ `GET /rules`(规则列表,复用 C5 读侧);⑤ `GET /skills`(技能库,经 registry。新写侧 repo `ww_core.domain.{character_repo(traits/speech_tics→{"items"}/arc→{"text"}),world_entity_repo(rules→{"rules"})}`(只 flush端点 commit。**T5.4 follow-up 接线完成**`build_gateway_for_tier` 据 DB `tier_routing.primary+fallback` 预备多 `OpenAICompatAdapter` + 注入 `chain_resolver=chain_from_routing(...)` 启用回退链;无路由行退回 `resolve_route`(单 providerM1 兼容无可用凭据→503。**仅 OpenAI 兼容 provider**deepseek/kimi/qwen/glm/openai——Anthropic/Gemini 真实回退需 @devops 加 SDK + provider_deps 据 provider 选适配器类(**余量 deferred 给 T5.7 切 provider**。全仓门禁绿ruff/format 净、mypy **142 文件**、**alembic 无漂移**M5 复用 T0.2 既有表、pytest **388 passed**371→+1713 生成/入库端点 + 4 网关回退接线 + 4 形变 helper。新端点入 OpenAPI验证 world/characters generate + POST characters + GET rules + GET skills 齐。memory 登记 C3扩稳定 + 2 decisions入库 gate / 形变)+ 2 gotchas。**@frontend(T5.3/T5.6) 行动**:先 `cd apps/web && pnpm gen:api` 纳入这 5 端点;入库流处理 409展示冲突→裁决勾选→带 `acknowledge_conflicts` 重发)。下一步 **Wave CT5.3+T5.6(@frontend)**(依赖 T5.2✅+T5.5✅,可起)。
- [2026-06-19] @orchestrator**M5 Wave A 收口 ✅**T5.1 + T5.4 + T5.5 三 agent 并行disjoint 目录 + 隔离 mypy cache零写冲突零返工。worldbuilder/character-gen 双 spec + 节点 + 入库前 continuity 校验缝C6扩‖ 网关多 provider 韧性Anthropic/Gemini 适配器 + 回退链 + 熔断 + 能力降级C1扩无 OpenAPI 形变)‖ Skill registry + 表权限白名单filter_reads/partition_writes+ `POST /rules`C8 + C3扩。全仓门禁绿ruff/format 干净、mypy **135 文件**、**alembic 无漂移**M5 复用 T0.2 已建的 world_entities/characters/rules/skills 表无建表迁移、pytest **371 passed**296→+75。**Wave B 衔接待办**T5.2 @backend 须处理):① 角色 schema `traits`/`speech_tics`(list)/`arc`(str) ↔ DB JSONB **dict** 形变,入库须转换;② 入库 gate 调 `precheck_generated_cards`(continuity 冲突拦截) + `partition_writes`(越权写丢弃审计);③ 补 `GET /rules` + `GET /skills` 供 T5.6 前端;④ 接线 `build_gateway_for_tier``chain_from_routing` 启用回退链T5.7 切 provider 依赖)。**@devops 待办**:加 `anthropic`/`google-genai` SDK 依赖(适配器懒 import单测不需评估把 `packages/skills` 纳入 uv workspace现 impl 落 ww_core.domain + ww_skills façade。下一步 **Wave BT5.2(@backend 生成/入库 API)**
- [2026-06-19] @llm**M5 Wave AT5.4✅ 网关多 provider 韧性**(契约 **C1 扩稳定**,仅扩 `packages/llm_gateway/`,与并行 T5.1 @llm 无文件冲突)。① **新适配器**`adapters/anthropic.py`(`AnthropicAdapter`:原生结构化经 instructor.from_anthropic 懒构建、`cache_control` 缓存断点、thinking 能力)+`adapters/gemini.py`(`GeminiAdapter``response_mime_type=json+response_schema` 结构化、thinking)——**均经注入的客户端 Protocol不硬 import anthropic/google-genai SDK单测零联网零依赖**。② **回退链 + 重试 + 熔断**`gateway.py``Gateway``chain_resolver`/`max_retries`/`breaker`(保留 `resolver=` M1 兼容→自动包成单元素链);沿链逐 provider→瞬时失败(tenacity 指数退避)重试 R 次→仍失败/熔断打开/无适配器→切下一个;首个成功者服务、非链首标 `served_by.fell_back`;链耗尽抛 `LLM_UNAVAILABLE`;记账记**实际服务方**(回退后不记主模型);流式仅首块前可切(产出后中途失败上抛不重连)。`CircuitBreaker(threshold/reset_seconds/clock)` 连续失败超阈值短时熔断、成功清零、冷却半开。③ **能力协商/降级**:结构化请求按 `capabilities().structured_output` 重排链优先选支持者;链上无支持者→降级首个可用(适配器自走 instructor JSON 提示)标 `served_by.degraded`。④ **瞬时错误契约**`errors.py``TransientProviderError`(三适配器按异常类名+status_code 包装)+网关视 `AppError(RATE_LIMITED)` 可重试。⑤ **routing.py**`resolve_chain`/`chain_from_routing`(据 DB tier_routing 去重保序)/`ChainResolver` 别名导出。⑥ `ServedBy``degraded`(向后兼容,默认 False)`pyproject.toml``tenacity>=8.2`。**23 新测**(test_fallback_chain 10/test_capability_degradation 4/test_new_adapters 7/test_multi_provider_accounting 2),包内 **41 passed**全仓门禁绿ruff/format 净、mypy **135 文件**、core+api **226 passed**(无回归)。**无 OpenAPI 形变**(served_by 不出 API前端无需 re-gen)。memory 登记 C1 扩 + 2 gotcha。**后续待办**apps/api `build_gateway_for_tier` 接线回退链(预备多适配器+传 chain_resolver=chain_from_routing 包装)、@devops 加 anthropic/google-genai SDK 依赖供真实接线。下一步 Wave B(T5.2 @backend,依赖 T5.1)。
- [2026-06-19] @backend**M5 Wave AT5.5✅ Skill 运行时 + 规则**(契约 **C8 + C3 扩稳定**;与并行 T5.1/T5.4 @llm 无目录/写冲突,隔离 `MYPY_CACHE_DIR=.mypy_cache_t55`)。① **Skill registry loader**`packages/core/ww_core/domain/skill_registry.py``SkillRecord`(frozen,skills 行声明快照) + `SkillRepo` Protocol + `SqlSkillRepo`(读 `skills` 表,裸 tier 非法→VALIDATION + `SkillRegistry.load(repo)`async每条跑 `validate_declaration`越权声明→AppError VALIDATION 加载中断frozen `dict[name,AgentSpec]`+ `get`/`names`/`list_scope`。复用 §5.1 网关机制(不重实现网关),不变量#2 只带 tier。② **表权限沙箱**`skill_permissions.py`,纯函数/不可变§5.6「声明式权限+apply 层白名单」非进程沙箱):`KNOWN_TABLES`(10 创作表白名单) + `filter_reads`(注入只喂声明 reads 表) + `partition_writes`(写库只应用声明 writes越权→rejected 供审计 log+丢弃) + `validate_declaration`(reads/writes 越界→VALIDATION)。守不变量#3allowed 仍过验收 gate不开写后门。③ **`POST /projects/:id/rules`**`routers/rules.py`,已注册 main`RuleCreateRequest{level:Literal[global/genre/style/project],content(min1)}`→**201** `RuleView{level,content}`,非法 level/空 content→FastAPI 422写侧 `domain/rule_repo.py`(`RuleWriteRepo`/`SqlRuleWriteRepo`/`RuleWriteView`,只 flush,端点 commit);喂 assemble 四级 `merge_rules`。④ 注入缝 `get_rule_write_repo`/`get_skill_registry``project_deps`。门禁绿限本任务范围ruff/format 净、`MYPY_CACHE_DIR=.mypy_cache_t55 mypy packages/skills packages/core apps/api` **0 error/94 文件**、pytest `packages/skills packages/core apps/api` **226 passed**+18 新测6 permissions + 4 registry + 2 rule_repo + 3 rules 端点 + 既有)。`POST /rules` 已入 OpenAPI验证。**实现位置决策**registry/沙箱落 `ww_core.domain`§5.6 强制点在编排/写库层 + `packages/skills` 当前非 uv workspace member根 pyproject @devops 未接线);`ww_skills` 留再导出门面,待 @devops 纳入 workspace 可迁移(见 decisions/gotchas。**@frontend(T5.6) 行动**:先 `cd apps/web && pnpm gen:api` 纳入 `POST /rules`(规则页);技能库经 `get_skill_registry`(若 T5.6 需 GET /skills 端点,请在此提请 @backend 补)。**@llm/T5.2 行动**:生成入库层用 `partition_writes` 守自定义 skill 产出白名单(仅声明 writes 表),仍过验收。
- [2026-06-19] @llm**M5 Wave A 半程T5.1✅**C6 扩稳定,与并行 T5.4 @llm/T5.5 @backend 无目录/写冲突,隔离 MYPY_CACHE_DIR。worldbuilder + character-gen 落地:① schema`packages/agents/ww_agents/schemas.py``WorldEntityCard{type,name,rules:list[str]}`/`WorldGenResult{entities}` + `CharacterRelation{name,kind,note?}`/`CharacterCard{name,role,traits,backstory,arc,speech_tics,tags,relations}`/`CharacterGenResult{cards}`——**字段对齐 `world_entities`/`characters` DB 列**供 T5.2 ingest 直映。② specspecs.py`worldbuilder_spec`(writer,reads=projects,writes=world_entities,硬规则显式 prompt)、`character_gen_spec`(writer,reads=world_entities+characters,writes=characters,**system_prompt 注入「已生成卡+已有角色」差异化要求=M5-a 防雷同**)。③ 生成节点(`packages/core/ww_core/orchestrator/generation_node.py`,模块内自有 GatewayRun Protocol不重复导出`run_worldbuilder`/`run_character_gen``build_character_gen_context` 把已有+已生成卡序列化成 `name(role)traits` 简表注入空时占位不崩独立生成、网关失败上抛、parsed 违约 ValueError只读不写库。④ **入库前 continuity 校验缝**ARCH §6.5`precheck_generated_cards(spec=continuity_spec,*,cards,world_context,characters_context,...)->list[Conflict]`——编排器追加的检查、**非 character-gen 直接互调**(守 §5.4/不变量#1T5.2 入库端点调它做 gate。门禁绿限本任务文件ruff/format 净、mypy 本任务 7 文件 0 error`packages/agents packages/core` 余 3 error 全在并行 @backend T5.5 的 rule_repo/skill 测试、pytest **246 passed**(+34 新测22 spec 契约 + 12 节点/防雷同/校验缝)。memory 登记 **C6 扩稳定**。**T5.2(@backend) 行动**`POST /world/generate`→run_worldbuilder、`/characters/generate`→run_character_gen(批量循环把已产卡传 generated_so_far)、`POST /characters` 入库前调 precheck_generated_cards gateanalyst/writer 网关复用 build_gateway_for_tier。下一步 **Wave BT5.2**(依赖本任务✅,可起)。
- [2026-06-19] @docs — spec 一致性回写:把 T4.3 顺带新增的 `GET /projects/:id/style`(最新指纹 `{dimensions, evidence, version}`,无指纹 404补进 ARCHITECTURE.md §7.2 端点表(紧邻 `POST /style` 行)+ PRODUCT_SPEC.md §6 端点清单(紧邻 `POST /style` 行),消除 code↔spec 漂移。`memory/decisions.md` 该决策的「待回写规格」flag 至此**已解决**。仅改两份 spec未动代码/测试。
- [2026-06-19] @orchestrator**M4 收官独立复核 ✅**。波次按计划走通 A(T4.2‖T4.1)→B(T4.3)→C(T4.4)→D(T4.5),每波契约先行 + 全仓门禁收口、零返工、无新 bug。最终独立复跑后端门禁确认绿ruff/format 干净、mypy **115 文件**、**alembic 无漂移**M4 全程零建表迁移,全靠 T0.2 预留的 `style_fingerprint`/`chapter_reviews.style`/`jobs` 列表、pytest **296 passed**;前端 gen:api/lint/tsc/vitest **96**/build 绿。**M4文风出口 DoD 全达成**:学文风(16 维指纹带证据,版本化,异步 jobs+zombie reaper) + 漂移打分作为第四审真持久化(review 四并行分支+SSE `style`+`chapter_reviews.style`) + 回炉同步 diff(不写库守不变量#3)。Phase 4 完成M1M4 全部交付。下一阶段 M5(生成+多 provider+Skill) 见 DEV_PLAN进入时归档 M3/M4 并拉入新表。
- [2026-06-19] @qa**🎉 M4 完成**Wave DT4.5✅ M4 E2E`tests/test_m4_e2e.py` 真 pg + 多档位 mock 网关(零 token真跑通过 3 用例:① **学文风闭环**`POST /style`→**202** `{job_id}`→BackgroundTask `run_job` 自建独立 session 跑 `run_style_extraction`(analyst 假适配器产 StyleFingerprintResult)→`SqlStyleFingerprintWriteRepo.append` 真落 pg→`GET /jobs/{id}` status=done(result.version/dims_count)→`GET /style` 最新指纹→`mode="update"` 学一次 version+1DB 真源 `style_fingerprint` 2 行(version 1/2,dimensions/evidence)+ `jobs` 2 行 done。② **写章第四审**:学文风后 assemble stable_core 含指纹→`POST .../review` SSE 四审齐(continuity/foreshadow/pace/style)无 error + `section{name:"style"}` + `style{score:72,segments:[{idx:2,score:40,label:"机翻腔"}]}` 帧真发 + `GET .../reviews` 留痕 style(dict)DB 真源 `chapter_reviews.style` 列真填。③ **回炉**`POST .../refine`→200 `{original,refined}`,refined≠original(writer 据 output_schema is None 返改写串)+ 不写库(无 chapter/review 行,不变量#3)+ writer 记账落 usage_ledger。**无新 bug**。关键坑(记 memory/gotchas):学文风 BackgroundTask 在独立 session 上**自造网关**(`build_gateway_for_tier` 从凭据建真 OpenAI 适配器)→E2E 无真凭据,须 monkeypatch `style.build_gateway_for_tier`→真 Gateway 包假适配器(ledger 绑入参 session);写侧 repo + `run_job` 默认 `SqlJobRepo` 保持真→指纹/job 真落 pg;override `get_session_factory``e2e_sm`(同测试 engine/loop)。最终全收口门禁绿ruff/format 干净、mypy **115 文件**、pytest **296 passed**(293→+3)、**alembic 无漂移**(M4 无建表迁移)。**M4 出口 DoD 全达成 → Phase 4 可归档。**
- [2026-06-19] @frontend**M4 Wave CT4.4✅ 文风页 + 漂移 + 回炉 + job 轮询**(消费 C3 扩 + C4 扩,无契约变更)。先 `pnpm gen:api` 纳入 style/refine/GET-style + jobs 类型(`lib/api/types` 加 4 别名)。① **job 轮询**:新模式 `lib/jobs/job.ts`(纯状态机 `narrowJob`/`reducePoll`/`isTerminal`/`styleLearnResult`——`GET /jobs/{id}` 是松散 `{[k]:unknown}` 裸端点须安全收窄) + `useJobPoll.ts`(setTimeout 轮询done/failed 终态停,卸载清理)。② **文风页** `app/projects/[id]/style/page.tsx`(RSC 取 project + `GET /style`→404 容忍返 null) + `components/style/``StyleUpload`(多段文本粘贴/选文件 `file.text()` 读成文本→空行切段→`POST /style`)、`useStyleLearn`(受理 202→`useJobPoll`→done 拉指纹)、`FingerprintView`(16 维行+可展开证据)。③ **审稿页第四审**`lib/review/sse.ts``StyleEvent`/`StyleDriftReport`/`reduceReview` case "style"(替换式仿 pace)/`ReviewStreamState.style`/seed`history`-style 收窄 `normalizeStyleDrift``lib/style/style.ts``StylePanel`(◔ 整体相似度% + 漂移段一键回炉)接进 ReviewReport(seed 扩四态)。④ **回炉** `RefineView` + `useRefine`(`POST /refine`→新旧 diff→采纳把 refined 替换 finalText 中原段,经既有 draft 路径合入,不另写库守不变量#3503 优雅提示)。LeftNav 开「文风」入口(`ActiveNav` 加 "style")。前端门禁全绿gen:api/lint/tsc/**vitest 96**(+27job 12 + style 10 + style-sse 5)/build(含 `/projects/[id]/style` 路由)。memory 记 1 gotcha。下一步 **Wave DT4.5(@qa) M4 E2E**
- [2026-06-19] @orchestrator**M4 Wave B 完成 ✅**T4.3C3 扩稳定。style/refine 三端点入 OpenAPI`POST /style`(202+jobs BackgroundTask 跑 run_style_extraction→StyleFingerprintWriteRepo.append)、`GET /style`(最新指纹含证据/版本)、`POST /refine`(writer 网关同步返 {original,refined} 不写库)。全仓门禁绿ruff/format 干净、mypy **115 文件**、**alembic 无漂移**、pytest **293 passed**(+13)。`GET /style` 顺带补入(回写 ARCH §7.2/PRODUCT_SPEC §6 留 @docs)。下一步 **Wave CT4.4(@frontend)**——先 `pnpm gen:api` 纳入 style/refine/GET-style + jobs 类型,再做文风页(上传/指纹/证据) + `lib/jobs/useJobPoll` + 审稿页第四审 StylePanel + 回炉 diff。
- [2026-06-19] @backend**M4 Wave BT4.3✅ API style + refine**(契约 **C3 扩稳定**)。三端点入 OpenAPI独立 `routers/style.py`,仿 outline.py已注册`POST /projects/:id/style`**202** `{job_id}`(学文风走 jobsM4-c`job_repo.create("style_learn")`+commit → `background_tasks.add_task(run_job, session_factory, job.id, work)``work``run_job` **自建独立 session** 上自造 analyst 网关+写侧 repo → `run_style_extraction` → 拆 `dimensions_json`/`evidence_json``append`(version+1) → `{version,dims_count}``jobs.result`;无凭据 → dep 解析阶段 **503**(调度 job 前拦下,不凭空写注定失败的 job。② `POST /projects/:id/chapters/:no/refine` → 同步 200 `{original,refined}`writer 网关 `run(refiner_spec)``output_schema=None``resp.text`;不写库守不变量#3;末尾 commit 让 usage_ledger 落库)。③ **顺带补 `GET /projects/:id/style`** → 最新指纹 `{dimensions,evidence,version}`UX §6.9ARCH §7.2 原缺读端点——决策见 decisions**待 @docs 回写 ARCH §7.2/PRODUCT_SPEC §6**);不动 C5 读侧 `SqlStyleRepo`assemble 行为不变)。新建写侧 `domain/style_repo.py`(`StyleFingerprintWriteRepo`/`Sql*``append` 只 flush + `latest` 含证据/版本)`project_deps``get_style_write_repo`/`get_style_extract_gateway`(analyst)/`get_refine_gateway`(writer)。全仓门禁绿ruff/format 干净、mypy **115 文件**、**alembic 无漂移**(M4 无建表)、pytest **293 passed**(280→+13)。memory 登记 C3 扩 + 2 decisions + 1 gotcha。**@frontend 行动**T4.4 前 `cd apps/web && pnpm gen:api` 纳入 style/refine/GET style + jobs 轮询类型。下一步 **Wave CT4.4(@frontend)**
- [2026-06-19] @orchestrator**M4 Wave A 收口 ✅**T4.2 + T4.1 全绿。全仓门禁收口ruff/format 干净、mypy **111 文件**、**alembic 无漂移**M4 无建表迁移、pytest **280 passed**M3 基线 228 → +52T4.2 37 + T4.1 15。契约稳定C6扩/C4扩StyleFingerprintResult/StyleDriftReview/四审齐/SSE style 事件、C3.5JobRepo/run_job。下一步 **Wave BT4.3(@backend)**——单 @backend agent 顺序做 style/refine 两端点:新建 `StyleFingerprintWriteRepo` + `routers/style.py`(`POST /style` 202 调 `job_repo.create`+`run_job`(work=`run_style_extraction``StyleFingerprintWriteRepo.append`)`POST /refine` 同步 `refiner_spec` writer 网关返 {original,refined})C3 扩;评估顺带 `GET /projects/:id/style`
- [2026-06-19] @backend**M4 Wave AT4.1✅ jobs 长任务基建**(契约 **C3.5 稳定**,不碰 style 业务,与并行 T4.2 @llm 无目录/写冲突)。`packages/core/ww_core/domain/job_repo.py``JobRepo` Protocol + `SqlJobRepo`(create/set_running/set_progress 夹取/complete/fail/get/reap_zombies) + frozen `JobView`——状态写**只 flush**(提交归 `run_job`/端点),唯 `reap_zombies` 启动期独立调用故**自 commit**。`apps/api/ww_api/services/job_runner.py`:通用 `run_job(session_factory, job_id, work)` **自建独立 session**(仿 `run_overdue_scan`)→set_running→work→complete/异常→新 session fail异常吞不冒泡最小 `JobLifecycleRepo` Protocol + 可注入 `repo_factory` 缝。`get_job_repo` 加进 `project_deps``main.py` lifespan 在 `seed_stub_user` 后跑 reaper(M4-d幂等自 commit)。门禁绿(限本任务文件)ruff/format 干净、mypy 本任务文件 0 error(`packages/core apps/api` 余 7 error 全在 @llm 在做的 `test_style_*`)、pytest **15 新测**(13 job_repo+2 job_runner)`packages/core` **130 passed** / `apps/api` **50 passed**。memory 登记 C3.5。**T4.3 行动**`POST /style``job_repo.create` + `background_tasks.add_task(run_job, session_factory, job.id, work=…)`
- [2026-06-19] @llm**M4 Wave A 半程T4.2✅**C6 扩 + C4 扩稳定。style-auditor 双轨落地:①提取轨 `style_extract_spec`(analyst,独立生成仿 outliner,**不进 review 图**)+`StyleFingerprintResult{dimensions:[StyleDimension{name,value,evidence}]}`(16 维带证据)+`orchestrator/style_extract_node.py`(`build_style_extract_request`/`run_style_extraction`,模块内自有 GatewayRun Protocol,网关失败上抛/parsed 违约抛 ValueError,只读不写库);②漂移轨第四审 `style_drift_spec`(name="style",light,只读)+`StyleDriftReview{score=100,segments:[StyleDriftSegment{idx,score,label?}]}`(默认值即无指纹降级态)→`REVIEW_SPECS` 四审齐collect 加 `STYLE`/`extract_style`→record `style=`sse 加 `EVENT_STYLE`/`style_event`+`_section_result_events` STYLE 分支(`style{score,segments}`,字典序 continuity<foreshadow<pace<style 天然纳入);③回炉 `refiner_spec`(writer,output_schema=None 纯文本,非持久)。门禁绿ruff/format mypy **47 文件**(隔离 cache 避与 @backend T4.1 竞争)、packages/agents+core pytest **197 passed**(+37 新测双轨 spec/schema 契约 + 四审图跑通 len(calls)==4 + collect style + SSE style 事件 + 无指纹降级落库)。memory 登记 C6扩/C4扩。**T4.1(@backend) 已并行完成**→ Wave BT4.3(@backend style/refine 端点) 可起调本任务 `run_style_extraction`/`refiner_spec`
- [2026-06-19] @orchestrator **进入 M4文风**归档心智上 M3Phase 4 五任务拉入台账探查确认免重复建表`style_fingerprint` /`chapter_reviews.style` /`jobs` +`GET /jobs/:id`/读侧 `SqlStyleRepo.latest` 均已在前期建齐 **M4 无新建表迁移**`assemble` 已把指纹纳入 `stable_core` 第四审天然在 review_context 拿指纹对照无需额外注入已定决策style-auditor 双轨拆 `style_extract_spec`(analyst,独立生成,仿 outliner)+`style_drift_spec`(light,第四审并入 `REVIEW_SPECS`)学文风走 jobs+BackgroundTask(独立 session,仿 run_overdue_scan)+启动 zombie reaper回炉 refine 同步返回 {original,refined} 不写库守不变量#3)。启动 **Wave AT4.2(@llm·agents+orchestrator) ‖ T4.1(@backend·jobs 基建)**不同目录,隔离 MYPY_CACHE_DIR)。
- [2026-06-18] @orchestrator **🎉 M3 完成**T3.8 修复 + @qa 解钉 xfail)。T3.8 `SqlAlchemyLedgerSink.record` add-only去并行路径里唯一的 `await flush()`靠端点/事务 commit 持久化并发回归测试 RED 已验旧实现抛 `Session is already flushing`@qa 移除 T3.7 case3 strict-xfail收紧为严格正向断言三审 section + foreshadow/pace 真发事件 + `chapter_reviews.foreshadow_sug`/`pace` 列真填 + `beat_map` 精确断言)。最终全收口门禁绿ruff/format 干净mypy **111 文件**pytest **228 passed**(0 xfailed/0 failed)、**alembic 无漂移**前端 lint/tsc/vitest 69 绿。**M3 出口 DoD 全达成。** 下一阶段 M4文风)。 - [2026-06-18] @orchestrator **🎉 M3 完成**T3.8 修复 + @qa 解钉 xfail)。T3.8 `SqlAlchemyLedgerSink.record` add-only去并行路径里唯一的 `await flush()`靠端点/事务 commit 持久化并发回归测试 RED 已验旧实现抛 `Session is already flushing`@qa 移除 T3.7 case3 strict-xfail收紧为严格正向断言三审 section + foreshadow/pace 真发事件 + `chapter_reviews.foreshadow_sug`/`pace` 列真填 + `beat_map` 精确断言)。最终全收口门禁绿ruff/format 干净mypy **111 文件**pytest **228 passed**(0 xfailed/0 failed)、**alembic 无漂移**前端 lint/tsc/vitest 69 绿。**M3 出口 DoD 全达成。** 下一阶段 M4文风)。
- [2026-06-18] @orchestrator **M3 Wave ET3.7✅ E2E 交付 + 暴露并发 bug**`tests/test_m3_e2e.py` pg 跑通:① 伏笔 埋设进展验收后 BackgroundTask 扫描置 OVERDUE看板过滤(DB 真源)✓ 大纲 `POST .../outline`outline 表行+窗口+幂等 upsert+记账✓。③ 三审 SSE 用例 **strict-xfail 钉住真 bug**三审同 LangGraph superstep 并行 + 共用请求 session `SqlAlchemyLedgerSink.record`(add+`await flush`)→`AsyncSession` 非并发安全第二/三审 flush Session is already flushing」→ `run_review` 失败隔离静默吞成 `incomplete`→**foreshadow/pace 静默丢失**(SSE 无事件`chapter_reviews` 列空)。M2 单审未触发基线 **222 passed + 1 xfailed** **T3.8(@llm)** 修复 修后 @qa 解钉 xfail 才算 M3 达成 - [2026-06-18] @orchestrator **M3 Wave ET3.7✅ E2E 交付 + 暴露并发 bug**`tests/test_m3_e2e.py` pg 跑通:① 伏笔 埋设进展验收后 BackgroundTask 扫描置 OVERDUE看板过滤(DB 真源)✓ 大纲 `POST .../outline`outline 表行+窗口+幂等 upsert+记账✓。③ 三审 SSE 用例 **strict-xfail 钉住真 bug**三审同 LangGraph superstep 并行 + 共用请求 session `SqlAlchemyLedgerSink.record`(add+`await flush`)→`AsyncSession` 非并发安全第二/三审 flush Session is already flushing」→ `run_review` 失败隔离静默吞成 `incomplete`→**foreshadow/pace 静默丢失**(SSE 无事件`chapter_reviews` 列空)。M2 单审未触发基线 **222 passed + 1 xfailed** **T3.8(@llm)** 修复 修后 @qa 解钉 xfail 才算 M3 达成
- [2026-06-18] @orchestrator **M3 Wave D 完成**T3.6✅)。前端三块落地伏笔四泳道看板`/foreshadow`OVERDUE 琥珀 + 登记/转移乐观更新)、大纲编辑器`/outline`逐章 beats + 伏笔窗口徽标 + 接近回收提示)、审稿页扩消费 `foreshadow`/`pace` SSE伏笔建议区 + 节奏节拍图 ▁▃▅,三审齐显文风第四审留 M4)。`pnpm gen:api` 纳入 M3 端点看板/登记/转移/大纲松散 JSONB 字段安全收窄不手写前端门禁绿gen:api/lint/tsc/**vitest 69(+24)**/buildmemory +1 decision +2 gotchas下一步 **Wave ET3.7 M3 E2E**(@qa DB + mock 网关零 token)。 - [2026-06-18] @orchestrator **M3 Wave D 完成**T3.6✅)。前端三块落地伏笔四泳道看板`/foreshadow`OVERDUE 琥珀 + 登记/转移乐观更新)、大纲编辑器`/outline`逐章 beats + 伏笔窗口徽标 + 接近回收提示)、审稿页扩消费 `foreshadow`/`pace` SSE伏笔建议区 + 节奏节拍图 ▁▃▅,三审齐显文风第四审留 M4)。`pnpm gen:api` 纳入 M3 端点看板/登记/转移/大纲松散 JSONB 字段安全收窄不手写前端门禁绿gen:api/lint/tsc/**vitest 69(+24)**/buildmemory +1 decision +2 gotchas下一步 **Wave ET3.7 M3 E2E**(@qa DB + mock 网关零 token)。
@@ -86,3 +152,5 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
- [2026-06-18] @devops/@db/@backend/@frontend **Phase 0 全部完成**monorepo(uv workspace + pnpm)、16 MVP 表迁移(无漂移)、FastAPI(structlog+request_id+错误信封+/jobs/:id)、Next.js(纸感 token + OpenAPITS gen:api)、CI门禁全绿API 实跑 200下一步 M1/T1.1(网关接口)。 - [2026-06-18] @devops/@db/@backend/@frontend **Phase 0 全部完成**monorepo(uv workspace + pnpm)、16 MVP 表迁移(无漂移)、FastAPI(structlog+request_id+错误信封+/jobs/:id)、Next.js(纸感 token + OpenAPITS gen:api)、CI门禁全绿API 实跑 200下一步 M1/T1.1(网关接口)。
- [2026-06-18] @docs DEV_PLAN 与规格对齐修订T0.3 加可观测性底座(structlog+request_id)、T1.1 `usage_ledger` 记账(从第一天)、T0.2/T2.3 统一建表口径( MVP 表一次建齐, T2.3 Repository)、T4.2 把文风漂移并入第四审新增 `PUT .../draft` 自动保存端点并回写 ARCH §7.2 + contracts C3 - [2026-06-18] @docs DEV_PLAN 与规格对齐修订T0.3 加可观测性底座(structlog+request_id)、T1.1 `usage_ledger` 记账(从第一天)、T0.2/T2.3 统一建表口径( MVP 表一次建齐, T2.3 Repository)、T4.2 把文风漂移并入第四审新增 `PUT .../draft` 自动保存端点并回写 ARCH §7.2 + contracts C3
- [2026-06-17] @docs 四份规格 + CLAUDE.md + 协同系统PROGRESS/memory就绪可进 Phase 0 / T0.1 - [2026-06-17] @docs 四份规格 + CLAUDE.md + 协同系统PROGRESS/memory就绪可进 Phase 0 / T0.1
- [2026-06-19] @llm **K1.2 修头**Kimi Code 适配器 `kimi_code_headers()` 改为**** `{"User-Agent": "KimiCLI/1.5"}`删全部 X-Msh-* + `kimi_device_id`/`KIMI_DEVICE_ID`/确定性 device-id 机制K1.5 联网实测有效 token X-Msh-* 疑似触发 `401 Invalid Authentication`对齐 pi-kimi-coder = UA-only。改 `adapters/kimi_code.py` + `__init__` 导出 + 两测adapter 6 /factory 3 default_headers UA X-Msh-*)。 contracts C1扩头集 UA-only+ gotcha门禁绿ruff/format/mypy/pytest56 passed)。
- [2026-06-19] @backend **bugfix 空写章 prompt**`assemble`C5从不含项目 premise/logline/theme/title 且无写章指令 全新项目 premise产空 `stable_core`+`volatile`写章触发 LLM `400 message must not be empty`修复`MemoryRepos` 加第 8 repo `project:ProjectSpecRepo``SqlProjectSpecRepo` project_id `projects``assemble` 作品蓝本放入 `stable_core` 首段书级 spec缓存前缀 #9+ `volatile` 始终含请创作第 N 章的正文。」。 `domain/repositories.py`(+ProjectSpecView/Repo) · `memory/sql_repositories.py`(+SqlProjectSpecRepo, sql_memory_repos 装配) · `memory/assemble.py`(_build_spec_section + 注入)测试 `test_memory.py`(+bare-project/chapter-directive 两测+fake) · `test_projects.py`/`test_generation.py`( project= fake)。无 DDL/迁移只读既有列)。门禁全绿ruff/format/mypy(154 文件)/alembic check(无漂移)/pytest **438 passed** contracts C5 + gotcha消费方注意手搓 `MemoryRepos(...)` 须补 `project=`

View File

@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView from ww_core.domain import ForeshadowLedgerView, OutlineWriteView, StyleFingerprintView
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
from ww_core.domain.foreshadow_state import ( from ww_core.domain.foreshadow_state import (
ForeshadowStatus, ForeshadowStatus,
@@ -21,8 +21,16 @@ from ww_core.domain.foreshadow_state import (
from ww_core.domain.foreshadow_state import ( from ww_core.domain.foreshadow_state import (
transition as apply_transition, transition as apply_transition,
) )
from ww_core.domain.job_repo import (
PROGRESS_COMPLETE,
STATUS_DONE,
STATUS_FAILED,
STATUS_QUEUED,
STATUS_RUNNING,
JobView,
)
from ww_core.domain.project_repo import ProjectCreate, ProjectView from ww_core.domain.project_repo import ProjectCreate, ProjectView
from ww_core.domain.repositories import DigestView from ww_core.domain.repositories import DigestView, OutlineView
from ww_core.domain.review_repo import ReviewView from ww_core.domain.review_repo import ReviewView
from ww_llm_gateway.types import Delta, LlmRequest, LlmResponse, ServedBy, Usage from ww_llm_gateway.types import Delta, LlmRequest, LlmResponse, ServedBy, Usage
@@ -312,6 +320,40 @@ class FakeOutlineWriteRepo:
) )
class FakeOutlineReadRepo:
"""实现读侧 `OutlineRepo` Protocol内存DB beats 形 `{"beats": [...]}`)。
`add_chapter` 以裸 `list[str]` 便利入种,内部包成 dict与 DB JSONB round-trip 一致)。
`list_for_project` 按 chapter_no 升序返回(仿 SqlOutlineRepo 的 order_by
"""
def __init__(self) -> None:
self.rows: dict[tuple[uuid.UUID, int], OutlineView] = {}
def add_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]] | None = None,
) -> None:
self.rows[(project_id, chapter_no)] = OutlineView(
volume=volume,
chapter_no=chapter_no,
beats={"beats": list(beats)},
foreshadow_windows=[dict(w) for w in (foreshadow_windows or [])],
)
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]
return sorted(views, key=lambda v: v.chapter_no)
class FakeSessionFactory: class FakeSessionFactory:
"""到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。 """到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。
@@ -332,20 +374,106 @@ class FakeSessionFactory:
return _cm() return _cm()
class FakeStyleWriteRepo:
"""实现 `StyleFingerprintWriteRepo` Protocol内存版本化 append + 最新读取)。
`append` 模拟 version=当前 max+1首次 1`latest` 取最大 version 行。
供 `GET /style` 读侧 + 学文风 workmode=update → 第二次 append version+1断言。
"""
def __init__(self) -> None:
# version -> (dimensions, evidence)
self.rows: dict[int, tuple[dict[str, Any], dict[str, Any]]] = {}
self.append_calls = 0
async def append(
self,
project_id: uuid.UUID,
*,
dimensions_json: dict[str, Any],
evidence_json: dict[str, Any],
) -> int:
self.append_calls += 1
next_version = (max(self.rows) if self.rows else 0) + 1
self.rows[next_version] = (dict(dimensions_json), dict(evidence_json))
return next_version
async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None:
if not self.rows:
return None
version = max(self.rows)
dimensions, evidence = self.rows[version]
return StyleFingerprintView(dimensions=dimensions, evidence=evidence, version=version)
class FakeJobRepo:
"""实现 `JobRepo` Protocol内存create/get/状态流转,供 `POST /style` 断言。"""
def __init__(self) -> None:
self.rows: dict[uuid.UUID, JobView] = {}
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
view = JobView(id=uuid.uuid4(), kind=kind, status=STATUS_QUEUED, progress=0)
self.rows[view.id] = view
return view
async def set_running(self, job_id: uuid.UUID) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_RUNNING})
self.rows[job_id] = view
return view
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
view = self.rows[job_id].model_copy(update={"progress": max(0, min(100, pct))})
self.rows[job_id] = view
return view
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
view = self.rows[job_id].model_copy(
update={"status": STATUS_DONE, "progress": PROGRESS_COMPLETE, "result": dict(result)}
)
self.rows[job_id] = view
return view
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error})
self.rows[job_id] = view
return view
async def get(self, job_id: uuid.UUID) -> JobView | None:
return self.rows.get(job_id)
async def reap_zombies(self) -> int:
return 0
class FakeReviewGateway: class FakeReviewGateway:
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。""" """实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""
def __init__(self, parsed: BaseModel | None = None, error: Exception | None = None) -> None: def __init__(
self,
parsed: BaseModel | None = None,
error: Exception | None = None,
*,
text: str | None = None,
) -> None:
self._parsed = parsed self._parsed = parsed
self._error = error self._error = error
# `text` 覆盖纯文本产出refiner output_schema=None → 端点消费 resp.text
self._text = text
self.requests: list[LlmRequest] = [] self.requests: list[LlmRequest] = []
async def run(self, req: LlmRequest) -> LlmResponse: async def run(self, req: LlmRequest) -> LlmResponse:
self.requests.append(req) self.requests.append(req)
if self._error is not None: if self._error is not None:
raise self._error raise self._error
if self._text is not None:
out_text = self._text
elif self._parsed is not None:
out_text = self._parsed.model_dump_json()
else:
out_text = "{}"
return LlmResponse( return LlmResponse(
text=self._parsed.model_dump_json() if self._parsed else "{}", text=out_text,
parsed=self._parsed, parsed=self._parsed,
usage=Usage( usage=Usage(
provider="fake", provider="fake",

View File

@@ -15,21 +15,34 @@ class FakeCredentialStore:
"""实现 `CredentialStore` Protocol 的内存版。""" """实现 `CredentialStore` Protocol 的内存版。"""
def __init__(self) -> None: def __init__(self) -> None:
# key: (owner_id, provider) → 密文 # key: (owner_id, provider) → api_key 密文
self.creds: dict[tuple[uuid.UUID, str], bytes] = {} self.creds: dict[tuple[uuid.UUID, str], bytes] = {}
# key: (owner_id, provider) → oauth 密文OAuth 凭据auth_type="oauth"
self.oauth: dict[tuple[uuid.UUID, str], bytes] = {}
self.routing: dict[str, StoredRouting] = {} self.routing: dict[str, StoredRouting] = {}
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]: async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]:
return [ rows = [
StoredCredential(provider=p, api_key_enc=blob) StoredCredential(provider=p, api_key_enc=blob)
for (o, p), blob in self.creds.items() for (o, p), blob in self.creds.items()
if o == owner_id if o == owner_id
] ]
rows += [
StoredCredential(provider=p, api_key_enc=None, auth_type="oauth", oauth_enc=blob)
for (o, p), blob in self.oauth.items()
if o == owner_id
]
return rows
async def list_routing(self) -> list[StoredRouting]: async def list_routing(self) -> list[StoredRouting]:
return list(self.routing.values()) return list(self.routing.values())
async def get_credential(self, owner_id: uuid.UUID, provider: str) -> StoredCredential | None: async def get_credential(self, owner_id: uuid.UUID, provider: str) -> StoredCredential | None:
oauth_blob = self.oauth.get((owner_id, provider))
if oauth_blob is not None:
return StoredCredential(
provider=provider, api_key_enc=None, auth_type="oauth", oauth_enc=oauth_blob
)
blob = self.creds.get((owner_id, provider)) blob = self.creds.get((owner_id, provider))
if blob is None: if blob is None:
return None return None
@@ -39,6 +52,19 @@ class FakeCredentialStore:
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
) -> None: ) -> None:
self.creds[(owner_id, provider)] = api_key_enc self.creds[(owner_id, provider)] = api_key_enc
self.oauth.pop((owner_id, provider), None)
async def upsert_oauth_credential(
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
) -> None:
self.oauth[(owner_id, provider)] = oauth_enc
self.creds.pop((owner_id, provider), None)
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
had = (owner_id, provider) in self.creds or (owner_id, provider) in self.oauth
self.creds.pop((owner_id, provider), None)
self.oauth.pop((owner_id, provider), None)
return had
async def upsert_routing(self, routing: StoredRouting) -> None: async def upsert_routing(self, routing: StoredRouting) -> None:
self.routing[routing.tier] = routing self.routing[routing.tier] = routing

View File

@@ -0,0 +1,100 @@
"""T5.2/T5.4 接线:`build_gateway_for_tier` 据 DB tier_routing 装配多 provider 回退链。
不联网:只断言网关被装配了**多个适配器** + 注入了回退链解析器chain_resolver
故链长 > 1。真实回退/降级行为由 packages/llm_gateway 单测 + T5.7 E2E 覆盖。
"""
from __future__ import annotations
import os
import pytest
from fakes_projects import FakeSession
from fakes_providers import FakeCredentialStore
from ww_api.services.credentials import STUB_OWNER_ID, StoredRouting
# 真 Fernet key测试加解密凭据用仅本测试非生产密钥
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
def _store_with_creds(*providers: str) -> FakeCredentialStore:
from ww_api.security.credentials import encrypt_api_key
store = FakeCredentialStore()
for p in providers:
store.creds[(STUB_OWNER_ID, p)] = encrypt_api_key("sk-test", key=_ENC_KEY)
return store
@pytest.mark.asyncio
async def test_build_gateway_wires_fallback_chain_with_multiple_adapters() -> None:
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.services.project_deps import build_gateway_for_tier
store = _store_with_creds("deepseek", "kimi")
store.routing["writer"] = StoredRouting(
tier="writer", provider="deepseek", model="deepseek-chat", fallback=["kimi:moonshot-v1-8k"]
)
gateway = await build_gateway_for_tier(FakeSession(), store, "writer")
# 两个 provider 的适配器都装配进网关 → 回退链可绕过首个失败者。
assert set(gateway._adapters) == {"deepseek", "kimi"} # noqa: SLF001
# chain_resolver 注入 → 链长 2去重保序
chain = gateway._resolve_chain("writer") # noqa: SLF001
assert [r.provider for r in chain] == ["deepseek", "kimi"]
@pytest.mark.asyncio
async def test_build_gateway_single_provider_still_works() -> None:
"""无 fallback 的路由行单元素链M1 行为不变,向后兼容)。"""
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.services.project_deps import build_gateway_for_tier
store = _store_with_creds("deepseek")
store.routing["analyst"] = StoredRouting(
tier="analyst", provider="deepseek", model="deepseek-chat", fallback=[]
)
gateway = await build_gateway_for_tier(FakeSession(), store, "analyst")
assert set(gateway._adapters) == {"deepseek"} # noqa: SLF001
chain = gateway._resolve_chain("analyst") # noqa: SLF001
assert len(chain) == 1
@pytest.mark.asyncio
async def test_build_gateway_no_routing_row_falls_back_to_global_default() -> None:
"""无 DB 路由行 → 退回全局 resolve_route单 provider"""
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.services.project_deps import build_gateway_for_tier
store = _store_with_creds("deepseek") # 无 routing 行
gateway = await build_gateway_for_tier(FakeSession(), store, "writer")
assert "deepseek" in gateway._adapters # noqa: SLF001
@pytest.mark.asyncio
async def test_build_gateway_no_usable_credentials_raises_503() -> None:
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.services.project_deps import build_gateway_for_tier
from ww_shared import AppError, ErrorCode
store = FakeCredentialStore() # 无任何凭据
store.routing["writer"] = StoredRouting(
tier="writer", provider="deepseek", model="deepseek-chat", fallback=["kimi:moonshot-v1-8k"]
)
with pytest.raises(AppError) as exc:
await build_gateway_for_tier(FakeSession(), store, "writer")
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE

View File

@@ -0,0 +1,506 @@
"""T5.2 生成/入库 + 读端点测试(内存替身,无 DB/无网络)。
覆盖:
- POST /world/generatemock 网关产 WorldGenResult → 预览 + commit落 ledger项目不存在 404。
- POST /characters/generatemock 网关产 CharacterGenResult → 预览(含已有角色注入防雷同)。
- POST /characters入库precheck 无冲突 → 写库 + 201有冲突且未确认 → 409确认后放行写库。
- 无凭据 → 503override 网关 dep 抛 LLM_UNAVAILABLE
- GET /rules规则列表GET /skills技能库列表。
"""
from __future__ import annotations
import os
import uuid
from typing import Any
import httpx
import pytest
from fakes_projects import FakeProjectRepo, FakeSession
from test_projects import _empty_memory_repos
from ww_agents import (
AgentSpec,
CharacterCard,
CharacterGenResult,
Conflict,
ContinuityReview,
WorldEntityCard,
WorldGenResult,
)
from ww_core.domain.character_repo import CharacterWriteView
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.repositories import RuleView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
from ww_skills import SkillRegistry
STUB_OWNER = uuid.UUID(int=1)
class _SchemaRoutingGateway:
"""按 `req.output_schema` 返对应 parsedworld/character/precheck 各拿自己的产物)。"""
def __init__(self, by_schema: dict[type[Any] | None, Any]) -> None:
self._by_schema = by_schema
self.calls: list[type[Any] | None] = []
async def run(self, req: LlmRequest) -> LlmResponse:
schema = req.output_schema
self.calls.append(schema)
parsed = self._by_schema.get(schema)
return LlmResponse(
text=parsed.model_dump_json() if parsed is not None else "{}",
parsed=parsed,
usage=Usage(
provider="fake",
model="fake",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake"),
)
class _FakeCharacterWriteRepo:
def __init__(self) -> None:
self.rows: list[dict[str, Any]] = []
async def create(
self,
project_id: uuid.UUID,
*,
name: str,
role: str,
traits: list[str],
backstory: str,
arc: str,
speech_tics: list[str],
tags: list[Any],
relations: list[dict[str, Any]],
) -> CharacterWriteView:
self.rows.append(
{
"project_id": project_id,
"name": name,
"role": role,
"traits": list(traits),
"arc": arc,
"speech_tics": list(speech_tics),
"tags": list(tags),
"relations": [dict(r) for r in relations],
}
)
return CharacterWriteView(id=uuid.uuid4(), name=name, role=role)
class _FakeRulesReadRepo:
def __init__(self, rules: list[RuleView] | None = None) -> None:
self._rules = rules or []
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
return list(self._rules)
def _world_result() -> WorldGenResult:
return WorldGenResult(
entities=[WorldEntityCard(type="力量体系", name="灵脉", rules=["灵力守恒", "禁止跨界"])]
)
def _character_result() -> CharacterGenResult:
return CharacterGenResult(
cards=[
CharacterCard(
name="叶寒",
role="主角",
traits=["腹黑", "护短"],
backstory="孤儿出身",
arc="从孤儿到帝王",
speech_tics=[""],
tags=["天才"],
relations=[],
)
]
)
def _no_conflicts() -> ContinuityReview:
return ContinuityReview(conflicts=[])
def _with_conflicts() -> ContinuityReview:
return ContinuityReview(
conflicts=[Conflict(type="设定违例", where="叶寒卡", refs=["灵脉"], suggestion="改设定")]
)
def _skill_registry() -> SkillRegistry:
spec = AgentSpec(
name="custom-namer",
tier="light",
system_prompt="取名",
input_schema=None,
output_schema=None,
reads=["characters"],
writes=["characters"],
scope="custom",
)
return SkillRegistry({spec.name: spec})
def _make_app(
*,
project_repo: FakeProjectRepo,
gateway: Any,
char_repo: _FakeCharacterWriteRepo | None = None,
session: FakeSession | None = None,
rules_repo: _FakeRulesReadRepo | None = None,
registry: SkillRegistry | None = None,
memory: Any = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_character_gen_gateway,
get_character_write_repo,
get_memory_repos,
get_precheck_gateway,
get_project_repo,
get_rules_read_repo,
get_skill_registry,
get_worldbuilder_gateway,
)
from ww_db import get_session
session = session or FakeSession()
char_repo = char_repo or _FakeCharacterWriteRepo()
rules_repo = rules_repo or _FakeRulesReadRepo()
registry = registry or _skill_registry()
async def _raise_no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_memory_repos] = (
(lambda: memory) if memory is not None else _empty_memory_repos
)
app.dependency_overrides[get_character_write_repo] = lambda: char_repo
app.dependency_overrides[get_rules_read_repo] = lambda: rules_repo
app.dependency_overrides[get_skill_registry] = lambda: registry
app.dependency_overrides[get_session] = lambda: session
gw = _raise_no_creds if no_creds else (lambda: gateway)
app.dependency_overrides[get_worldbuilder_gateway] = gw
app.dependency_overrides[get_character_gen_gateway] = gw
app.dependency_overrides[get_precheck_gateway] = gw
return app, session
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
view = await repo.create(STUB_OWNER, ProjectCreate(title="测试作品", genre="玄幻"))
return uuid.UUID(str(view.id))
def _client(app: Any) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
# ---- 世界观生成 ----
@pytest.mark.asyncio
async def test_generate_world_returns_preview_and_commits_ledger() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()})
app, session = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "东方玄幻"})
assert resp.status_code == 200
body = resp.json()
assert body["entities"][0]["name"] == "灵脉"
assert body["entities"][0]["rules"] == ["灵力守恒", "禁止跨界"]
assert session.commits == 1 # 预览不写业务表,但落 ledger
@pytest.mark.asyncio
async def test_generate_world_unknown_project_404() -> None:
repo = FakeProjectRepo()
gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()})
app, _ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(f"/projects/{uuid.uuid4()}/world/generate", json={"brief": "x"})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_generate_world_no_credentials_503() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, session = _make_app(project_repo=repo, gateway=object(), no_creds=True)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "x"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0
# ---- 角色生成 ----
@pytest.mark.asyncio
async def test_generate_characters_returns_preview() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({CharacterGenResult: _character_result()})
app, session = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters/generate", json={"brief": "天才主角", "count": 1}
)
assert resp.status_code == 200
card = resp.json()["cards"][0]
assert card["name"] == "叶寒"
assert card["traits"] == ["腹黑", "护短"]
assert card["arc"] == "从孤儿到帝王"
assert session.commits == 1
# ---- 角色入库gate----
@pytest.mark.asyncio
async def test_ingest_characters_no_conflict_writes_and_201() -> None:
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)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={
"cards": [
{
"name": "叶寒",
"role": "主角",
"traits": ["腹黑"],
"backstory": "孤儿",
"arc": "成长",
"speech_tics": [""],
"tags": ["天才"],
"relations": [],
}
]
},
)
assert resp.status_code == 201
body = resp.json()
assert body["created"] == ["叶寒"]
assert body["rejected_tables"] == []
assert len(char_repo.rows) == 1
assert char_repo.rows[0]["traits"] == ["腹黑"]
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_characters_with_conflict_blocks_409() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={"cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}]},
)
assert resp.status_code == 409
err = resp.json()["error"]
assert err["code"] == ErrorCode.CONFLICT_UNRESOLVED
assert err["details"]["conflict_count"] == 1
assert err["details"]["conflicts"][0]["type"] == "设定违例"
# 未写库gate 拦下),但落 precheck ledger。
assert len(char_repo.rows) == 0
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_characters_acknowledged_conflict_writes() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
char_repo = _FakeCharacterWriteRepo()
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/characters",
json={
"cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}],
"acknowledge_conflicts": True,
},
)
assert resp.status_code == 201
assert len(char_repo.rows) == 1
# ---- 读端点 ----
@pytest.mark.asyncio
async def test_list_rules_returns_rules() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
rules_repo = _FakeRulesReadRepo(
[
RuleView(level="project", content="主角不复活"),
RuleView(level="global", content="无脏话"),
]
)
gateway = _SchemaRoutingGateway({})
app, _ = _make_app(project_repo=repo, gateway=gateway, rules_repo=rules_repo)
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/rules")
assert resp.status_code == 200
rules = resp.json()["rules"]
assert [r["content"] for r in rules] == ["主角不复活", "无脏话"]
@pytest.mark.asyncio
async def test_list_skills_returns_registry() -> None:
repo = FakeProjectRepo()
gateway = _SchemaRoutingGateway({})
app, _ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.get("/skills")
assert resp.status_code == 200
skills = resp.json()["skills"]
assert skills[0]["name"] == "custom-namer"
assert skills[0]["scope"] == "custom"
assert skills[0]["writes"] == ["characters"]
# ---- 设定库 Codex 读端点GET characters / world_entities----
def _codex_memory() -> Any:
"""MemoryRepos角色/世界观各一行DB JSONB dict 形,验反向解包到 API list/str"""
from test_projects import (
_EmptyDigestRepo,
_EmptyForeshadowRepo,
_EmptyOutlineRepo,
_EmptyRulesRepo,
_EmptyStyleRepo,
_StubProjectSpecRepo,
)
from ww_core.domain.repositories import (
CharacterView,
MemoryRepos,
WorldEntityView,
)
class _CharRepo:
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
return [
CharacterView(
name="叶寒",
role="主角",
traits={"items": ["腹黑", "护短"]},
backstory="孤儿出身",
arc={"text": "从孤儿到帝王"},
speech_tics={"items": [""]},
tags=["天才"],
relations=[],
)
]
class _WorldRepo:
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
return [
WorldEntityView(
type="力量体系",
name="灵脉",
rules={"rules": ["灵力守恒", "禁止跨界"]},
)
]
return MemoryRepos(
outline=_EmptyOutlineRepo(),
character=_CharRepo(),
world_entity=_WorldRepo(),
digest=_EmptyDigestRepo(),
foreshadow=_EmptyForeshadowRepo(),
style=_EmptyStyleRepo(),
rules=_EmptyRulesRepo(),
project=_StubProjectSpecRepo(),
)
@pytest.mark.asyncio
async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory())
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/characters")
assert resp.status_code == 200
chars = resp.json()["characters"]
assert len(chars) == 1
card = chars[0]
# DB JSONB dict → API list/str反向解包入库形变的逆向
assert card["name"] == "叶寒"
assert card["traits"] == ["腹黑", "护短"]
assert card["speech_tics"] == [""]
assert card["arc"] == "从孤儿到帝王"
assert card["tags"] == ["天才"]
@pytest.mark.asyncio
async def test_list_world_entities_unpacks_rules_to_list() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory())
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/world_entities")
assert resp.status_code == 200
entities = resp.json()["world_entities"]
assert len(entities) == 1
assert entities[0]["type"] == "力量体系"
assert entities[0]["name"] == "灵脉"
assert entities[0]["rules"] == ["灵力守恒", "禁止跨界"]
@pytest.mark.asyncio
async def test_list_characters_empty_when_none_ingested() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/characters")
assert resp.status_code == 200
assert resp.json()["characters"] == []

View File

@@ -0,0 +1,153 @@
"""T4.1 通用长任务 runner 单测ARCH §7.4)。
直接 `await run_job(...)`(不起后台线程、不连真 DB注入 fake session 工厂 + fake job
repo + fake work断言
- 成功路set_running → work → complete(result) → commit
- 失败路work 抛 → 回滚 → 新 session 里 fail(error) → commit且异常不冒泡。
独立 session 纪律 = `run_job` 自建 session经 session_factory与 `run_overdue_scan`
先例一致。这里的 fake session 工厂记录开了几次 session + 各次 commit。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from ww_api.services.job_runner import run_job
from ww_core.domain.job_repo import (
PROGRESS_COMPLETE,
STATUS_DONE,
STATUS_FAILED,
STATUS_RUNNING,
JobView,
)
JOB_ID = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
class _FakeSession:
"""最小 fake记录 commit 次数(提交边界断言)。"""
def __init__(self) -> None:
self.commits = 0
async def commit(self) -> None:
self.commits += 1
class _FakeSessionFactory:
"""独立 session 工厂替身:`()` → async-CM 产新 `_FakeSession`,记录开了几次。"""
def __init__(self) -> None:
self.sessions: list[_FakeSession] = []
def __call__(self) -> Any:
session = _FakeSession()
self.sessions.append(session)
@asynccontextmanager
async def _cm() -> AsyncIterator[_FakeSession]:
yield session
return _cm()
@dataclass
class _FakeJobRepo:
"""内存 job repo记录状态流转 + result/error不触 DB"""
status: str = "queued"
progress: int = 0
result: dict[str, Any] | None = None
error: str | None = None
calls: list[str] = field(default_factory=list)
def _view(self, job_id: uuid.UUID) -> JobView:
return JobView(
id=job_id,
kind="style_learn",
status=self.status,
progress=self.progress,
result=self.result,
error=self.error,
)
async def set_running(self, job_id: uuid.UUID) -> JobView:
self.calls.append("set_running")
self.status = STATUS_RUNNING
return self._view(job_id)
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
self.calls.append("complete")
self.status = STATUS_DONE
self.progress = PROGRESS_COMPLETE
self.result = dict(result)
return self._view(job_id)
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
self.calls.append("fail")
self.status = STATUS_FAILED
self.error = error
return self._view(job_id)
# ---- success path ----
async def test_run_job_success_sets_done_and_commits() -> None:
factory = _FakeSessionFactory()
repo = _FakeJobRepo()
received: list[AsyncSession] = []
async def work(session: AsyncSession) -> dict[str, Any]:
received.append(session)
return {"version": 1, "dims_count": 16}
await run_job(
factory,
JOB_ID,
work,
repo_factory=lambda _s: repo,
)
assert repo.calls == ["set_running", "complete"]
assert repo.status == STATUS_DONE
assert repo.progress == PROGRESS_COMPLETE
assert repo.result == {"version": 1, "dims_count": 16}
# 自建了一个独立 session 且提交了一次
assert len(factory.sessions) == 1
assert factory.sessions[0].commits == 1
# work 拿到的就是 run_job 自建的 session
assert len(received) == 1
# ---- failure path ----
async def test_run_job_failure_sets_failed_and_does_not_raise() -> None:
factory = _FakeSessionFactory()
repo = _FakeJobRepo()
async def work(_session: AsyncSession) -> dict[str, Any]:
raise RuntimeError("extraction blew up")
# 异常被吞(后台任务边界),不冒泡
await run_job(
factory,
JOB_ID,
work,
repo_factory=lambda _s: repo,
)
assert "complete" not in repo.calls
assert "fail" in repo.calls
assert repo.status == STATUS_FAILED
assert repo.error == "extraction blew up"
# 失败置态在一个**全新** session 里完成并 commit原事务作废
assert len(factory.sessions) == 2
assert factory.sessions[1].commits == 1

View File

@@ -0,0 +1,77 @@
"""接线测试:`kimi-code-key` 作为**普通 api_key** provider 经既有路径建干净 coding 适配器。
不联网。与 OAuth 的 `kimi-code` 对照——`kimi-code-key`
- 走 api_key 凭据路径Fernet `api_key_enc`**不**触发 OAuth 刷新分支;
- `build_gateway_for_tier` 据 DB `tier_routing` 路由 → `_build_provider_adapter` →
`build_adapter("kimi-code-key", ...)` → `KimiCodeKeyAdapter`coding base + 纯 bearer
- 适配器**不带**伪造 UA / X-Msh-* 头,结构化走 JSON 模式。
"""
from __future__ import annotations
import os
import pytest
from fakes_projects import FakeSession
from fakes_providers import FakeCredentialStore
from ww_api.services.credentials import STUB_OWNER_ID, StoredRouting
from ww_api.services.project_deps import _build_provider_adapter, build_gateway_for_tier
from ww_llm_gateway.adapters.kimi_code_key import KIMI_CODE_KEY_PROVIDER, KimiCodeKeyAdapter
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
_API_KEY = "kimi-console-static-key-not-real"
def _set_env() -> None:
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
def _store_with_key() -> FakeCredentialStore:
from ww_api.security.credentials import encrypt_api_key
store = FakeCredentialStore()
store.creds[(STUB_OWNER_ID, KIMI_CODE_KEY_PROVIDER)] = encrypt_api_key(_API_KEY, key=_ENC_KEY)
return store
@pytest.mark.asyncio
async def test_build_provider_adapter_for_kimi_code_key_sends_spoofed_headers() -> None:
_set_env()
store = _store_with_key()
adapter = await _build_provider_adapter(store, KIMI_CODE_KEY_PROVIDER)
assert isinstance(adapter, KimiCodeKeyAdapter)
assert adapter.provider == "kimi-code-key"
client = adapter._client # noqa: SLF001
assert str(client.base_url).rstrip("/") == "https://api.kimi.com/coding/v1"
assert client.api_key == _API_KEY
# 实测coding 端点据 UA 做 allow-list 门禁缺伪造头→403故静态 key 也必须发伪造头。
headers = client.default_headers
assert any(str(k).lower().startswith("x-msh-") for k in headers)
assert "KimiCLI" in str(headers.get("User-Agent", ""))
@pytest.mark.asyncio
async def test_build_gateway_for_tier_routes_kimi_code_key_for_coding_model() -> None:
_set_env()
store = _store_with_key()
# 把 writer 档位路由到 kimi-code-key:kimi-for-coding无回退
store.routing["writer"] = StoredRouting(
tier="writer",
provider=KIMI_CODE_KEY_PROVIDER,
model="kimi-for-coding",
fallback=[],
)
gateway = await build_gateway_for_tier(FakeSession(), store, "writer")
assert set(gateway._adapters) == {KIMI_CODE_KEY_PROVIDER} # noqa: SLF001
adapter = gateway._adapters[KIMI_CODE_KEY_PROVIDER] # noqa: SLF001
assert isinstance(adapter, KimiCodeKeyAdapter)
chain = gateway._resolve_chain("writer") # noqa: SLF001
assert [r.provider for r in chain] == [KIMI_CODE_KEY_PROVIDER]
assert chain[0].model == "kimi-for-coding"

View File

@@ -0,0 +1,125 @@
"""K1.3 接线测试:`_build_provider_adapter` 为 kimi-code 走 OAuth 路径 + 按需刷新。
不联网refresh 路径 monkeypatch httpx + kimi_oauth.refresh。断言
- 凭据未临近过期 → 直接用现有 access token 建 KimiCodeAdapter不刷新
- 临近过期 → 调 refresh + 持久化新包 + 用新 access token 建适配器;
- 无 oauth 凭据 → LLM_UNAVAILABLE。
"""
from __future__ import annotations
import os
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from fakes_providers import FakeCredentialStore
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.kimi_oauth import TokenSet, decrypt_oauth_bundle, encrypt_oauth_bundle
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER, KimiCodeAdapter
from ww_shared import AppError, ErrorCode
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
def _store_with_token(token: TokenSet) -> FakeCredentialStore:
store = FakeCredentialStore()
store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)] = encrypt_oauth_bundle(token, key=_ENC_KEY)
return store
def _set_env() -> None:
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
from ww_config import get_settings
get_settings.cache_clear()
@pytest.mark.asyncio
async def test_build_adapter_uses_existing_token_when_fresh() -> None:
_set_env()
from ww_api.services.project_deps import _build_provider_adapter
fresh = TokenSet(
access_token="fresh-acc",
refresh_token="ref",
expires_at=datetime.now(UTC) + timedelta(seconds=900),
)
store = _store_with_token(fresh)
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
assert isinstance(adapter, KimiCodeAdapter)
# 未刷新oauth 凭据未变。
cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)]
assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "fresh-acc"
@pytest.mark.asyncio
async def test_build_adapter_refreshes_when_near_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
_set_env()
from ww_api.services import project_deps
from ww_api.services.project_deps import _build_provider_adapter
near = TokenSet(
access_token="old-acc",
refresh_token="old-ref",
expires_at=datetime.now(UTC) + timedelta(seconds=30), # 临近过期
)
store = _store_with_token(near)
refreshed = TokenSet(
access_token="new-acc",
refresh_token="new-ref",
expires_at=datetime.now(UTC) + timedelta(seconds=900),
)
async def _fake_refresh(_http: Any, _refresh_token: str) -> TokenSet:
assert _refresh_token == "old-ref"
return refreshed
# 替换 refresh + httpx.AsyncClient避免真联网
monkeypatch.setattr(project_deps, "kimi_refresh", _fake_refresh)
class _FakeAsyncClient:
async def __aenter__(self) -> _FakeAsyncClient:
return self
async def __aexit__(self, *exc: object) -> None:
return None
monkeypatch.setattr(
"ww_api.services.project_deps.httpx.AsyncClient", lambda *a, **k: _FakeAsyncClient()
)
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
assert isinstance(adapter, KimiCodeAdapter)
# 刷新后新包持久化(下次复用刷新结果)。
cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)]
assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "new-acc"
@pytest.mark.asyncio
async def test_build_adapter_no_credential_returns_none() -> None:
_set_env()
from ww_api.services.project_deps import _build_provider_adapter
store = FakeCredentialStore() # 无任何凭据
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
assert adapter is None
@pytest.mark.asyncio
async def test_build_adapter_raises_when_oauth_missing_blob() -> None:
"""auth_type=oauth 但 oauth_enc=None数据不一致→ LLM_UNAVAILABLE。"""
_set_env()
from ww_api.services.credentials import StoredCredential
from ww_api.services.project_deps import _resolve_kimi_code_token
bad = StoredCredential(
provider=KIMI_CODE_PROVIDER, api_key_enc=None, auth_type="oauth", oauth_enc=None
)
with pytest.raises(AppError) as exc:
await _resolve_kimi_code_token(FakeCredentialStore(), bad, _ENC_KEY)
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE

View File

@@ -0,0 +1,213 @@
"""K1.3 端点测试Kimi Code OAuth start/disconnect/status内存替身无 DB/无网络)。
覆盖:
- POST .../start → 202 + user_code/verification_uri + 创建 job后台轮询 poll_token 成功
→ 存加密 oauth_enc + job done**token 不进 job 结果/响应**
- 后台轮询 authorization_pending → 继续轮询直到成功;
- POST .../disconnect → 清除凭据行;
- GET .../status → connected 真值(无 token 本体)。
后台 work 用 `run_job` 自建独立 session请求 session 已关闭)——测试经依赖 override
注 fake http + fake store共享实例断后台落库+ FakeSessionFactorymonkeypatch
`kimi_oauth.asyncio.sleep` 为 no-op避免真等 interval 秒)。
"""
from __future__ import annotations
import os
from typing import Any
import pytest
from cryptography.fernet import Fernet
from fakes_providers import FakeCredentialStore
from fastapi.testclient import TestClient
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.kimi_oauth import decrypt_oauth_bundle
from ww_db import get_session
class _FakeResponse:
def __init__(self, status_code: int, body: dict[str, Any]) -> None:
self.status_code = status_code
self._body = body
def json(self) -> Any:
return self._body
class _ScriptedHttp:
"""按调用序号返回预设响应device auth → poll attempts。aclose 无操作。"""
def __init__(self, responses: list[_FakeResponse]) -> None:
self._responses = responses
self.calls: list[tuple[str, dict[str, str]]] = []
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
idx = len(self.calls)
self.calls.append((url, data))
return self._responses[idx]
async def aclose(self) -> None:
return None
def _make_client(
*,
store: FakeCredentialStore,
http: _ScriptedHttp,
enc_key: str,
monkeypatch: pytest.MonkeyPatch,
) -> TestClient:
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
from ww_config import get_settings
get_settings.cache_clear()
from fakes_projects import FakeJobRepo, FakeSession, FakeSessionFactory
from ww_api import routers
from ww_api.main import create_app
from ww_api.services import job_runner
from ww_api.services.project_deps import (
get_job_repo,
get_session_factory,
)
from ww_api.services.provider_deps import get_credential_store
# 后台轮询不真等 interval 秒。
async def _no_sleep(_seconds: float) -> None:
return None
monkeypatch.setattr("ww_api.routers.kimi_oauth.asyncio.sleep", _no_sleep)
# 后台 work 自建 SqlCredentialStore(session)——指到共享 fake store断后台落库
monkeypatch.setattr(routers.kimi_oauth, "SqlCredentialStore", lambda _session: store)
# 后台 work 内调模块级 `_default_http_client()` 自建 http非 dep——monkeypatch 它指到
# scripted fake否则后台轮询会真联网见 device-flow 注入纪律)。
monkeypatch.setattr(routers.kimi_oauth, "_default_http_client", lambda: http)
# `run_job` 默认 repo_factory 构 `SqlJobRepo(session)`——FakeSession 无 .execute
# 故 monkeypatch 模块级 `job_runner.SqlJobRepo` 指到共享 fake见 memory/gotchas
job_repo = FakeJobRepo()
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
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
return TestClient(app)
def _device_auth_response() -> _FakeResponse:
return _FakeResponse(
200,
{
"device_code": "dev-1",
"user_code": "WXYZ-9999",
"verification_uri": "https://kimi.com/device",
"verification_uri_complete": "https://kimi.com/device?code=WXYZ-9999",
"expires_in": 600,
"interval": 5,
},
)
def _token_response() -> _FakeResponse:
return _FakeResponse(
200, {"access_token": "secret-acc", "refresh_token": "secret-ref", "expires_in": 900}
)
def test_start_returns_202_and_polls_to_success(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
# device auth → poll #1 successTestClient 同步跑完 background task
http = _ScriptedHttp([_device_auth_response(), _token_response()])
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.post("/settings/providers/kimi-code/oauth/start")
assert resp.status_code == 202
body = resp.json()
assert body["user_code"] == "WXYZ-9999"
assert body["verification_uri"] == "https://kimi.com/device"
assert body["interval"] == 5
assert "job_id" in body
# 响应绝不含 token。
assert "secret-acc" not in resp.text
assert "secret-ref" not in resp.text
# 后台轮询成功 → 加密 oauth 凭据落库(共享 store
cred = store.oauth.get((STUB_OWNER_ID, "kimi-code"))
assert cred is not None
token = decrypt_oauth_bundle(cred, key=enc_key)
assert token.access_token == "secret-acc"
assert token.refresh_token == "secret-ref"
def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
http = _ScriptedHttp(
[
_device_auth_response(),
_FakeResponse(400, {"error": "authorization_pending"}),
_token_response(),
]
)
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.post("/settings/providers/kimi-code/oauth/start")
assert resp.status_code == 202
# pending 后继续轮询 → 第二次成功落库。
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
store.oauth[(STUB_OWNER_ID, "kimi-code")] = b"blob"
http = _ScriptedHttp([])
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.post("/settings/providers/kimi-code/oauth/disconnect")
assert resp.status_code == 200
assert resp.json()["disconnected"] is True
assert (STUB_OWNER_ID, "kimi-code") not in store.oauth
def test_status_reflects_connection(monkeypatch: pytest.MonkeyPatch) -> None:
from datetime import UTC, datetime, timedelta
from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
expires = datetime.now(UTC) + timedelta(seconds=900)
token = TokenSet(access_token="a", refresh_token="r", expires_at=expires)
store.oauth[(STUB_OWNER_ID, "kimi-code")] = encrypt_oauth_bundle(token, key=enc_key)
http = _ScriptedHttp([])
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.get("/settings/providers/kimi-code/oauth/status")
assert resp.status_code == 200
body = resp.json()
assert body["connected"] is True
assert body["expires_at"] is not None
# 状态绝不回 token 本体。
assert "access_token" not in body
assert "refresh_token" not in body
def test_status_not_connected_when_no_credential(monkeypatch: pytest.MonkeyPatch) -> None:
enc_key = Fernet.generate_key().decode()
store = FakeCredentialStore()
http = _ScriptedHttp([])
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
resp = client.get("/settings/providers/kimi-code/oauth/status")
assert resp.status_code == 200
assert resp.json()["connected"] is False

View File

@@ -0,0 +1,184 @@
"""K1.3 单测Kimi Code OAuth device-flow 服务fake httpx绝不联网
覆盖start_device_authorization、poll_token成功/pending/slow_down/失败、refresh、
encrypt/decrypt round-trip、needs_refresh 启发式。
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from cryptography.fernet import Fernet
from ww_api.services.kimi_oauth import (
DEVICE_AUTHORIZATION_URL,
TOKEN_URL,
AuthorizationPending,
SlowDown,
TokenSet,
decrypt_oauth_bundle,
encrypt_oauth_bundle,
needs_refresh,
poll_token,
refresh,
start_device_authorization,
)
from ww_shared import AppError, ErrorCode
class _FakeResponse:
def __init__(self, status_code: int, body: dict[str, Any]) -> None:
self.status_code = status_code
self._body = body
def json(self) -> Any:
return self._body
class _FakeHttp:
"""记录每次 post 的 url + data按 url 返回预设响应。"""
def __init__(self, responses: list[_FakeResponse]) -> None:
self._responses = responses
self.calls: list[tuple[str, dict[str, str]]] = []
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
self.calls.append((url, data))
return self._responses[len(self.calls) - 1]
@pytest.mark.asyncio
async def test_start_device_authorization_parses_response_and_sends_scope() -> None:
http = _FakeHttp(
[
_FakeResponse(
200,
{
"device_code": "dev-123",
"user_code": "ABCD-1234",
"verification_uri": "https://kimi.com/device",
"verification_uri_complete": "https://kimi.com/device?code=ABCD-1234",
"expires_in": 600,
"interval": 5,
},
)
]
)
device = await start_device_authorization(http)
assert device.device_code == "dev-123"
assert device.user_code == "ABCD-1234"
assert device.interval == 5
assert device.expires_in == 600
# 请求打到 device authorization 端点,且**带 scope=kimi-code**coding entitlement
url, data = http.calls[0]
assert url == DEVICE_AUTHORIZATION_URL
assert "client_id" in data
assert data["scope"] == "kimi-code"
@pytest.mark.asyncio
async def test_poll_token_success_returns_token_set() -> None:
http = _FakeHttp(
[
_FakeResponse(
200,
{
"access_token": "acc-xyz",
"refresh_token": "ref-xyz",
"expires_in": 900,
},
)
]
)
token = await poll_token(http, "dev-123")
assert token.access_token == "acc-xyz"
assert token.refresh_token == "ref-xyz"
assert token.expires_at > datetime.now(UTC)
# 打到 token 端点 + device_code grant。
url, data = http.calls[0]
assert url == TOKEN_URL
assert data["device_code"] == "dev-123"
assert data["grant_type"].endswith("device_code")
@pytest.mark.asyncio
async def test_poll_token_authorization_pending_raises_pending() -> None:
http = _FakeHttp([_FakeResponse(400, {"error": "authorization_pending"})])
with pytest.raises(AuthorizationPending):
await poll_token(http, "dev-123")
@pytest.mark.asyncio
async def test_poll_token_slow_down_raises_slow_down() -> None:
http = _FakeHttp([_FakeResponse(400, {"error": "slow_down"})])
with pytest.raises(SlowDown):
await poll_token(http, "dev-123")
@pytest.mark.asyncio
async def test_poll_token_expired_raises_app_error() -> None:
http = _FakeHttp([_FakeResponse(400, {"error": "expired_token"})])
with pytest.raises(AppError) as exc:
await poll_token(http, "dev-123")
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
@pytest.mark.asyncio
async def test_refresh_returns_new_token_set() -> None:
http = _FakeHttp(
[
_FakeResponse(
200, {"access_token": "new-acc", "refresh_token": "new-ref", "expires_in": 900}
)
]
)
token = await refresh(http, "ref-old")
assert token.access_token == "new-acc"
assert token.refresh_token == "new-ref"
url, data = http.calls[0]
assert url == TOKEN_URL
assert data["grant_type"] == "refresh_token"
assert data["refresh_token"] == "ref-old"
@pytest.mark.asyncio
async def test_refresh_failure_raises_app_error() -> None:
http = _FakeHttp([_FakeResponse(400, {"error": "invalid_grant"})])
with pytest.raises(AppError) as exc:
await refresh(http, "ref-bad")
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
def test_encrypt_decrypt_oauth_bundle_round_trip() -> None:
key = Fernet.generate_key().decode()
expires = datetime.now(UTC) + timedelta(seconds=900)
token = TokenSet(access_token="acc", refresh_token="ref", expires_at=expires)
blob = encrypt_oauth_bundle(token, key=key)
restored = decrypt_oauth_bundle(blob, key=key)
assert restored.access_token == "acc"
assert restored.refresh_token == "ref"
assert restored.expires_at == expires
# 密文不含明文 token加密真生效
assert b"acc" not in blob
assert b"ref" not in blob
def test_needs_refresh_true_when_near_expiry() -> None:
now = datetime.now(UTC)
near = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=60))
assert needs_refresh(near, now=now) is True
def test_needs_refresh_false_when_fresh() -> None:
now = datetime.now(UTC)
fresh = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=900))
assert needs_refresh(fresh, now=now) is False

View File

@@ -16,6 +16,7 @@ import httpx
import pytest import pytest
from fakes_projects import ( from fakes_projects import (
FakeForeshadowRepo, FakeForeshadowRepo,
FakeOutlineReadRepo,
FakeOutlineWriteRepo, FakeOutlineWriteRepo,
FakeProjectRepo, FakeProjectRepo,
FakeReviewGateway, FakeReviewGateway,
@@ -186,6 +187,84 @@ async def test_generate_outline_upper_error_propagates() -> None:
assert session.commits == 0 assert session.commits == 0
# ---- 大纲读取GET /outline----
def _make_read_client(
*,
project_repo: FakeProjectRepo,
outline_read_repo: FakeOutlineReadRepo,
) -> httpx.AsyncClient:
"""装配 GET /outline 测试客户端(注入 fake 项目 repo + 读侧 outline repo"""
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import get_outline_read_repo, get_project_repo
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_outline_read_repo] = lambda: outline_read_repo
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_beats() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
read_repo = FakeOutlineReadRepo()
# 乱序入种,断言端点按 chapter_no 升序返回。
read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["冲突升级"])
read_repo.add_chapter(
pid,
volume=1,
chapter_no=1,
beats=["开篇引入主角", "埋下信物伏笔"],
foreshadow_windows=[{"code": "F1", "plant_chapter": 1, "expected_close_to": 10}],
)
client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo)
async with client:
resp = await client.get(f"/projects/{pid}/outline")
assert resp.status_code == 200
body = resp.json()
assert [c["no"] for c in body["chapters"]] == [1, 2]
ch1 = body["chapters"][0]
# beats 由 DB dict `{"beats": [...]}` 解包成裸 list与 POST 响应同形)。
assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
assert ch1["foreshadow_windows"][0]["code"] == "F1"
assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
assert body["chapters"][1]["beats"] == ["冲突升级"]
@pytest.mark.asyncio
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
client = _make_read_client(project_repo=project_repo, outline_read_repo=FakeOutlineReadRepo())
async with client:
resp = await client.get(f"/projects/{pid}/outline")
# 项目存在但无大纲 → 200 空列表(非 404
assert resp.status_code == 200
assert resp.json()["chapters"] == []
@pytest.mark.asyncio
async def test_get_outline_unknown_project_404() -> None:
client = _make_read_client(
project_repo=FakeProjectRepo(), outline_read_repo=FakeOutlineReadRepo()
)
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}/outline")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
# ---- 伏笔看板 ---- # ---- 伏笔看板 ----

View File

@@ -16,6 +16,7 @@ from ww_core.domain.repositories import (
ForeshadowView, ForeshadowView,
MemoryRepos, MemoryRepos,
OutlineView, OutlineView,
ProjectSpecView,
RuleView, RuleView,
StyleView, StyleView,
WorldEntityView, WorldEntityView,
@@ -27,6 +28,9 @@ class _EmptyOutlineRepo:
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None: async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return None return None
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
return []
class _EmptyCharacterRepo: class _EmptyCharacterRepo:
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]: async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
@@ -58,6 +62,11 @@ class _EmptyRulesRepo:
return [] return []
class _StubProjectSpecRepo:
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
return ProjectSpecView(title="测试作品", premise="测试前提")
def _empty_memory_repos() -> MemoryRepos: def _empty_memory_repos() -> MemoryRepos:
return MemoryRepos( return MemoryRepos(
outline=_EmptyOutlineRepo(), outline=_EmptyOutlineRepo(),
@@ -67,6 +76,7 @@ def _empty_memory_repos() -> MemoryRepos:
foreshadow=_EmptyForeshadowRepo(), foreshadow=_EmptyForeshadowRepo(),
style=_EmptyStyleRepo(), style=_EmptyStyleRepo(),
rules=_EmptyRulesRepo(), rules=_EmptyRulesRepo(),
project=_StubProjectSpecRepo(),
) )
@@ -208,3 +218,30 @@ async def test_put_draft_is_idempotent() -> None:
assert saved.content == "改稿更长" assert saved.content == "改稿更长"
assert saved.version == 1 assert saved.version == 1
assert r2.json()["length"] == len("改稿更长") assert r2.json()["length"] == len("改稿更长")
@pytest.mark.asyncio
async def test_get_draft_returns_saved_content() -> None:
chapter_repo = FakeChapterRepo()
client, _, _, _ = _make_client(chapter_repo=chapter_repo)
pid = uuid.uuid4()
async with client:
await client.put(f"/projects/{pid}/chapters/5/draft", json={"text": "阿福重访工作台"})
resp = await client.get(f"/projects/{pid}/chapters/5/draft")
assert resp.status_code == 200
body = resp.json()
assert body["content"] == "阿福重访工作台"
assert body["status"] == "draft"
assert body["version"] == 1
assert body["chapter_no"] == 5
assert body["length"] == len("阿福重访工作台")
@pytest.mark.asyncio
async def test_get_draft_404_when_no_draft() -> None:
client, _, _, _ = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND

View File

@@ -0,0 +1,87 @@
"""T5.5 POST /projects/:id/rules 端点(内存替身,无 DB/无网络)。
覆盖:
- 201 + 回显 level/content + 端点 commit
- 非法 level → 422Pydantic Literal 校验FastAPI 422
- 空 content → 422。
"""
from __future__ import annotations
import uuid
import httpx
import pytest
from fakes_projects import FakeSession
from ww_core.domain.rule_repo import RuleWriteView
class _FakeRuleWriteRepo:
def __init__(self) -> None:
self.rows: list[RuleWriteView] = []
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
view = RuleWriteView(project_id=project_id, level=level, content=content)
self.rows.append(view)
return view
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import get_rule_write_repo
from ww_db import get_session
repo = _FakeRuleWriteRepo()
session = FakeSession()
app = create_app()
app.dependency_overrides[get_rule_write_repo] = lambda: repo
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, repo, session
@pytest.mark.asyncio
async def test_create_rule_returns_201_and_commits() -> None:
client, repo, session = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "project", "content": "主角不许中途复活"},
)
assert resp.status_code == 201
body = resp.json()
assert body["level"] == "project"
assert body["content"] == "主角不许中途复活"
assert session.commits == 1
assert len(repo.rows) == 1
assert repo.rows[0].project_id == pid
@pytest.mark.asyncio
async def test_create_rule_invalid_level_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "nonsense", "content": "x"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_rule_empty_content_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "global", "content": ""},
)
assert resp.status_code == 422

View File

@@ -0,0 +1,394 @@
"""T4.3 端点测试学文风jobs 异步)+ 回炉 + 最新指纹(内存替身,无 DB/无网络)。
覆盖:
- POST /style写一行 job 返 202 `{job_id}`BackgroundTask 跑完写 style_fingerprint
+ 置 job doneASGITransport 下 background task 在请求返回前跑完,见 gotcha
- mode="update" → 第二次 append version+1
- 项目不存在 → 404无凭据 → 503dep 解析阶段拦下,未写 job
- GET /style返最新指纹dimensions/evidence/version无指纹 → 404
- POST /refine{original, refined}refiner 纯文本)+ 末尾 commit记账落库无凭据 → 503。
学文风 work 在 `run_job` 自建独立 session 上自造网关 + repo请求 session 已关闭)——
测试 monkeypatch `build_gateway_for_tier`(产 StyleFingerprintResult+ 写侧 repo
(指到共享 fake repo并 override `get_session_factory` 为 FakeSessionFactory断言后台落库。
"""
from __future__ import annotations
import os
import uuid
import httpx
import pytest
from fakes_projects import (
FakeJobRepo,
FakeProjectRepo,
FakeReviewGateway,
FakeSession,
FakeSessionFactory,
FakeStyleWriteRepo,
)
from fastapi import FastAPI
from ww_agents import StyleDimension, StyleFingerprintResult
from ww_core.domain.job_repo import STATUS_DONE
from ww_core.domain.project_repo import ProjectCreate
from ww_shared import AppError, ErrorCode
def _fingerprint() -> StyleFingerprintResult:
return StyleFingerprintResult(
dimensions=[
StyleDimension(name="句长节奏", value="短句为主", evidence=["他来了。", "她走了。"]),
StyleDimension(name="叙事人称", value="第三人称", evidence=["他看着远方。"]),
]
)
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻"))
return uuid.UUID(str(view.id))
def _app_with_overrides(
*,
project_repo: FakeProjectRepo,
job_repo: FakeJobRepo | None = None,
style_repo: FakeStyleWriteRepo | None = None,
session: FakeSession | None = None,
session_factory: FakeSessionFactory | None = None,
extract_gateway: object | None = None,
refine_gateway: object | None = None,
) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_job_repo,
get_project_repo,
get_refine_gateway,
get_session_factory,
get_style_extract_gateway,
get_style_write_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 job_repo is not None:
app.dependency_overrides[get_job_repo] = lambda: job_repo
if style_repo is not None:
app.dependency_overrides[get_style_write_repo] = lambda: style_repo
if session_factory is not None:
app.dependency_overrides[get_session_factory] = lambda: session_factory
if extract_gateway is not None:
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
return app
def _client(app: FastAPI) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
# ---- POST /style学文风 jobs 异步)----
@pytest.mark.asyncio
async def test_learn_style_returns_202_and_writes_job_row(
monkeypatch: pytest.MonkeyPatch,
) -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
session = FakeSession()
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=session,
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
# background task work 触网关/repo → monkeypatch 成 no-op fake本用例不验落库
# run_job 默认 repo_factory 用 SqlJobRepo(FakeSession) 会触 .execute → 改用 fake job repo。
import ww_api.routers.style as style_mod
import ww_api.services.job_runner as runner_mod
async def _noop_work(_session: object) -> dict[str, int]:
return {"version": 1, "dims_count": 2}
monkeypatch.setattr(style_mod, "_make_style_learn_work", lambda *_a, **_k: _noop_work)
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/style", json={"samples": ["样本正文一"]})
assert resp.status_code == 202
body = resp.json()
job_id = uuid.UUID(body["job_id"])
# job 行已创建 + 请求阶段 commit 一次(供前端立即轮询)。
assert job_id in job_repo.rows
assert job_repo.rows[job_id].kind == "style_learn"
assert session.commits == 1
@pytest.mark.asyncio
async def test_learn_style_background_task_writes_fingerprint_and_completes_job(
monkeypatch: pytest.MonkeyPatch,
) -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
style_repo = FakeStyleWriteRepo()
session_factory = FakeSessionFactory()
import ww_api.routers.style as style_mod
# work 内 build_gateway_for_tier → fake 网关产指纹;写侧 repo → 共享 fake repo。
async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def]
return FakeReviewGateway(parsed=_fingerprint())
monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
# `run_job` 用 SqlJobRepo默认 repo_factory会触 FakeSession.execute → 改用 fake job repo。
import ww_api.services.job_runner as runner_mod
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=FakeSession(),
session_factory=session_factory,
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/style", json={"samples": ["样本一", "样本二"], "mode": "create"}
)
assert resp.status_code == 202
job_id = uuid.UUID(resp.json()["job_id"])
# BackgroundTask 已跑完ASGITransport 等其完成style_fingerprint 落库 + job done。
assert style_repo.append_calls == 1
assert style_repo.rows[1][0] == {"句长节奏": "短句为主", "叙事人称": "第三人称"}
assert style_repo.rows[1][1]["句长节奏"] == ["他来了。", "她走了。"]
assert job_repo.rows[job_id].status == STATUS_DONE
assert job_repo.rows[job_id].result == {"version": 1, "dims_count": 2}
@pytest.mark.asyncio
async def test_learn_style_update_mode_bumps_version(monkeypatch: pytest.MonkeyPatch) -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
style_repo = FakeStyleWriteRepo()
# 预置 version 1首学已存在
await style_repo.append(pid, dimensions_json={"x": "y"}, evidence_json={"x": []})
import ww_api.routers.style as style_mod
import ww_api.services.job_runner as runner_mod
async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def]
return FakeReviewGateway(parsed=_fingerprint())
monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=FakeSession(),
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/style", json={"samples": ["新样本"], "mode": "update"}
)
assert resp.status_code == 202
job_id = uuid.UUID(resp.json()["job_id"])
# 第二次 append → version 2version+1
assert max(style_repo.rows) == 2
assert job_repo.rows[job_id].result == {"version": 2, "dims_count": 2}
@pytest.mark.asyncio
async def test_learn_style_unknown_project_404() -> None:
project_repo = FakeProjectRepo()
app = _app_with_overrides(
project_repo=project_repo,
job_repo=FakeJobRepo(),
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(f"/projects/{uuid.uuid4()}/style", json={"samples": ["x"]})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_learn_style_without_credentials_503_and_no_job() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
job_repo = FakeJobRepo()
session = FakeSession()
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = _app_with_overrides(
project_repo=project_repo,
job_repo=job_repo,
session=session,
session_factory=FakeSessionFactory(),
)
from ww_api.services.project_deps import get_style_extract_gateway
app.dependency_overrides[get_style_extract_gateway] = _no_creds
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/style", json={"samples": ["x"]})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
# 凭据探测在调度 job 之前 → 未写 job、未 commit。
assert job_repo.rows == {}
assert session.commits == 0
@pytest.mark.asyncio
async def test_learn_style_empty_samples_422() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(
project_repo=project_repo,
job_repo=FakeJobRepo(),
session_factory=FakeSessionFactory(),
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/style", json={"samples": []})
assert resp.status_code == 422
# ---- GET /style最新指纹----
@pytest.mark.asyncio
async def test_get_style_returns_latest_fingerprint() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
style_repo = FakeStyleWriteRepo()
await style_repo.append(
pid, dimensions_json={"句长": ""}, evidence_json={"句长": ["他来了。"]}
)
app = _app_with_overrides(project_repo=project_repo, style_repo=style_repo)
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/style")
assert resp.status_code == 200
body = resp.json()
assert body["version"] == 1
assert body["dimensions"] == {"句长": ""}
assert body["evidence"] == {"句长": ["他来了。"]}
@pytest.mark.asyncio
async def test_get_style_no_fingerprint_404() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(project_repo=project_repo, style_repo=FakeStyleWriteRepo())
async with _client(app) as client:
resp = await client.get(f"/projects/{pid}/style")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_get_style_unknown_project_404() -> None:
app = _app_with_overrides(project_repo=FakeProjectRepo(), style_repo=FakeStyleWriteRepo())
async with _client(app) as client:
resp = await client.get(f"/projects/{uuid.uuid4()}/style")
assert resp.status_code == 404
# ---- POST /refine同步回炉----
@pytest.mark.asyncio
async def test_refine_returns_original_and_refined_and_commits() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
gateway = FakeReviewGateway(text="重写后的段落。")
app = _app_with_overrides(project_repo=project_repo, session=session, refine_gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chapters/1/refine",
json={"segment": "原始段落。", "instruction": "去掉机翻腔"},
)
assert resp.status_code == 200
body = resp.json()
assert body["original"] == "原始段落。"
assert body["refined"] == "重写后的段落。"
# writer 档 + 末尾 commit记账落库
assert gateway.requests[0].tier == "writer"
assert session.commits == 1
# 指令进入了输入文本。
assert "去掉机翻腔" in str(gateway.requests[0].input)
@pytest.mark.asyncio
async def test_refine_unknown_project_404() -> None:
app = _app_with_overrides(
project_repo=FakeProjectRepo(), refine_gateway=FakeReviewGateway(text="x")
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/chapters/1/refine", json={"segment": "原段"}
)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_refine_empty_segment_422() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
app = _app_with_overrides(project_repo=project_repo, refine_gateway=FakeReviewGateway(text="x"))
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": ""})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_refine_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_refine_gateway
app.dependency_overrides[get_refine_gateway] = _no_creds
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": "原段"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0

View File

@@ -6,7 +6,10 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from ww_config import get_settings
from ww_core.domain.job_repo import SqlJobRepo
from ww_db import get_sessionmaker from ww_db import get_sessionmaker
from ww_shared import AppError, ErrorBody, ErrorEnvelope from ww_shared import AppError, ErrorBody, ErrorEnvelope
@@ -14,11 +17,15 @@ from ww_api.logging_config import configure_logging, get_logger
from ww_api.middleware import request_id_middleware from ww_api.middleware import request_id_middleware
from ww_api.routers import ( from ww_api.routers import (
foreshadow, foreshadow,
generation,
health, health,
jobs, jobs,
kimi_oauth,
outline, outline,
projects, projects,
rules,
settings_providers, settings_providers,
style,
) )
from ww_api.services.project_deps import seed_stub_user from ww_api.services.project_deps import seed_stub_user
@@ -31,11 +38,28 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
# 幂等 seed 单用户 stub——所有 owner_id FK 依赖它(见 memory/gotchas # 幂等 seed 单用户 stub——所有 owner_id FK 依赖它(见 memory/gotchas
async with get_sessionmaker()() as session: async with get_sessionmaker()() as session:
await seed_stub_user(session) await seed_stub_user(session)
# zombie reaperM4-d / §7.4 缓解):进程重启会丢未跑完的 BackgroundTask把残留
# status=running 的 job 标 failed让用户看到失败可重试而非进度条永转。自 commit、幂等。
async with get_sessionmaker()() as session:
reaped = await SqlJobRepo(session).reap_zombies()
if reaped:
log.info("job_zombies_reaped", count=reaped)
yield yield
def create_app() -> FastAPI: def create_app() -> FastAPI:
app = FastAPI(title="网文创作工作流 API", version="0.0.0", lifespan=_lifespan) app = FastAPI(title="网文创作工作流 API", version="0.0.0", lifespan=_lifespan)
# CORS前端Next.js与 API 分端口/跨源,浏览器端 `api.POST/PUT` 需 CORS 放行
# RSC 服务端取数同源、无需 CORS故此前同进程测试从未暴露此缺口。原型单用户、
# 本地开发:放行 localhost:3000可经 env `CORS_ORIGINS`(逗号分隔)覆盖。
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.middleware("http")(request_id_middleware) app.middleware("http")(request_id_middleware)
@app.exception_handler(AppError) @app.exception_handler(AppError)
@@ -57,7 +81,12 @@ def create_app() -> FastAPI:
app.include_router(projects.router) app.include_router(projects.router)
app.include_router(foreshadow.router) app.include_router(foreshadow.router)
app.include_router(outline.router) app.include_router(outline.router)
app.include_router(rules.router)
app.include_router(style.router)
app.include_router(generation.router)
app.include_router(generation.skills_router)
app.include_router(settings_providers.router) app.include_router(settings_providers.router)
app.include_router(kimi_oauth.router)
return app return app

View File

@@ -0,0 +1,427 @@
"""生成/入库端点C3 扩 / ARCH §6.5 角色生成器 / §5.4 / §7.2;不变量 #1/#3/#9
三类端点 + 两个读端点M5-d 生成走即时返回:预览 → 作者确认 → 入库,非 jobs
- `POST /projects/:id/world/generate`worldbuilderwriter 网关)→ **预览**世界观实体(不入库)。
- `POST /projects/:id/characters/generate`character-genwriter 网关,群像防雷同)→ 预览角色卡。
- `POST /projects/:id/characters`:作者确认的角色卡入库——**入库前 gate**
1. `precheck_generated_cards`continuity 预检analyst 网关)比对生成卡 vs 真相源;
有冲突且未 `acknowledge_conflicts` → **409 CONFLICT_UNRESOLVED**(不静默入库,仿 accept gate
守不变量 #3作者确认后放行。
2. `partition_writes`:按 character-gen 声明的 `writes` 白名单过滤(越权表丢弃 + 审计),守 §5.6。
3. 写 `characters` 行schema list/str → DB JSONB dict 形变,见 character_repo
- `GET /projects/:id/rules`规则列表规则页T5.6)。
- `GET /skills`:技能库列表(技能库 UIT5.6)。
提交边界:网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(生成预览也 commit
ledger否则 usage 静默丢失,同 draft/review 纪律)。无凭据 → `LLM_UNAVAILABLE`503
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import (
CharacterCard,
CharacterRelation,
character_gen_spec,
continuity_spec,
worldbuilder_spec,
)
from ww_core.domain import (
CharacterWriteRepo,
ProjectRepo,
)
from ww_core.domain.repositories import MemoryRepos, RulesRepo
from ww_core.orchestrator import (
precheck_generated_cards,
run_character_gen,
run_worldbuilder,
)
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode
from ww_skills import SkillRegistry, partition_writes
from ww_api.logging_config import get_logger
from ww_api.schemas.generation import (
CharacterCardView,
CharacterGenerateRequest,
CharacterGenPreviewResponse,
CharacterIngestRequest,
CharacterIngestResponse,
CharacterListResponse,
CharacterRelationView,
IngestConflictView,
RuleListResponse,
SkillListResponse,
SkillView,
WorldEntityCardView,
WorldEntityListResponse,
WorldGenerateRequest,
WorldGenPreviewResponse,
)
from ww_api.schemas.rules import RuleView
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import (
get_character_gen_gateway,
get_character_write_repo,
get_memory_repos,
get_precheck_gateway,
get_project_repo,
get_rules_read_repo,
get_skill_registry,
get_worldbuilder_gateway,
)
log = get_logger("ww.api.generation")
router = APIRouter(prefix="/projects", tags=["generation"])
skills_router = APIRouter(prefix="/skills", tags=["skills"])
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
CharacterWriteRepoDep = Annotated[CharacterWriteRepo, Depends(get_character_write_repo)]
RulesReadRepoDep = Annotated[RulesRepo, Depends(get_rules_read_repo)]
SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)]
PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def _project_context(title: str, genre: str | None, premise: str | None, theme: str | None) -> str:
"""确定性序列化作品设定(喂 worldbuilder无时间戳/UUID"""
lines = [f"标题:{title}"]
if genre:
lines.append(f"题材:{genre}")
if premise:
lines.append(f"前提:{premise}")
if theme:
lines.append(f"主题:{theme}")
return "\n".join(lines)
def _render_characters_context(cards: list[CharacterCard]) -> str:
"""已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。"""
if not cards:
return ""
return "\n".join(f"- {c.name}{c.role}{''.join(c.traits) or '(未列)'}" for c in cards)
async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]:
"""把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck
DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。
"""
views = await memory.character.list_for_project(project_id)
cards: list[CharacterCard] = []
for v in views:
traits = list((v.traits or {}).get("items", [])) if isinstance(v.traits, dict) else []
tics = (
list((v.speech_tics or {}).get("items", [])) if isinstance(v.speech_tics, dict) else []
)
arc = (v.arc or {}).get("text", "") if isinstance(v.arc, dict) else ""
cards.append(
CharacterCard(
name=v.name,
role=v.role or "",
traits=traits,
backstory=v.backstory or "",
arc=arc or "",
speech_tics=tics,
tags=list(v.tags or []),
relations=[],
)
)
return cards
async def _world_context(memory: MemoryRepos, project_id: uuid.UUID) -> str:
"""已有世界观硬规则简表(喂 character-gen / precheck 真相源)。"""
views = await memory.world_entity.list_for_project(project_id)
lines: list[str] = []
for v in views:
rules = list((v.rules or {}).get("rules", [])) if isinstance(v.rules, dict) else []
rule_text = "".join(rules) if rules else "(无硬规则)"
lines.append(f"- [{v.type}] {v.name}{rule_text}")
return "\n".join(lines)
# ---- 世界观生成(预览,不入库)----
@router.post("/{project_id}/world/generate")
async def generate_world(
project_id: uuid.UUID,
body: WorldGenerateRequest,
request: Request,
project_repo: ProjectRepoDep,
gateway: WorldGatewayDep,
session: SessionDep,
) -> WorldGenPreviewResponse:
"""生成世界观实体预览(不持久化;作者确认后另入库)。无凭据 → 503项目不存在 → 404。"""
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}")
project_context = _project_context(project.title, project.genre, project.premise, project.theme)
result = await run_worldbuilder(
worldbuilder_spec,
brief=body.brief,
project_context=project_context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
# 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage否则丢失
await session.commit()
log.info(
"world_generate_done",
project_id=str(project_id),
request_id=request_id,
entity_count=len(result.entities),
)
return WorldGenPreviewResponse(
entities=[
WorldEntityCardView(type=e.type, name=e.name, rules=list(e.rules))
for e in result.entities
]
)
# ---- 角色生成(预览,不入库;群像防雷同)----
@router.post("/{project_id}/characters/generate")
async def generate_characters(
project_id: uuid.UUID,
body: CharacterGenerateRequest,
request: Request,
project_repo: ProjectRepoDep,
memory: MemoryReposDep,
gateway: CharacterGatewayDep,
session: SessionDep,
) -> CharacterGenPreviewResponse:
"""生成角色卡预览(不持久化;注入已有角色防雷同)。无凭据 → 503项目不存在 → 404。"""
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}")
existing = await _existing_characters(memory, project_id)
world_context = await _world_context(memory, project_id)
result = await run_character_gen(
character_gen_spec,
brief=body.brief,
count=body.count,
role=body.role,
world_context=world_context,
existing_chars=existing,
generated_so_far=[],
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
await session.commit()
log.info(
"characters_generate_done",
project_id=str(project_id),
request_id=request_id,
card_count=len(result.cards),
)
return CharacterGenPreviewResponse(cards=[_card_to_view(c) for c in result.cards])
# ---- 角色入库作者确认后continuity gate + 权限白名单)----
@router.post("/{project_id}/characters", status_code=201)
async def ingest_characters(
project_id: uuid.UUID,
body: CharacterIngestRequest,
request: Request,
project_repo: ProjectRepoDep,
memory: MemoryReposDep,
char_repo: CharacterWriteRepoDep,
gateway: PrecheckGatewayDep,
session: SessionDep,
) -> CharacterIngestResponse:
"""入库作者确认的角色卡。有 continuity 冲突且未确认 → 409越权写表丢弃 + 审计。"""
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}")
cards = [_view_to_card(v) for v in body.cards]
# gate 1入库前 continuity 预检(编排器追加的检查,非 character-gen 互调,守不变量 #1
world_context = await _world_context(memory, project_id)
existing = await _existing_characters(memory, project_id)
conflicts = await precheck_generated_cards(
continuity_spec,
cards=cards,
world_context=world_context,
characters_context=_render_characters_context(existing),
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
if conflicts and not body.acknowledge_conflicts:
# 仿 accept 的冲突 gate不静默入库须作者裁决/确认(不变量 #3
await session.commit() # 落 precheck 网关 usage否则丢失不写业务表。
log.info(
"characters_ingest_blocked_by_conflicts",
project_id=str(project_id),
request_id=request_id,
conflict_count=len(conflicts),
)
raise AppError(
ErrorCode.CONFLICT_UNRESOLVED,
"生成角色卡与现有设定存在 continuity 冲突,请裁决后确认入库",
{
"conflicts": [
IngestConflictView(
type=c.type, where=c.where, refs=list(c.refs), suggestion=c.suggestion
).model_dump()
for c in conflicts
],
"conflict_count": len(conflicts),
},
)
# gate 2权限白名单——character-gen 只声明 writes=["characters"],越权产出丢弃 + 审计。
allowed, rejected = partition_writes(character_gen_spec, {"characters": cards})
if rejected:
log.warning(
"characters_ingest_rejected_over_permission",
project_id=str(project_id),
request_id=request_id,
rejected_tables=rejected,
)
created: list[str] = []
for card in allowed.get("characters", []):
view = await char_repo.create(
project_id,
name=card.name,
role=card.role,
traits=list(card.traits),
backstory=card.backstory,
arc=card.arc,
speech_tics=list(card.speech_tics),
tags=list(card.tags),
relations=[r.model_dump() for r in card.relations],
)
created.append(view.name)
# 提交边界:网关 ledgerprecheck+ 角色写侧均只 flush → 端点末尾一次 commit。
await session.commit()
log.info(
"characters_ingest_done",
project_id=str(project_id),
request_id=request_id,
created_count=len(created),
)
return CharacterIngestResponse(created=created, rejected_tables=rejected)
# ---- 读端点:规则列表 + 技能库T5.6 前端)----
@router.get("/{project_id}/characters")
async def list_characters(
project_id: uuid.UUID,
memory: MemoryReposDep,
) -> CharacterListResponse:
"""已入库角色全量列表(设定库 Codex 真源;复用 C5 读侧 SqlCharacterRepo
DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 `_existing_characters`)。
"""
cards = await _existing_characters(memory, project_id)
return CharacterListResponse(characters=[_card_to_view(c) for c in cards])
@router.get("/{project_id}/world_entities")
async def list_world_entities(
project_id: uuid.UUID,
memory: MemoryReposDep,
) -> WorldEntityListResponse:
"""已入库世界观实体全量列表(设定库 Codex 真源;复用 C5 读侧 SqlWorldEntityRepo
DB `rules` JSONB dict `{"rules":[...]}` → 裸 listworldbuilder 形变的逆向)。
"""
views = await memory.world_entity.list_for_project(project_id)
entities = [
WorldEntityCardView(
type=v.type,
name=v.name,
rules=(list((v.rules or {}).get("rules", [])) if isinstance(v.rules, dict) else []),
)
for v in views
]
return WorldEntityListResponse(world_entities=entities)
@router.get("/{project_id}/rules")
async def list_rules(
project_id: uuid.UUID,
repo: RulesReadRepoDep,
) -> RuleListResponse:
"""规则列表(按读侧顺序)。规则页用。"""
rules = await repo.all_for_project(project_id)
return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules])
@skills_router.get("")
async def list_skills(registry: SkillRegistryDep) -> SkillListResponse:
"""技能库列表(按 name 升序)。技能库 UI 用。"""
skills = [
SkillView(
name=spec.name,
scope=spec.scope,
tier=spec.tier,
reads=list(spec.reads),
writes=list(spec.writes),
genre=spec.genre,
)
# registry.names() 已排序;按名取 spec 保证确定性顺序。
for spec in (registry.get(name) for name in registry.names())
]
return SkillListResponse(skills=skills)
# ---- schema <-> ww_agents 卡转换 ----
def _card_to_view(card: CharacterCard) -> CharacterCardView:
return CharacterCardView(
name=card.name,
role=card.role,
traits=list(card.traits),
backstory=card.backstory,
arc=card.arc,
speech_tics=list(card.speech_tics),
tags=list(card.tags),
relations=[
CharacterRelationView(name=r.name, kind=r.kind, note=r.note) for r in card.relations
],
)
def _view_to_card(view: CharacterCardView) -> CharacterCard:
return CharacterCard(
name=view.name,
role=view.role,
traits=list(view.traits),
backstory=view.backstory,
arc=view.arc,
speech_tics=list(view.speech_tics),
tags=list(view.tags),
relations=[
CharacterRelation(name=r.name, kind=r.kind, note=r.note) for r in view.relations
],
)

View File

@@ -0,0 +1,190 @@
"""Kimi Code OAuth device-flow 端点C3 扩 K1.3 / ARCH §7.2 / §7.4 jobs
订阅 plan device-flow 登录:作者在设置页点「连接 Kimi Code」→ 后端发起 device
authorization → 返回 202 `{job_id, user_code, verification_uri, ...}` → 前端展示 user_code
+ 打开授权页 + 轮询 `GET /jobs/{id}`;后端经 BackgroundTask `run_job` **后台轮询** token
端点直到授权成功(加密存 `oauth_enc` + job done或过期/拒绝job failed
三端点(挂 `kimi_oauth.router`,已注册):
- `POST /settings/providers/kimi-code/oauth/start` → 202 `OAuthStartResponse`
- `POST /settings/providers/kimi-code/oauth/disconnect` → 200 `OAuthDisconnectResponse`
- `GET /settings/providers/kimi-code/oauth/status` → 200 `OAuthStatusResponse`
**token 绝不出边界**:响应/job 结果/日志只含 user_code/connected 等非密信息access/refresh
token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
"""
from __future__ import annotations
import asyncio
from typing import Annotated, Any
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_core.domain import JobRepo
from ww_db import get_session
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.kimi_oauth import (
OAuthDisconnectResponse,
OAuthStartResponse,
OAuthStatusResponse,
)
from ww_api.services.credentials import (
AUTH_TYPE_OAUTH,
STUB_OWNER_ID,
CredentialStore,
SqlCredentialStore,
)
from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.job_runner import run_job
from ww_api.services.kimi_oauth import (
AsyncHttpClient,
AuthorizationPending,
DeviceAuth,
SlowDown,
decrypt_oauth_bundle,
encrypt_oauth_bundle,
poll_token,
start_device_authorization,
)
from ww_api.services.project_deps import get_job_repo, get_session_factory
from ww_api.services.provider_deps import get_credential_store
log = get_logger("ww.api.kimi_oauth")
router = APIRouter(prefix="/settings/providers/kimi-code/oauth", tags=["kimi-oauth"])
CredentialStoreDep = Annotated[CredentialStore, Depends(get_credential_store)]
JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
_JOB_KIND_KIMI_OAUTH = "kimi_oauth"
#: device flow 轮询安全上限(防止 work 在异常 interval 下无限循环;按 expires_in 兜底)。
_MAX_POLL_ATTEMPTS = 200
def _default_http_client() -> AsyncHttpClient:
return httpx.AsyncClient(timeout=30.0)
def _make_poll_work(device: DeviceAuth) -> Any:
"""构造后台轮询工作闭包:循环 poll token 直到成功/过期/拒绝。
`work(session)` 自建 httpx 客户端 + 凭据 store用 `run_job` 传入的独立 session
按 `interval` 轮询 token → 成功则加密存 `oauth_enc` 并返回非密 job 结果 → 过期/拒绝则
抛 AppError`run_job` 置 job failed。**token 绝不进 job 结果/日志**。
"""
async def work(session: AsyncSession) -> dict[str, Any]:
enc_key = get_settings().credential_enc_key
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()
return work
@router.post("/start", status_code=202)
async def start_oauth(
request: Request,
response: Response,
background_tasks: BackgroundTasks,
job_repo: JobRepoDep,
session: SessionDep,
session_factory: SessionFactoryDep,
http: Annotated[AsyncHttpClient, Depends(_default_http_client)],
) -> OAuthStartResponse:
"""发起 device authorization返回 202 + user_code/verification_uri后台轮询 token。
创建一行 `jobs(kind="kimi_oauth")`202 前持久化供轮询)→ 调度 BackgroundTask 后台
轮询。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`。
"""
request_id = getattr(request.state, "request_id", None)
device = await start_device_authorization(http)
job = await job_repo.create(None, _JOB_KIND_KIMI_OAUTH)
await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。
background_tasks.add_task(
run_job,
session_factory,
job.id,
_make_poll_work(device),
request_id=request_id,
)
log.info(
"kimi_oauth_started",
request_id=request_id,
job_id=str(job.id),
user_code=device.user_code,
)
response.status_code = 202
return OAuthStartResponse(
job_id=job.id,
user_code=device.user_code,
verification_uri=device.verification_uri,
verification_uri_complete=device.verification_uri_complete,
expires_in=device.expires_in,
interval=device.interval,
)
@router.post("/disconnect")
async def disconnect_oauth(
request: Request,
store: CredentialStoreDep,
) -> OAuthDisconnectResponse:
"""断开 Kimi Code删除 OAuth 凭据行token 一并消失)。"""
request_id = getattr(request.state, "request_id", None)
deleted = await store.delete_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER)
log.info("kimi_oauth_disconnected", request_id=request_id, deleted=deleted)
return OAuthDisconnectResponse(disconnected=deleted)
@router.get("/status")
async def oauth_status(store: CredentialStoreDep) -> OAuthStatusResponse:
"""连接状态:是否已连接 + access token 过期时刻(**无 token 本体**)。"""
enc_key = get_settings().credential_enc_key
cred = await store.get_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER)
if cred is None or cred.auth_type != AUTH_TYPE_OAUTH or cred.oauth_enc is None:
return OAuthStatusResponse(connected=False)
try:
token = decrypt_oauth_bundle(cred.oauth_enc, key=enc_key)
except Exception: # noqa: BLE001 — 解密失败视作未连接(不泄露原因到响应)
return OAuthStatusResponse(connected=False)
return OAuthStatusResponse(connected=True, expires_at=token.expires_at.isoformat())

View File

@@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import outliner_spec from ww_agents import outliner_spec
from ww_core.domain import ForeshadowLedgerRepo, OutlineWriteRepo, ProjectRepo from ww_core.domain import ForeshadowLedgerRepo, OutlineWriteRepo, ProjectRepo
from ww_core.domain.repositories import MemoryRepos from ww_core.domain.repositories import MemoryRepos, OutlineRepo
from ww_core.orchestrator import run_outline from ww_core.orchestrator import run_outline
from ww_db import get_session from ww_db import get_session
from ww_llm_gateway import Gateway from ww_llm_gateway import Gateway
@@ -41,6 +41,7 @@ from ww_api.services.project_deps import (
get_foreshadow_repo, get_foreshadow_repo,
get_memory_repos, get_memory_repos,
get_outline_gateway, get_outline_gateway,
get_outline_read_repo,
get_outline_write_repo, get_outline_write_repo,
get_project_repo, get_project_repo,
) )
@@ -53,6 +54,7 @@ ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)] ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)] OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)]
OutlineGatewayDep = Annotated[Gateway, Depends(get_outline_gateway)] OutlineGatewayDep = Annotated[Gateway, Depends(get_outline_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -133,3 +135,42 @@ async def generate_outline(
chapter_count=len(chapters), chapter_count=len(chapters),
) )
return OutlineResponse(chapters=chapters) return OutlineResponse(chapters=chapters)
@router.get("/{project_id}/outline")
async def get_outline(
project_id: uuid.UUID,
request: Request,
project_repo: ProjectRepoDep,
outline_repo: OutlineReadRepoDep,
) -> OutlineResponse:
"""读取已持久化的大纲(逐章,按 chapter_no 升序)。
项目不存在 → 404项目存在但尚无大纲 → 200 空列表(非 404页面初次访问的常态
DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形,
前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。
"""
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 outline_repo.list_for_project(project_id)
chapters = [
OutlineChapterView(
no=view.chapter_no,
volume=view.volume,
beats=list(view.beats.get("beats", [])),
foreshadow_windows=[ForeshadowWindowView(**w) for w in view.foreshadow_windows],
)
for view in views
]
log.info(
"outline_read",
project_id=str(project_id),
request_id=request_id,
chapter_count=len(chapters),
)
return OutlineResponse(chapters=chapters)

View File

@@ -45,6 +45,7 @@ from ww_api.schemas.projects import (
AcceptResponse, AcceptResponse,
DraftResponse, DraftResponse,
DraftSaveRequest, DraftSaveRequest,
DraftView,
ProjectCreateRequest, ProjectCreateRequest,
ProjectListResponse, ProjectListResponse,
ProjectResponse, ProjectResponse,
@@ -198,6 +199,41 @@ async def save_draft(
) )
@router.get("/{project_id}/chapters/{chapter_no}/draft")
async def get_draft(
project_id: uuid.UUID,
chapter_no: int,
repo: ChapterRepoDep,
) -> DraftView:
"""读取已保存草稿(含正文),供工作台重访时重载编辑器(镜像 GET /outline 读侧)。
复用 `chapter_repo.get_draft`(与续审 `_resolve_review_draft` 同一读 seam只读不写库。
无草稿行(含空正文)→ 404 NOT_FOUND工作台据此呈现空编辑器与其它「缺资源」端点一致
多版本时该读取固定返草稿版次DRAFT_VERSION这条可编辑工作副本与 PUT 保存的同一行。
"""
view = await repo.get_draft(project_id, chapter_no)
if view is None or not view.content.strip():
raise AppError(
ErrorCode.NOT_FOUND,
f"chapter {chapter_no} has no saved draft",
)
log.info(
"draft_read",
project_id=str(project_id),
chapter_no=chapter_no,
length=len(view.content),
)
return DraftView(
project_id=view.project_id,
chapter_no=view.chapter_no,
volume=view.volume,
status=view.status,
version=view.version,
content=view.content,
length=len(view.content),
)
async def _resolve_review_draft( async def _resolve_review_draft(
body: ReviewRequest, body: ReviewRequest,
chapter_repo: ChapterRepo, chapter_repo: ChapterRepo,

View File

@@ -0,0 +1,53 @@
"""规则端点C3 扩 / PRODUCT_SPEC §7 `POST /projects/:id/rules`;不变量 #3
POST /projects/:id/rules作者显式加规则——审稿发现的问题/亮点随手沉淀为项目规则。
写一行 `rules`level + content喂给 assemble 的四级合并(`merge_rules`)。
加规则是**作者显式动作**(不变量 #3规则入库不经 AI 静默写库)。`level` 合法性由
`RuleCreateRequest` 的 Literal 校验(非法 → FastAPI 422
提交边界:`RuleWriteRepo.create` 只 `flush()`,端点写后 `await session.commit()`
(仿 foreshadow/outline 写侧,见 memory/gotchas
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import RuleWriteRepo
from ww_db import get_session
from ww_api.logging_config import get_logger
from ww_api.schemas.rules import RuleCreateRequest, RuleView
from ww_api.services.project_deps import get_rule_write_repo
log = get_logger("ww.api.rules")
router = APIRouter(prefix="/projects", tags=["rules"])
RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.post("/{project_id}/rules", status_code=201)
async def create_rule(
project_id: uuid.UUID,
body: RuleCreateRequest,
request: Request,
repo: RuleWriteRepoDep,
session: SessionDep,
) -> RuleView:
"""新增一条规则201。非法 level / 空 content → FastAPI 422。"""
request_id = getattr(request.state, "request_id", None)
view = await repo.create(project_id, level=body.level, content=body.content)
await session.commit()
log.info(
"rule_created",
project_id=str(project_id),
request_id=request_id,
level=body.level,
)
return RuleView(level=view.level, content=view.content)

View File

@@ -0,0 +1,225 @@
"""文风端点:学文风(异步长任务)+ 回炉 + 最新指纹读取
C3 扩 / ARCH §5.4 style-auditor 提取轨 / §7.2 / §7.4 jobs / UX §6.9 / §8.3)。
独立 router仿 outline.py挂 `app.include_router`。三端点:
- `POST /projects/{id}/style` → 202 `{job_id}`:学文风走 jobsM4-c。立即写一行 job
返 202提取经 BackgroundTask `run_job` 跑(**自建独立 session**,请求 session 已关闭)。
无凭据 → 流前 `LLM_UNAVAILABLE`503在调度 job 之前拦下,避免凭空写注定失败的 job
- `POST /projects/{id}/chapters/{no}/refine` → 200 `{original, refined}`同步回炉writer
网关纯文本重写选中段;不写库(不变量 #3作者采纳经既有 draft 自动保存合入);末尾
`commit()` 让 usage_ledger 落库(网关 ledger add-only同 draft/review 纪律)。
- `GET /projects/{id}/style` → 200 最新指纹(完整 16 维 + 证据 + 版本UX §6.9)。
不变量 #2经 `get_*_gateway`analyst/writer 档注入spec 只声明档位不传 model。
不变量 #3提取/回炉 agent 只读不写其它表;文风指纹落库经此作者发起的学文风路径。
"""
from __future__ import annotations
import uuid
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_core.domain import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo
from ww_core.orchestrator import run_style_extraction
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.style import (
RefineRequest,
RefineResponse,
StyleFingerprintResponse,
StyleLearnRequest,
StyleLearnResponse,
)
from ww_api.services.credentials import STUB_OWNER_ID, SqlCredentialStore
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_job_repo,
get_project_repo,
get_refine_gateway,
get_session_factory,
get_style_extract_gateway,
get_style_write_repo,
)
log = get_logger("ww.api.style")
router = APIRouter(prefix="/projects", tags=["style"])
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
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)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
_JOB_KIND_STYLE_LEARN = "style_learn"
def _split_fingerprint(result: Any) -> tuple[dict[str, str], dict[str, list[str]]]:
"""把 `StyleFingerprintResult` 拆成 DB 两列:`{name:value}` + `{name:[evidence]}`。"""
dimensions = {dim.name: dim.value for dim in result.dimensions}
evidence = {dim.name: list(dim.evidence) for dim in result.dimensions}
return dimensions, evidence
def _make_style_learn_work(project_id: uuid.UUID, samples_text: str) -> Any:
"""构造学文风后台工作闭包:在 `run_job` 自建的独立 session 上跑提取 + 落库。
`work(session)` 自建 analyst 网关(从凭据)+ 写侧 repo用 `run_job` 传入的独立
session→ `run_style_extraction` → 拆指纹 → `append`(版本化)→ 返回 result 摘要
`{version, dims_count}`。提交归 `run_job`(业务写 + job done 同一事务一次 commit
"""
async def work(session: AsyncSession) -> dict[str, Any]:
store = SqlCredentialStore(session)
gateway = await build_gateway_for_tier(session, store, "analyst")
result = await run_style_extraction(
style_extract_spec,
samples_text=samples_text,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
dimensions, evidence = _split_fingerprint(result)
repo = SqlStyleFingerprintWriteRepo(session)
version = await repo.append(project_id, dimensions_json=dimensions, evidence_json=evidence)
return {"version": version, "dims_count": len(dimensions)}
return work
@router.post("/{project_id}/style", status_code=202)
async def learn_style(
project_id: uuid.UUID,
body: StyleLearnRequest,
request: Request,
response: Response,
background_tasks: BackgroundTasks,
project_repo: ProjectRepoDep,
job_repo: JobRepoDep,
gateway: StyleExtractGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503
session: SessionDep,
session_factory: SessionFactoryDep,
) -> StyleLearnResponse:
"""学文风:写一行 job 返 202提取经 BackgroundTask 异步跑。
项目不存在 → 404无凭据 → 503在调度 job 前拦下)。`mode` 不影响落库逻辑
(写侧始终 append 新版本version+1 自动),仅供前端区分首学/更新语义。
"""
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}")
# `gateway` 已在 dep 解析阶段验过凭据(无凭据 → 503提取本体在 BackgroundTask
# 里用独立 session 重新构网关跑,此处不复用它(请求 session 即将关闭)。
samples_text = "\n\n".join(body.samples)
job = await job_repo.create(project_id, _JOB_KIND_STYLE_LEARN)
await session.commit() # job 行需在 202 返回前持久化(供前端立即轮询)。
background_tasks.add_task(
run_job,
session_factory,
job.id,
_make_style_learn_work(project_id, samples_text),
request_id=request_id,
)
log.info(
"style_learn_scheduled",
project_id=str(project_id),
request_id=request_id,
job_id=str(job.id),
sample_count=len(body.samples),
mode=body.mode,
)
response.status_code = 202
return StyleLearnResponse(job_id=job.id)
@router.get("/{project_id}/style")
async def get_style(
project_id: uuid.UUID,
project_repo: ProjectRepoDep,
style_repo: StyleWriteRepoDep,
) -> StyleFingerprintResponse:
"""取最新文风指纹(完整 16 维 + 证据 + 版本)。项目不存在 → 404无指纹 → 404。"""
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}")
latest = await style_repo.latest(project_id)
if latest is None:
raise AppError(ErrorCode.NOT_FOUND, f"no style fingerprint for project: {project_id}")
return StyleFingerprintResponse(
dimensions=latest.dimensions,
evidence=latest.evidence,
version=latest.version,
)
def _build_refine_input(segment: str, instruction: str | None) -> str:
"""组回炉输入:待重写段 + 可选指令(保留上下文语气,只重写该段)。"""
parts = [f"【待重写段落】\n{segment}"]
if instruction:
parts.append(f"【改写指令】\n{instruction}")
return "\n\n".join(parts)
@router.post("/{project_id}/chapters/{chapter_no}/refine")
async def refine_segment(
project_id: uuid.UUID,
chapter_no: int,
body: RefineRequest,
request: Request,
project_repo: ProjectRepoDep,
gateway: RefineGatewayDep,
session: SessionDep,
) -> RefineResponse:
"""同步回炉writer 网关纯文本重写选中段,返回 {original, refined}。
项目不存在 → 404无凭据 → 503。不写库不变量 #3末尾 `commit()` 让网关
usage_ledger 落库add-only否则记账静默丢失同 draft/review 纪律)。
"""
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}")
req = LlmRequest(
tier=refiner_spec.tier, # 不变量 #2writer 档,不传 model
system=[Block(text=refiner_spec.system_prompt, cache=True)],
input=_build_refine_input(body.segment, body.instruction),
output_schema=None, # refiner 纯文本(无结构化 schema
scope=Scope(user_id=STUB_OWNER_ID, project_id=project_id),
)
resp = await gateway.run(req)
# 提交边界:网关 ledger add-only → 端点末尾 commit否则记账静默丢失见 gotcha
await session.commit()
log.info(
"refine_done",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
segment_len=len(body.segment),
refined_len=len(resp.text),
has_instruction=body.instruction is not None,
)
return RefineResponse(original=body.segment, refined=resp.text)

View File

@@ -0,0 +1,163 @@
"""生成/入库端点的请求/响应 schemaC3 扩 / ARCH §6.5 / §7.2)。
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
生成走**即时返回**(预览 → 作者确认 → 入库),非 jobs 长任务M5-d
- `POST /world/generate` / `POST /characters/generate`:返回**预览**(不持久化)。
- `POST /characters`(入库):作者确认的角色卡 → 过 continuity 预检 gate + 权限白名单 → 写库。
视图字段贴 `ww_agents.WorldEntityCard`/`CharacterCard`保持生成产物形traits/speech_tics
是 list、arc 是 str入库时由写侧 repo 转 DB JSONB 形,见 character_repo
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from ww_api.schemas.rules import RuleView
# 批量生成数量上限(防一次性巨量调用;原型保守值)。
MAX_GENERATE_COUNT = 12
# ---- 世界观生成(预览)----
class WorldGenerateRequest(BaseModel):
"""POST /projects/:id/world/generate据需求生成世界观实体预览"""
brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)")
class WorldEntityCardView(BaseModel):
"""单个世界观实体卡(贴 ww_agents.WorldEntityCard预览 + 入库共用形)。"""
type: str = Field(description="实体类型(势力 / 地理 / 力量体系 / 物品 / 概念 等)")
name: str = Field(description="实体名")
rules: list[str] = Field(default_factory=list, description="该实体的硬规则清单")
class WorldGenPreviewResponse(BaseModel):
"""世界观生成预览(不持久化;作者确认后另走入库,本期入库端点为角色)。"""
entities: list[WorldEntityCardView] = Field(default_factory=list)
# ---- 角色生成(预览)----
class CharacterGenerateRequest(BaseModel):
"""POST /projects/:id/characters/generate据需求生成角色卡预览群像防雷同"""
brief: str = Field(min_length=1, description="角色生成需求")
count: int = Field(default=1, ge=1, le=MAX_GENERATE_COUNT, description="生成数量")
role: str | None = Field(default=None, description="角色定位(主角/CP/对手/导师/工具人 等)")
class CharacterRelationView(BaseModel):
"""单条人物关系(贴 ww_agents.CharacterRelation"""
name: str
kind: str
note: str | None = None
class CharacterCardView(BaseModel):
"""单张角色卡(贴 ww_agents.CharacterCard预览 + 入库请求共用形)。"""
name: str = Field(description="角色名")
role: str = Field(description="角色定位")
traits: list[str] = Field(default_factory=list, description="性格特质清单")
backstory: str = Field(description="背景故事")
arc: str = Field(description="人物弧光(一句话)")
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")
tags: list[str] = Field(default_factory=list, description="人设标签/萌点")
relations: list[CharacterRelationView] = Field(default_factory=list, description="关系网")
class CharacterGenPreviewResponse(BaseModel):
"""角色生成预览(不持久化;作者确认后经 POST /characters 入库)。"""
cards: list[CharacterCardView] = Field(default_factory=list)
# ---- 角色入库(作者确认后;过 continuity gate + 权限白名单)----
class CharacterIngestRequest(BaseModel):
"""POST /projects/:id/characters把作者确认的角色卡入库。
`acknowledge_conflicts`:作者已查看并接受预检冲突时置 `true`,越过 continuity gate
(仿 accept 的冲突裁决:不静默入库,须作者确认;默认 false → 有冲突即 409见端点
"""
cards: list[CharacterCardView] = Field(min_length=1, description="待入库的角色卡(至少 1 张)")
acknowledge_conflicts: bool = Field(
default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库"
)
class IngestConflictView(BaseModel):
"""入库预检检出的单条 continuity 冲突(贴 ww_agents.Conflict 五类)。"""
type: str
where: str
refs: list[str] = Field(default_factory=list)
suggestion: str
class CharacterIngestResponse(BaseModel):
"""角色入库结果201写入的角色 + 被白名单丢弃的越权表(审计)。"""
created: list[str] = Field(default_factory=list, description="写入的角色名(按入参顺序)")
rejected_tables: list[str] = Field(
default_factory=list, description="被权限白名单丢弃的越权写表名(审计;正常为空)"
)
# ---- 读端点:设定库 Codex角色 / 世界观全量列表)----
class CharacterListResponse(BaseModel):
"""GET /projects/:id/characters已入库角色全量列表设定库 Codex 真源)。
DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 character_repo
`{"items":[...]}`→list、`{"text":...}`→strtags/relations 直落 list。
"""
characters: list[CharacterCardView] = Field(default_factory=list)
class WorldEntityListResponse(BaseModel):
"""GET /projects/:id/world_entities已入库世界观实体全量列表设定库 Codex 真源)。
DB JSONB dict 列 `{"rules":[...]}`→裸 listworldbuilder 形变的逆向)。
"""
world_entities: list[WorldEntityCardView] = Field(default_factory=list)
# ---- 读端点:规则列表 + 技能库T5.6 前端)----
class RuleListResponse(BaseModel):
"""GET /projects/:id/rules规则列表规则页"""
rules: list[RuleView] = Field(default_factory=list)
class SkillView(BaseModel):
"""单个 skill 的声明式视图(技能库 UI"""
name: str
scope: str = Field(description="builtin / custom / community")
tier: str = Field(description="能力档位writer / analyst / light")
reads: list[str] = Field(default_factory=list)
writes: list[str] = Field(default_factory=list)
genre: str | None = None
class SkillListResponse(BaseModel):
"""GET /skills技能库列表按 name 升序)。"""
skills: list[SkillView] = Field(default_factory=list)

View File

@@ -0,0 +1,39 @@
"""Kimi Code OAuth device-flow 端点的响应 schemaC3 扩 K1.3 / ARCH §7.2 / §7.4)。
snake_case命名契约见 memory/gotchas。前端经 OpenAPI→TS 客户端消费——改字段须
`pnpm gen:api` 重生成。**响应绝不含 access/refresh token**(只 user_code + 轮询信息)。
"""
from __future__ import annotations
import uuid
from pydantic import BaseModel
class OAuthStartResponse(BaseModel):
"""device flow 启动:返回 job_id轮询 `GET /jobs/{id}`+ 用户面展示信息。
前端展示 `user_code`、打开 `verification_uri`(或 `verification_uri_complete`),并按
`interval` 轮询 job 直到 `done`(已连接)/`failed`(过期/拒绝)。**无 token**。
"""
job_id: uuid.UUID
user_code: str
verification_uri: str
verification_uri_complete: str | None = None
expires_in: int
interval: int
class OAuthStatusResponse(BaseModel):
"""连接状态:是否已连接 + access token 过期时刻ISO8601**无 token 本体**)。"""
connected: bool
expires_at: str | None = None
class OAuthDisconnectResponse(BaseModel):
"""断开:是否删到凭据行。"""
disconnected: bool

View File

@@ -59,6 +59,22 @@ class DraftResponse(BaseModel):
length: int length: int
class DraftView(BaseModel):
"""GET .../draft回灌已保存草稿含正文供工作台重载编辑器
与 PUT 的 `DraftResponse` 同元信息,**额外带 `content`**——读侧需要正文以重建编辑器,
保存侧不回灌正文故不带。`length` 仍按 content 字符数派生。
"""
project_id: uuid.UUID
chapter_no: int
volume: int
status: str
version: int
content: str
length: int
# ---- 审稿T2.5---- # ---- 审稿T2.5----

View File

@@ -0,0 +1,28 @@
"""规则端点的请求/响应 schemaC3 扩 / PRODUCT_SPEC §7 POST /rules
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
`level` ∈ global/genre/style/project四级合并优先级见 memory `merge_rules`)。
加规则是作者显式动作——审稿发现的问题/亮点随手沉淀为规则(非 AI 静默写库,不变量 #3
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
RuleLevel = Literal["global", "genre", "style", "project"]
class RuleCreateRequest(BaseModel):
"""POST /projects/:id/rules新增一条规则。"""
level: RuleLevel = Field(description="规则级别global/genre/style/project越具体越优先")
content: str = Field(min_length=1, description="规则正文")
class RuleView(BaseModel):
"""规则视图创建后回显snake_case"""
level: str
content: str

View File

@@ -0,0 +1,47 @@
"""文风学习 + 回炉端点的请求/响应 schemaC3 扩 / ARCH §7.2 / UX §6.9 / §8.3)。
snake_case命名契约见 memory/gotchas。前端经 OpenAPI→TS 客户端消费——改字段
须 `pnpm gen:api` 重生成。
"""
from __future__ import annotations
import uuid
from typing import Any, Literal
from pydantic import BaseModel, Field
class StyleLearnRequest(BaseModel):
"""学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。"""
samples: list[str] = Field(min_length=1)
mode: Literal["create", "update"] = "create"
class StyleLearnResponse(BaseModel):
"""学文风受理:返回 job_id长任务走 `GET /jobs/{id}` 轮询)。"""
job_id: uuid.UUID
class StyleFingerprintResponse(BaseModel):
"""最新文风指纹(`GET /style`):完整 16 维 + 证据 + 版本(对齐 UX §6.9)。"""
dimensions: dict[str, Any] = Field(default_factory=dict)
evidence: dict[str, Any] = Field(default_factory=dict)
version: int
class RefineRequest(BaseModel):
"""回炉:重写选中段(可选改写指令)。"""
segment: str = Field(min_length=1)
instruction: str | None = None
class RefineResponse(BaseModel):
"""回炉结果:原段 + 重写段(不写库,作者采纳经既有 draft 自动保存合入)。"""
original: str
refined: str

View File

@@ -20,12 +20,24 @@ from ww_llm_gateway.adapters.base import Capabilities
STUB_OWNER_ID = uuid.UUID(int=1) STUB_OWNER_ID = uuid.UUID(int=1)
# 凭据认证类型(`provider_credentials.auth_type`,见 C2 扩 K1.1)。
AUTH_TYPE_API_KEY = "api_key"
AUTH_TYPE_OAUTH = "oauth"
@dataclass(frozen=True) @dataclass(frozen=True)
class StoredCredential: class StoredCredential:
"""存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。""" """存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。
一行二选一:`auth_type="api_key"` → `api_key_enc` 有值、`oauth_enc=None`
`auth_type="oauth"`Kimi Code device-flowK1.3)→ `oauth_enc` 有值、`api_key_enc=None`
(持 Fernet 加密的 `{access_token,refresh_token,expires_at}` JSON 包)。
"""
provider: str provider: str
api_key_enc: bytes api_key_enc: bytes | None
auth_type: str = AUTH_TYPE_API_KEY
oauth_enc: bytes | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -51,6 +63,12 @@ class CredentialStore(Protocol):
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
) -> None: ... ) -> None: ...
async def upsert_oauth_credential(
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
) -> None: ...
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool: ...
async def upsert_routing(self, routing: StoredRouting) -> None: ... async def upsert_routing(self, routing: StoredRouting) -> None: ...
@@ -72,7 +90,15 @@ class SqlCredentialStore:
select(ProviderCredential).where(ProviderCredential.owner_id == owner_id) select(ProviderCredential).where(ProviderCredential.owner_id == owner_id)
) )
).scalars() ).scalars()
return [StoredCredential(provider=r.provider, api_key_enc=r.api_key_enc) for r in rows] return [
StoredCredential(
provider=r.provider,
api_key_enc=r.api_key_enc,
auth_type=r.auth_type,
oauth_enc=r.oauth_enc,
)
for r in rows
]
async def list_routing(self) -> list[StoredRouting]: async def list_routing(self) -> list[StoredRouting]:
rows = (await self._session.execute(select(TierRouting))).scalars() rows = (await self._session.execute(select(TierRouting))).scalars()
@@ -94,7 +120,12 @@ class SqlCredentialStore:
).scalar_one_or_none() ).scalar_one_or_none()
if row is None: if row is None:
return None return None
return StoredCredential(provider=row.provider, api_key_enc=row.api_key_enc) return StoredCredential(
provider=row.provider,
api_key_enc=row.api_key_enc,
auth_type=row.auth_type,
oauth_enc=row.oauth_enc,
)
async def upsert_credential( async def upsert_credential(
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
@@ -117,12 +148,68 @@ class SqlCredentialStore:
project_id=None, project_id=None,
provider=provider, provider=provider,
api_key_enc=api_key_enc, api_key_enc=api_key_enc,
auth_type=AUTH_TYPE_API_KEY,
oauth_enc=None,
) )
) )
else: else:
existing.api_key_enc = api_key_enc existing.api_key_enc = api_key_enc
existing.auth_type = AUTH_TYPE_API_KEY
existing.oauth_enc = None
await self._session.commit() await self._session.commit()
async def upsert_oauth_credential(
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
) -> None:
"""写/更新 OAuth 凭据行Kimi Code device-flowK1.3)。
`auth_type="oauth"`、`oauth_enc=<Fernet 加密 token 包>`、`api_key_enc=None`。
显式 read-modify-write同 `upsert_credential`:含可空 project_id 的唯一约束不能用
PG `ON CONFLICT`,见 memory/gotchas。明文 token 绝不进此层(已加密)。
"""
existing = (
await self._session.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == owner_id,
ProviderCredential.project_id.is_(None),
ProviderCredential.provider == provider,
)
)
).scalar_one_or_none()
if existing is None:
self._session.add(
ProviderCredential(
owner_id=owner_id,
project_id=None,
provider=provider,
api_key_enc=None,
auth_type=AUTH_TYPE_OAUTH,
oauth_enc=oauth_enc,
)
)
else:
existing.api_key_enc = None
existing.auth_type = AUTH_TYPE_OAUTH
existing.oauth_enc = oauth_enc
await self._session.commit()
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
"""删除凭据行OAuth disconnect / 撤销)。返回是否删到行。"""
existing = (
await self._session.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == owner_id,
ProviderCredential.project_id.is_(None),
ProviderCredential.provider == provider,
)
)
).scalar_one_or_none()
if existing is None:
return False
await self._session.delete(existing)
await self._session.commit()
return True
async def upsert_routing(self, routing: StoredRouting) -> None: async def upsert_routing(self, routing: StoredRouting) -> None:
existing = ( existing = (
await self._session.execute( await self._session.execute(

View File

@@ -0,0 +1,97 @@
"""通用长任务 runnerBackgroundTask 跑 `jobs` 表上的异步工作ARCH §7.4)。
T4.3「学文风走 jobs」复用此基建`POST /style` 立即写一行 `jobs(status=queued)` 返
202 `{job_id}`,再经 FastAPI `BackgroundTasks` 登记 `run_job(...)` 跑真正的提取工作。
**独立 session 纪律**(同 `services/foreshadow_scan.run_overdue_scan` 先例 + memory/gotchas
BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故 `run_job`
**自建新 session**(经 `session_factory`),绝不复用请求 session。
`work: Callable[[AsyncSession], Awaitable[dict]]` 是业务逻辑缝T4.3 部分应用「跑提取
→ 写 style_fingerprint → 返回 result 摘要」)。`work` 拿到的 session 与 job 状态写同一
session → 一次 `commit()` 一并落库(业务写 + job done 原子)。
可测性:`run_job` 经可注入 `session_factory`/`repo_factory` 缝——单测直接 `await` 它,
注 fake session 工厂 + fake job repo + fake work**不起后台线程、不连真 DB**),断言
成功路置 done、异常路置 failed。
"""
from __future__ import annotations
import uuid
from collections.abc import Awaitable, Callable
from typing import Any, Protocol
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.job_repo import JobView, SqlJobRepo
from ww_api.services.foreshadow_scan import SessionFactory
log = structlog.get_logger(__name__)
# 业务工作缝:拿 session 跑真正的长任务,返回写回 job.result 的摘要 dict。
JobWork = Callable[[AsyncSession], Awaitable[dict[str, Any]]]
class JobLifecycleRepo(Protocol):
"""`run_job` 对 job repo 的**最小**依赖(仅生命周期三态写)——便于注入 fake。"""
async def set_running(self, job_id: uuid.UUID) -> JobView: ...
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: ...
async def fail(self, job_id: uuid.UUID, error: str) -> JobView: ...
# repo 工厂:从新 session 造 job repo。默认建 SQL 实现;测试注 fake避免真连 DB
JobRepoFactory = Callable[[AsyncSession], JobLifecycleRepo]
def _default_repo_factory(session: AsyncSession) -> JobLifecycleRepo:
return SqlJobRepo(session)
async def run_job(
session_factory: SessionFactory,
job_id: uuid.UUID,
work: JobWork,
*,
request_id: str | None = None,
repo_factory: JobRepoFactory = _default_repo_factory,
) -> None:
"""跑一个长任务:新建独立 session → set_running → await work → complete/fail → commit。
成功:`complete(job_id, result)`status=done, progress=100, result=work 返回值)后 commit。
异常:回滚 work 的部分写 → 新 session 里 `fail(job_id, str(exc))` → commitjob 失败可见)。
任何异常都被吞(后台任务边界,不冒泡崩进程);失败置态本身再炸只记日志。
`session_factory`/`repo_factory` 是可注入缝:测试直接 await、注 fake绝不联网/起线程。
"""
try:
async with session_factory() as session:
repo = repo_factory(session)
await repo.set_running(job_id)
result = await work(session)
await repo.complete(job_id, result)
await session.commit()
log.info("job_done", job_id=str(job_id), request_id=request_id)
except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed不冒泡。
log.error("job_failed", job_id=str(job_id), request_id=request_id, error=str(exc))
await _mark_failed(session_factory, job_id, str(exc), repo_factory, request_id)
async def _mark_failed(
session_factory: SessionFactory,
job_id: uuid.UUID,
error: str,
repo_factory: JobRepoFactory,
request_id: str | None,
) -> None:
"""在一个**全新** session 里把 job 置 failed前一 session 的事务已因异常作废)。"""
try:
async with session_factory() as session:
repo = repo_factory(session)
await repo.fail(job_id, error)
await session.commit()
except Exception as exc: # noqa: BLE001 — 置失败态本身再炸只记日志,不冒泡。
log.error("job_fail_mark_failed", job_id=str(job_id), request_id=request_id, error=str(exc))

View File

@@ -0,0 +1,240 @@
"""Kimi Code OAuth device-flow 客户端K1.3 / PROGRESS K1
Kimi 订阅 plan 走 OAuth 2.0 **device authorization flow**RFC 8628
1. `start_device_authorization` → `POST .../device_authorization`,拿 `device_code` +
`user_code` + `verification_uri`(用户在浏览器授权)+ 轮询 `interval`/过期 `expires_in`。
2. `poll_token`(一次尝试,调用方按 `interval` 循环)→ `POST .../token`
grant=device_code`authorization_pending` → 继续轮询、`slow_down` → 增大间隔、
`expired_token`/`access_denied` → 停止失败;成功 → access/refresh token。
3. `refresh` → 同 token 端点grant=refresh_token换新的 access/refresh token。
**httpx 注入**:所有 HTTP 经注入的 `AsyncHttpClient` Protocol= `httpx.AsyncClient`
的 `.post` 子集)——测试注 fake**绝不联网**。
**token 不落明文**`TokenSet` 序列化为 JSON 串经 Fernet 加密入 `provider_credentials.oauth_enc`
`encrypt_oauth_bundle`/`decrypt_oauth_bundle`),明文 token 绝不进日志/响应/job 结果。
研究确认(对照 `github.com/ooojustin/opencode-kimi` `constants.ts` + `picassio/pi-kimi-coder`
- client_id `17e5f671-d194-4dfb-9706-5516cb48c098`env `KIMI_CLIENT_ID` 可覆盖)。
- device authorization **带 `scope=kimi-code`**coding-agent OAuth scopeopencode-kimi
`constants.ts` 发送之kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token
缺 coding entitlement调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`,故重新带上。
- scope 只放在 device_authorization 请求上token 交换/刷新不带 scopeOAuth device flow 惯例)。
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol
from ww_shared import AppError, ErrorCode
from ww_api.security.credentials import decrypt_api_key, encrypt_api_key
#: Kimi OAuth 端点device authorization + token
KIMI_AUTH_BASE_URL = "https://auth.kimi.com/api/oauth"
DEVICE_AUTHORIZATION_URL = f"{KIMI_AUTH_BASE_URL}/device_authorization"
TOKEN_URL = f"{KIMI_AUTH_BASE_URL}/token"
#: 默认 client_idkimi-cli 公开常量env `KIMI_CLIENT_ID` 可覆盖)。
DEFAULT_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
ENV_CLIENT_ID = "KIMI_CLIENT_ID"
GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
GRANT_REFRESH_TOKEN = "refresh_token"
#: coding-agent OAuth scopedevice authorization 专用;缺它 token 无 coding entitlement
KIMI_CODE_SCOPE = "kimi-code"
#: device flow 默认轮询间隔(秒)——服务端未给 `interval` 时的兜底。
DEFAULT_POLL_INTERVAL = 5
#: token 刷新触发缓冲(秒):剩余寿命低于 max(300, 0.5*expires_in) 即刷新。
MIN_REFRESH_BUFFER_SECONDS = 300
def client_id() -> str:
"""当前 OAuth client_idenv `KIMI_CLIENT_ID` 优先,否则公开默认值)。"""
return os.environ.get(ENV_CLIENT_ID) or DEFAULT_CLIENT_ID
class AsyncHttpClient(Protocol):
"""`run_job`/服务对 HTTP 客户端的**最小**依赖(= `httpx.AsyncClient.post` 子集)。
便于测试注 fake绝不联网。运行时传 `httpx.AsyncClient`。
"""
async def post(self, url: str, *, data: dict[str, str]) -> HttpResponse: ...
class HttpResponse(Protocol):
"""HTTP 响应的最小读接口(`httpx.Response` 满足之)。"""
status_code: int
def json(self) -> Any: ...
@dataclass(frozen=True)
class DeviceAuth:
"""device authorization 响应(用户面:展示 user_code + 打开 verification_uri"""
device_code: str
user_code: str
verification_uri: str
verification_uri_complete: str | None
expires_in: int
interval: int
@dataclass(frozen=True)
class TokenSet:
"""OAuth token 三元组access 短期 / refresh 长期 / 服务端驱动过期时刻 UTC"""
access_token: str
refresh_token: str
expires_at: datetime
class AuthorizationPending(Exception):
"""device flow 轮询:用户尚未授权(继续轮询)。"""
class SlowDown(Exception):
"""device flow 轮询:轮询过快(增大 interval 后继续)。"""
def _now() -> datetime:
return datetime.now(UTC)
def _expires_at(expires_in: int) -> datetime:
return _now() + timedelta(seconds=max(0, expires_in))
def needs_refresh(token: TokenSet, *, now: datetime | None = None) -> bool:
"""判定 access token 是否临近过期(剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS`)。
建网关时按需刷新§token 刷新启发式);过期时刻已是服务端驱动的绝对时刻,故只需
与缓冲比较(无需原始 expires_in缓冲固定 300s对 ~15min access 足够)。
"""
current = now or _now()
remaining = (token.expires_at - current).total_seconds()
return remaining < MIN_REFRESH_BUFFER_SECONDS
async def start_device_authorization(http: AsyncHttpClient) -> DeviceAuth:
"""发起 device authorization带 `scope=kimi-code` 以获取 coding entitlement"""
resp = await http.post(
DEVICE_AUTHORIZATION_URL,
data={"client_id": client_id(), "scope": KIMI_CODE_SCOPE},
)
if resp.status_code >= 400:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
"Kimi 设备授权请求失败",
{"status": resp.status_code},
)
body = resp.json()
return DeviceAuth(
device_code=str(body["device_code"]),
user_code=str(body["user_code"]),
verification_uri=str(body["verification_uri"]),
verification_uri_complete=(
str(body["verification_uri_complete"])
if body.get("verification_uri_complete")
else None
),
expires_in=int(body.get("expires_in", 0)),
interval=int(body.get("interval", DEFAULT_POLL_INTERVAL)),
)
def _token_set_from_body(body: dict[str, Any]) -> TokenSet:
return TokenSet(
access_token=str(body["access_token"]),
refresh_token=str(body["refresh_token"]),
expires_at=_expires_at(int(body.get("expires_in", 0))),
)
async def poll_token(http: AsyncHttpClient, device_code: str) -> TokenSet:
"""轮询一次 token 端点(调用方按 interval 循环)。
`authorization_pending` → 抛 `AuthorizationPending`(继续轮询);
`slow_down` → 抛 `SlowDown`(增大 interval
`expired_token`/`access_denied`/其它 → 抛 `AppError`(停止失败);
成功 → `TokenSet`。
"""
resp = await http.post(
TOKEN_URL,
data={
"grant_type": GRANT_DEVICE_CODE,
"device_code": device_code,
"client_id": client_id(),
},
)
body = resp.json()
if resp.status_code >= 400 or body.get("error"):
error = str(body.get("error", "unknown_error"))
if error == "authorization_pending":
raise AuthorizationPending
if error == "slow_down":
raise SlowDown
# expired_token / access_denied / 其它 → 终止失败。
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"Kimi 设备授权失败:{error}",
{"error": error},
)
return _token_set_from_body(body)
async def refresh(http: AsyncHttpClient, refresh_token: str) -> TokenSet:
"""用 refresh_token 换新 token 组access 临近过期时建网关触发)。"""
resp = await http.post(
TOKEN_URL,
data={
"grant_type": GRANT_REFRESH_TOKEN,
"refresh_token": refresh_token,
"client_id": client_id(),
},
)
body = resp.json()
if resp.status_code >= 400 or body.get("error"):
error = str(body.get("error", "unknown_error"))
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"Kimi token 刷新失败:{error}",
{"error": error},
)
return _token_set_from_body(body)
def encrypt_oauth_bundle(token: TokenSet, *, key: str) -> bytes:
"""把 `TokenSet` 序列化为 JSON 串并 Fernet 加密为 `oauth_enc` 密文。
JSON 形 `{access_token, refresh_token, expires_at(ISO8601)}`——明文 token 绝不出此函数。
"""
bundle = json.dumps(
{
"access_token": token.access_token,
"refresh_token": token.refresh_token,
"expires_at": token.expires_at.isoformat(),
}
)
return encrypt_api_key(bundle, key=key)
def decrypt_oauth_bundle(blob: bytes, *, key: str) -> TokenSet:
"""解密 `oauth_enc` 密文回 `TokenSet`reuse Fernet helper工作在 str 上)。"""
bundle = json.loads(decrypt_api_key(blob, key=key))
return TokenSet(
access_token=str(bundle["access_token"]),
refresh_token=str(bundle["refresh_token"]),
expires_at=datetime.fromisoformat(str(bundle["expires_at"])),
)

View File

@@ -11,40 +11,58 @@ from __future__ import annotations
from typing import Annotated from typing import Annotated
import httpx
from fastapi import Depends from fastapi import Depends
from openai import AsyncOpenAI
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings from ww_config import get_settings
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.job_repo import JobRepo, SqlJobRepo
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
from ww_core.domain.repositories import MemoryRepos from ww_core.domain.repositories import MemoryRepos, OutlineRepo, RulesRepo
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
from ww_core.memory.sql_repositories import sql_memory_repos from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo, StyleFingerprintWriteRepo
from ww_core.domain.world_entity_repo import SqlWorldEntityWriteRepo, WorldEntityWriteRepo
from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos
from ww_db import get_session, get_sessionmaker from ww_db import get_session, get_sessionmaker
from ww_db.models import User from ww_db.models import User
from ww_llm_gateway import ( from ww_llm_gateway import (
Gateway, Gateway,
OpenAICompatAdapter, ProviderAdapter,
Route,
SqlAlchemyLedgerSink, SqlAlchemyLedgerSink,
build_adapter,
chain_from_routing,
resolve_route, resolve_route,
) )
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER
from ww_llm_gateway.types import Tier from ww_llm_gateway.types import Tier
from ww_shared import AppError, ErrorCode from ww_shared import AppError, ErrorCode
from ww_skills import SkillRegistry, SqlSkillRepo
from ww_api.security.credentials import ( from ww_api.security.credentials import (
CredentialKeyError, CredentialKeyError,
decrypt_api_key, decrypt_api_key,
) )
from ww_api.services.credentials import ( from ww_api.services.credentials import (
AUTH_TYPE_OAUTH,
STUB_OWNER_ID, STUB_OWNER_ID,
CredentialStore, CredentialStore,
SqlCredentialStore, SqlCredentialStore,
StoredCredential,
) )
from ww_api.services.foreshadow_scan import SessionFactory from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.kimi_oauth import (
decrypt_oauth_bundle,
encrypt_oauth_bundle,
needs_refresh,
)
from ww_api.services.kimi_oauth import refresh as kimi_refresh
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。 # 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
@@ -105,6 +123,16 @@ def get_foreshadow_repo(
return SqlForeshadowLedgerRepo(session) return SqlForeshadowLedgerRepo(session)
def get_job_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> JobRepo:
"""长任务写侧 repo创建/进度/完成/失败;状态写只 flush提交归 run_job/端点)。
测试经 `app.dependency_overrides[get_job_repo]` 注入 fake避免真连 DB
"""
return SqlJobRepo(session)
def get_outline_write_repo( def get_outline_write_repo(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> OutlineWriteRepo: ) -> OutlineWriteRepo:
@@ -112,6 +140,90 @@ def get_outline_write_repo(
return SqlOutlineWriteRepo(session) return SqlOutlineWriteRepo(session)
def get_rule_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> RuleWriteRepo:
"""规则写侧 repoPOST /rules作者显式加规则只 flush端点提交。测试经 override 注。"""
return SqlRuleWriteRepo(session)
async def get_skill_registry(
session: Annotated[AsyncSession, Depends(get_session)],
) -> SkillRegistry:
"""从 `skills` 表加载声明式 skill registryARCH §5.6;越权声明 → VALIDATION
每请求按 session 加载registry 不可变快照)。测试经 `app.dependency_overrides` 注 fake repo
或直接注 `SkillRegistry`。技能库 UIT5.6)经此读 builtin/custom/community。
"""
return await SkillRegistry.load(SqlSkillRepo(session))
def get_style_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> StyleFingerprintWriteRepo:
"""文风指纹写侧 repo`GET /style` 读最新 + 学文风后台任务 append 版本化指纹)。
注:学文风的 `work` 在 `run_job` 自建的独立 session 上自造 repo请求 session 已关闭),
故本依赖只服务于 `GET /style` 读侧。测试经 `app.dependency_overrides` 注 fake。
"""
return SqlStyleFingerprintWriteRepo(session)
def get_character_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> CharacterWriteRepo:
"""角色写侧 repoPOST /characters 入库schema→DB 形变;只 flush端点提交
测试经 `app.dependency_overrides` 注 fake。
"""
return SqlCharacterWriteRepo(session)
def get_world_entity_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> WorldEntityWriteRepo:
"""世界观实体写侧 repo预留对称入库当前生成端点只用其形变能力"""
return SqlWorldEntityWriteRepo(session)
def get_rules_read_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> RulesRepo:
"""规则读侧 repoGET /rules 列表,复用 C5 assemble 读侧;测试经 override 注 fake"""
return SqlRulesRepo(session)
def get_outline_read_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> OutlineRepo:
"""大纲读侧 repoGET /outline 列表,复用 C5 assemble 读侧;测试经 override 注 fake"""
return SqlOutlineRepo(session)
async def get_worldbuilder_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""世界观生成writer 档位)的可注入网关缝。测试经 override 注 mock产 WorldGenResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer")
async def get_character_gen_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""角色生成writer 档位)的可注入网关缝。测试经 override 注 mock产 CharacterGenResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer")
async def get_precheck_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""入库前 continuity 预检analyst 档位)的可注入网关缝。测试注 mock产 ContinuityReview"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
def get_session_factory() -> SessionFactory: def get_session_factory() -> SessionFactory:
"""验收后到期扫描的**独立 session 工厂**缝。 """验收后到期扫描的**独立 session 工厂**缝。
@@ -122,43 +234,126 @@ def get_session_factory() -> SessionFactory:
return get_sessionmaker() return get_sessionmaker()
async def _build_provider_adapter(store: CredentialStore, provider: str) -> ProviderAdapter | None:
"""据 provider 解密凭据 → 经 `build_adapter` 工厂建对应 provider 适配器T5.4 follow-up
`build_adapter(provider, *, api_key, base_url=None)` 按 provider 选适配器类:
OpenAI 兼容deepseek/kimi/qwen/glm/openai经 `base_url` 走 OpenAI 兼容适配器;
Anthropic/Gemini 走各自原生适配器(无需 `base_url``kimi-code` 走 OAuth bearer +
coding base + 伪造头(**access token 经 `_resolve_kimi_code_token` 按需刷新**K1.3)。
返回 `None` 表示该 provider 未配置凭据——回退链里缺位时网关会跳到下一个,故宽容返回
None不直接抛
"""
cred = await store.get_credential(STUB_OWNER_ID, provider)
if cred is None:
return None
settings = get_settings()
if cred.auth_type == AUTH_TYPE_OAUTH or provider == KIMI_CODE_PROVIDER:
# OAuth 凭据Kimi Code解密 token 包 → 临近过期则刷新并持久化 → access token
# 当 api_key 喂工厂(工厂为 kimi-code 构建带伪造头 + coding base 的客户端)。
access_token = await _resolve_kimi_code_token(store, cred, settings.credential_enc_key)
return build_adapter(
provider, api_key=access_token, base_url=_PROVIDER_BASE_URLS.get(provider)
)
if cred.api_key_enc is None:
# api_key 凭据但密文缺失(数据不一致)——视作未配置,回退链跳过。
return None
try:
api_key = decrypt_api_key(cred.api_key_enc, key=settings.credential_enc_key)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
# OpenAI 兼容 provider 需 base_urlAnthropic/Gemini 走原生 SDKbase_url=None
base_url = _PROVIDER_BASE_URLS.get(provider)
return build_adapter(provider, api_key=api_key, base_url=base_url)
async def _resolve_kimi_code_token(
store: CredentialStore, cred: StoredCredential, enc_key: str
) -> str:
"""解密 Kimi Code OAuth token 包 → 临近过期时刷新并持久化 → 返回当前 access token。
刷新经一个**临时 httpx 客户端**(与 token 端点交互);新 token 包经
`store.upsert_oauth_credential` 持久化(下次建网关复用刷新结果)。明文 token 绝不进
日志/响应。无 `oauth_enc` → `LLM_UNAVAILABLE`(未连接 Kimi Code
"""
if cred.oauth_enc is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{KIMI_CODE_PROVIDER} 未连接(无 OAuth 凭据),请先在设置中连接 Kimi Code",
{"provider": KIMI_CODE_PROVIDER},
)
try:
token = decrypt_oauth_bundle(cred.oauth_enc, key=enc_key)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
if not needs_refresh(token):
return token.access_token
# 临近过期 → 刷新并持久化新包。
async with httpx.AsyncClient() as http:
refreshed = await kimi_refresh(http, token.refresh_token)
new_blob = encrypt_oauth_bundle(refreshed, key=enc_key)
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, new_blob)
return refreshed.access_token
async def build_gateway_for_tier( async def build_gateway_for_tier(
session: AsyncSession, store: CredentialStore, tier: Tier session: AsyncSession, store: CredentialStore, tier: Tier
) -> Gateway: ) -> Gateway:
"""据指定档位路由解密对应 provider 凭据 → 建网关(解析器仍为全局 `resolve_route`)。 """据指定档位路由 + DB `tier_routing.fallback` 装配**多 provider 回退链**网关T5.4 接线)。
无凭据/未知 provider → `LLM_UNAVAILABLE`(友好提示,前端引导去配置)。 流程§4.3 三级解析 / §4.5 回退链):
解析器用 `resolve_route`(按 tier 路由);这里只决定**要预备哪个 provider 的适配器**。 1. 读 DB `tier_routing` 取该 tier 的 primary `provider:model` + fallback 列表(缺则退回
全局 `resolve_route`,单 provider向后兼容
2. 为 primary + fallback 里**每个能建出适配器**的 provider 预备 OpenAI 兼容适配器
(未知 base_url / 未配凭据的 provider 跳过——回退链自然绕过它)。
3. 至少要有一个可用适配器,否则 `LLM_UNAVAILABLE`(无任何凭据可用)。
4. 注入 `chain_resolver=chain_from_routing(...)`(多元素链,启用回退);无 DB 路由时
用 `resolver=resolve_route`单路由M1 行为不变)。
单 provider 配置仍走单元素链(网关把它当无回退处理),**不破既有行为**。
""" """
settings = get_settings() ledger = SqlAlchemyLedgerSink(session)
stored = next((r for r in await store.list_routing() if r.tier == tier), None)
if stored is None:
# 无 DB 路由行:退回全局默认(单 providerM1 兼容)。
route = resolve_route(tier) route = resolve_route(tier)
base_url = _PROVIDER_BASE_URLS.get(route.provider) adapter = await _build_provider_adapter(store, route.provider)
if base_url is None: if adapter is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位 provider {route.provider} 暂不支持",
{"provider": route.provider, "tier": tier},
)
cred = await store.get_credential(STUB_OWNER_ID, route.provider)
if cred is None:
raise AppError( raise AppError(
ErrorCode.LLM_UNAVAILABLE, ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置", f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
{"provider": route.provider, "tier": tier}, {"provider": route.provider, "tier": tier},
) )
try: return Gateway(adapters={route.provider: adapter}, ledger=ledger, resolver=resolve_route)
api_key = decrypt_api_key(cred.api_key_enc, key=settings.credential_enc_key)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
client = AsyncOpenAI(api_key=api_key, base_url=base_url) # DB 路由primary + fallback 构链;为每个可建的 provider 预备适配器。
adapter = OpenAICompatAdapter(provider=route.provider, client=client) primary_spec = f"{stored.provider}:{stored.model}"
return Gateway( chain: list[Route] = chain_from_routing(tier, primary_spec, list(stored.fallback))
adapters={route.provider: adapter}, adapters: dict[str, ProviderAdapter] = {}
ledger=SqlAlchemyLedgerSink(session), for route in chain:
resolver=resolve_route, if route.provider in adapters:
continue
built = await _build_provider_adapter(store, route.provider)
if built is not None:
adapters[route.provider] = built
if not adapters:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位无任何已配置凭据的 provider请先在设置中配置",
{"providers": [r.provider for r in chain], "tier": tier},
) )
def _resolver(_tier: Tier) -> list[Route]:
return chain_from_routing(_tier, primary_spec, list(stored.fallback))
return Gateway(adapters=adapters, ledger=ledger, chain_resolver=_resolver)
async def build_writer_gateway(session: AsyncSession, store: CredentialStore) -> Gateway: async def build_writer_gateway(session: AsyncSession, store: CredentialStore) -> Gateway:
"""据 writer 档位路由解密对应 provider 凭据 → 建网关。""" """据 writer 档位路由解密对应 provider 凭据 → 建网关。"""
@@ -195,3 +390,25 @@ async def get_outline_gateway(
"""大纲生成analyst 档位)的可注入网关缝。测试经 override 注 mock产 OutlineResult""" """大纲生成analyst 档位)的可注入网关缝。测试经 override 注 mock产 OutlineResult"""
store = SqlCredentialStore(session) store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst") return await build_gateway_for_tier(session, store, "analyst")
async def get_style_extract_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""学文风提取analyst 档位)的可注入网关缝。
`POST /style` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`503
调度 job 之前拦下,避免凭空写一行注定失败的 job。提取本体在 BackgroundTask 里
用 `run_job` 自建的独立 session 重新构网关跑(请求 session 已关闭);本依赖确保
凭据探测在请求阶段发生。测试经 override 注 mock产 StyleFingerprintResult
"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
async def get_refine_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""回炉writer 档位)的可注入网关缝。测试经 override 注 mock产纯文本重写段"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer")

View File

@@ -34,6 +34,10 @@ _PROVIDER_BASE_URLS: dict[str, str] = {
"qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"glm": "https://open.bigmodel.cn/api/paas/v4", "glm": "https://open.bigmodel.cn/api/paas/v4",
"openai": "https://api.openai.com/v1", "openai": "https://api.openai.com/v1",
# Kimi 订阅 planOAuth device-flowK1.3coding 端点OpenAI 兼容 + 伪造头)。
"kimi-code": "https://api.kimi.com/coding/v1",
# Kimi 订阅 plan静态 Console KeyToS 合规):同一 coding 端点,纯 bearer 无伪造头。
"kimi-code-key": "https://api.kimi.com/coding/v1",
} }
@@ -68,6 +72,13 @@ class GatewayProviderProbe:
f"未知提供商 {provider}", f"未知提供商 {provider}",
{"provider": provider}, {"provider": provider},
) )
if cred.api_key_enc is None:
# api_key 探测不支持 OAuth 凭据(无 api_key 密文——OAuth provider 走专属端点。
raise AppError(
ErrorCode.VALIDATION,
f"provider {provider} 为 OAuth 凭据,不支持 api_key 连接测试",
{"provider": provider},
)
try: try:
api_key = decrypt_api_key(cred.api_key_enc, key=self._enc_key) api_key = decrypt_api_key(cred.api_key_enc, key=self._enc_key)
except CredentialKeyError as exc: except CredentialKeyError as exc:

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
import { ToastProvider } from "@/components/Toast"; import { ToastProvider } from "@/components/Toast";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -16,7 +17,10 @@ export default function RootLayout({
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body className="font-sans antialiased"> <body className="font-sans antialiased">
<ToastProvider>{children}</ToastProvider> <ToastProvider>
{children}
<CommandPaletteMount />
</ToastProvider>
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,42 @@
import { notFound } from "next/navigation";
import { CodexPage } from "@/components/codex/CodexPage";
import {
fetchCharacters,
fetchProject,
fetchWorldEntities,
} from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ gen?: string }>;
}
// 设定库 Codex 页UX §6.5。Server Component 取项目 + 跨会话全量人物/世界观真源;
// CodexPageClient承载生成/管理 + 本会话新入库回显。
// ?gen=character|world 由命令面板动作跳转,进页直开对应生成器 tab。
export default async function CodexRoutePage({
params,
searchParams,
}: PageProps) {
const { id } = await params;
const { gen } = await searchParams;
const initialTab = gen === "world" ? "world" : "characters";
try {
const [project, characters, worldEntities] = await Promise.all([
fetchProject(id),
fetchCharacters(id),
fetchWorldEntities(id),
]);
return (
<CodexPage
project={project}
initialCharacters={characters.characters ?? []}
initialWorldEntities={worldEntities.world_entities ?? []}
initialTab={initialTab}
/>
);
} catch {
notFound();
}
}

View File

@@ -1,19 +1,23 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { OutlineEditor } from "@/components/outline/OutlineEditor"; import { OutlineEditor } from "@/components/outline/OutlineEditor";
import { fetchProject } from "@/lib/api/server"; import { fetchOutline, fetchProject } from "@/lib/api/server";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
// 大纲编辑器页UX §6.7。Server Component 取项目OutlineEditorClient按需 // 大纲编辑器页UX §6.7。Server Component 取项目 + 已存大纲GET .../outline
// 触发 POST .../outline 生成大纲M3 无 GET 大纲端点,进页空、生成后填充)。 // 空/错误降级为空列表作为初始章节重访时显已生成的大纲OutlineEditorClient
// 仍按需触发 POST .../outline 生成/覆盖所显章节。
export default async function OutlinePage({ params }: PageProps) { export default async function OutlinePage({ params }: PageProps) {
const { id } = await params; const { id } = await params;
try { try {
const project = await fetchProject(id); const project = await fetchProject(id);
return <OutlineEditor project={project} initialChapters={[]} />; const initialChapters = await fetchOutline(id);
return (
<OutlineEditor project={project} initialChapters={initialChapters} />
);
} catch { } catch {
notFound(); notFound();
} }

View File

@@ -1,7 +1,7 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ReviewReport } from "@/components/review/ReviewReport"; import { ReviewReport } from "@/components/review/ReviewReport";
import { fetchProject, fetchReviews } from "@/lib/api/server"; import { fetchDraft, fetchProject, fetchReviews } from "@/lib/api/server";
import { latestReview } from "@/lib/review/history"; import { latestReview } from "@/lib/review/history";
interface PageProps { interface PageProps {
@@ -20,12 +20,14 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
try { try {
const project = await fetchProject(id); const project = await fetchProject(id);
const history = await fetchReviews(id, chapterNo); const history = await fetchReviews(id, chapterNo);
// 终稿初值取已存草稿GET .../draft404→null→"");否则 final_text 为空 → accept 422。
const draft = await fetchDraft(id, chapterNo);
return ( return (
<ReviewReport <ReviewReport
project={project} project={project}
chapterNo={chapterNo} chapterNo={chapterNo}
initialReview={latestReview(history.reviews)} initialReview={latestReview(history.reviews)}
initialDraft="" initialDraft={draft?.content ?? ""}
/> />
); );
} catch { } catch {

View File

@@ -0,0 +1,20 @@
import { notFound } from "next/navigation";
import { RulesPage } from "@/components/rules/RulesPage";
import { fetchProject, fetchRules } from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
}
// 规则页UX §7。Server Component 取项目 + 规则列表RulesPageClient承载新增。
export default async function RulesRoutePage({ params }: PageProps) {
const { id } = await params;
try {
const project = await fetchProject(id);
const rules = await fetchRules(id);
return <RulesPage project={project} initialRules={rules.rules ?? []} />;
} catch {
notFound();
}
}

View File

@@ -0,0 +1,20 @@
import { notFound } from "next/navigation";
import { SkillsPage } from "@/components/skills/SkillsPage";
import { fetchProject, fetchSkills } from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
}
// 技能库页UX §7。Server Component 取项目 + 技能注册表(只读)。
export default async function SkillsRoutePage({ params }: PageProps) {
const { id } = await params;
try {
const project = await fetchProject(id);
const skills = await fetchSkills();
return <SkillsPage project={project} skills={skills.skills ?? []} />;
} catch {
notFound();
}
}

View File

@@ -0,0 +1,27 @@
import { notFound } from "next/navigation";
import { StylePage } from "@/components/style/StylePage";
import { fetchProject, fetchStyleFingerprint } from "@/lib/api/server";
import { normalizeFingerprint } from "@/lib/style/style";
interface PageProps {
params: Promise<{ id: string }>;
}
// 文风页UX §6.9。Server Component 取项目 + 最新指纹GET /style无则 null
// StylePageClient承载上传/学文风/指纹展示交互。
export default async function StyleRoutePage({ params }: PageProps) {
const { id } = await params;
try {
const project = await fetchProject(id);
const fingerprint = await fetchStyleFingerprint(id);
return (
<StylePage
project={project}
initialFingerprint={normalizeFingerprint(fingerprint ?? undefined)}
/>
);
} catch {
notFound();
}
}

View File

@@ -1,19 +1,36 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Workbench } from "@/components/workbench/Workbench"; import { Workbench } from "@/components/workbench/Workbench";
import { fetchProject } from "@/lib/api/server"; import { fetchDraft, fetchProject } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
// 写作工作台页UX §6.3。Server Component 取项目Workbench 负责编辑/流式/保存。 // 写作工作台页UX §6.3。Server Component 取项目 + 当前章已存草稿GET .../draft
// 404→null=空编辑器把正文作为编辑器初值种入重访时重载已写章节Workbench
// 负责编辑/流式/保存(流式照常覆盖初值)。
export default async function WritePage({ params }: PageProps) { export default async function WritePage({ params }: PageProps) {
const { id } = await params; const { id } = await params;
// 仅当项目确实取不到时才判 404草稿/瞬时错误不应把整页变成「找不到页面」。
let project: ProjectResponse;
try { try {
const project = await fetchProject(id); project = await fetchProject(id);
return <Workbench project={project} />;
} catch { } catch {
notFound(); notFound();
} }
// 草稿读取失败(后端瞬时错误 / 尚无草稿)→ 空编辑器初值,绝不 404。
let initialText = "";
try {
const draft = await fetchDraft(id, WORKBENCH_CHAPTER_NO);
initialText = draft?.content ?? "";
} catch {
initialText = "";
}
return <Workbench project={project} initialText={initialText} />;
} }

View File

@@ -1,14 +1,26 @@
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { ProvidersSettings } from "@/components/settings/ProvidersSettings"; import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
import { fetchProviders } from "@/lib/api/server"; import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server";
import type { ProvidersResponse } from "@/lib/api/types"; import type { ProvidersResponse } from "@/lib/api/types";
// 模型与提供商设置UX §6.10。Server Component 取初始数据。 // 模型与提供商设置UX §6.10。Server Component 取初始数据(含 Kimi Code OAuth 状态)
export default async function ProvidersSettingsPage() { export default async function ProvidersSettingsPage() {
let initial: ProvidersResponse = { providers: [], tier_routing: [] }; let initial: ProvidersResponse = { providers: [], tier_routing: [] };
let kimiOauth = { connected: false, expiresAt: null as string | null };
let loadError = false; let loadError = false;
try { try {
initial = await fetchProviders(); const [providers, oauthStatus] = await Promise.all([
fetchProviders(),
fetchKimiOauthStatus(),
]);
initial = providers;
kimiOauth = {
connected: oauthStatus.connected === true,
expiresAt:
typeof oauthStatus.expires_at === "string"
? oauthStatus.expires_at
: null,
};
} catch { } catch {
loadError = true; loadError = true;
} }
@@ -21,7 +33,7 @@ export default async function ProvidersSettingsPage() {
API API
</p> </p>
) : ( ) : (
<ProvidersSettings initial={initial} /> <ProvidersSettings initial={initial} kimiOauth={kimiOauth} />
)} )}
</div> </div>
</AppShell> </AppShell>

View File

@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { LeftNav } from "./LeftNav"; import { LeftNav, type ActiveNav } from "./LeftNav";
interface AppShellProps { interface AppShellProps {
children: ReactNode; children: ReactNode;
@@ -11,7 +11,7 @@ interface AppShellProps {
// 项目内页给出 projectId → 左导航展开项目级入口(大纲/伏笔/审稿UX §3.1)。 // 项目内页给出 projectId → 左导航展开项目级入口(大纲/伏笔/审稿UX §3.1)。
projectId?: string; projectId?: string;
// 当前激活的项目级入口键(用于高亮)。 // 当前激活的项目级入口键(用于高亮)。
activeNav?: "write" | "outline" | "foreshadow" | "review"; activeNav?: ActiveNav;
} }
// 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。 // 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。

View File

@@ -3,7 +3,15 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
export type ActiveNav = "write" | "outline" | "foreshadow" | "review"; export type ActiveNav =
| "write"
| "outline"
| "foreshadow"
| "review"
| "style"
| "codex"
| "rules"
| "skills";
interface LeftNavProps { interface LeftNavProps {
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿UX §3.1)。 // 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿UX §3.1)。
@@ -50,8 +58,30 @@ function projectItems(projectId: string): NavItem[] {
enabled: true, enabled: true,
key: "review", key: "review",
}, },
{ href: "#codex", label: "设定库", enabled: false }, {
{ href: "#style", label: "文风", enabled: false }, href: `/projects/${projectId}/style`,
label: "文风",
enabled: true,
key: "style",
},
{
href: `/projects/${projectId}/codex`,
label: "设定库",
enabled: true,
key: "codex",
},
{
href: `/projects/${projectId}/rules`,
label: "规则",
enabled: true,
key: "rules",
},
{
href: `/projects/${projectId}/skills`,
label: "技能库",
enabled: true,
key: "skills",
},
]; ];
} }

View File

@@ -0,0 +1,167 @@
"use client";
import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { CharacterGenerator } from "@/components/generation/CharacterGenerator";
import { WorldGenerator } from "@/components/generation/WorldGenerator";
import type {
CharacterCardView,
ProjectResponse,
WorldEntityCardView,
} from "@/lib/api/types";
import {
mergeCharacterCards,
worldEntityRules,
} from "@/lib/generation/cards";
type CodexTab = "characters" | "world" | "timeline";
interface CodexPageProps {
project: ProjectResponse;
// 后端读端点GET .../characters / .../world_entities拉来的跨会话全量真源。
initialCharacters: CharacterCardView[];
initialWorldEntities: WorldEntityCardView[];
// 进页可经 ?gen=character|world 直接打开对应生成器(命令面板动作跳转)。
initialTab?: CodexTab;
}
const TABS: { key: CodexTab; label: string }[] = [
{ key: "characters", label: "人物" },
{ key: "world", label: "世界观" },
{ key: "timeline", label: "时间线" },
];
// 设定库 CodexUX §6.5):管理人物 / 世界观 / 时间线。
// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显,
// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards
// 世界观本期无入库端点(仅生成预览)→ 展示真源 + 生成器预览。时间线为 P2/派生,暂占位。
export function CodexPage({
project,
initialCharacters,
initialWorldEntities,
initialTab = "characters",
}: CodexPageProps) {
const [tab, setTab] = useState<CodexTab>(initialTab);
// 本会话内新入库的角色(即时回显,无需刷新即见)。
const [sessionCards, setSessionCards] = useState<CharacterCardView[]>([]);
// 真源(初始全量)+ 本会话新卡,按 name 去重合并。
const characters = useMemo(
() => mergeCharacterCards(initialCharacters, sessionCards),
[initialCharacters, sessionCards],
);
return (
<AppShell
title={`${project.title}`}
subtitle="设定库"
projectId={project.id}
activeNav="codex"
>
<div className="flex h-[calc(100vh-4rem)] flex-col p-4">
<div className="mb-4 flex items-center gap-2" role="tablist">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
onClick={() => setTab(t.key)}
className={`rounded px-3 py-1.5 text-sm ${
tab === t.key
? "border-b-2 border-cinnabar text-cinnabar"
: "text-ink-soft hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-auto">
{tab === "characters" ? (
<div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3">
<h3 className="mb-2 font-serif text-sm text-ink">
{characters.length}
</h3>
{characters.length > 0 ? (
<ul className="flex flex-wrap gap-2">
{characters.map((c, i) => (
<li
key={`${c.name}-${i}`}
className="rounded bg-bg px-2 py-1 text-xs text-ink-soft"
>
{c.name}{c.role}
</li>
))}
</ul>
) : (
<p className="text-xs text-ink-soft">
</p>
)}
</section>
<CharacterGenerator
projectId={project.id}
onIngested={(_, cards) =>
setSessionCards((prev) => [...prev, ...cards])
}
/>
</div>
) : null}
{tab === "world" ? (
<div className="flex flex-col gap-4">
<section className="rounded border border-line bg-panel p-3">
<h3 className="mb-2 font-serif text-sm text-ink">
{initialWorldEntities.length}
</h3>
{initialWorldEntities.length > 0 ? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{initialWorldEntities.map((entity, i) => (
<article
key={`${entity.name}-${i}`}
className="rounded border border-line bg-bg p-3 text-sm"
>
<header className="mb-2 flex items-center gap-2">
<span className="font-serif text-base text-ink">
{entity.name}
</span>
<span className="rounded bg-panel px-2 py-0.5 text-xs text-ink-soft">
{entity.type}
</span>
</header>
{worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
{worldEntityRules(entity).map((rule, j) => (
<li key={j}>{rule}</li>
))}
</ul>
) : (
<p className="text-ink-soft"></p>
)}
</article>
))}
</div>
) : (
<p className="text-xs text-ink-soft">
</p>
)}
</section>
<WorldGenerator projectId={project.id} />
</div>
) : null}
{tab === "timeline" ? (
<div className="rounded border border-dashed border-line bg-panel p-6 text-center text-sm text-ink-soft">
线P2 +
</div>
) : null}
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
filterCommands,
globalCommands,
moveHighlight,
projectCommands,
type Command,
} from "@/lib/command/palette";
// 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null {
const m = pathname.match(/^\/projects\/([^/]+)/);
return m ? m[1] : null;
}
interface CommandPaletteProps {
pathname: string;
}
// 命令面板⌘KUX §7键盘触发的快速导航/动作。
// 焦点管理打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11yrole=dialog/listbox
export function CommandPalette({ pathname }: CommandPaletteProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const projectId = projectIdFromPath(pathname);
const commands = useMemo<Command[]>(
() =>
projectId
? [...projectCommands(projectId), ...globalCommands()]
: globalCommands(),
[projectId],
);
const results = useMemo(
() => filterCommands(commands, query),
[commands, query],
);
const close = useCallback((): void => {
setOpen(false);
setQuery("");
setHighlight(0);
}, []);
const run = useCallback(
(cmd: Command | undefined): void => {
if (!cmd) return;
close();
if (cmd.href) router.push(cmd.href);
},
[close, router],
);
// ⌘K / Ctrl+K 全局开关。
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// 打开时聚焦输入框;切查询重置高亮。
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
setHighlight(0);
}, [query]);
if (!open) return null;
const onInputKey = (e: React.KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => moveHighlight(h, 1, results.length));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => moveHighlight(h, -1, results.length));
} else if (e.key === "Enter") {
e.preventDefault();
run(results[highlight]);
}
};
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 pt-[15vh] motion-safe:transition-opacity"
onClick={close}
>
<div
role="dialog"
aria-modal="true"
aria-label="命令面板"
className="w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-paper"
onClick={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
aria-label="命令搜索"
aria-controls="command-list"
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none"
/>
<ul
id="command-list"
role="listbox"
aria-label="命令列表"
className="max-h-80 overflow-auto py-1"
>
{results.length === 0 ? (
<li className="px-4 py-3 text-sm text-ink-soft"></li>
) : (
results.map((cmd, i) => (
<li
key={cmd.id}
role="option"
aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)}
onClick={() => run(cmd)}
className={`flex cursor-pointer items-center justify-between px-4 py-2 text-sm ${
i === highlight
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink"
}`}
>
<span>{cmd.title}</span>
<span className="text-xs text-ink-soft">{cmd.group}</span>
</li>
))
)}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { usePathname } from "next/navigation";
import { CommandPalette } from "./CommandPalette";
// 全局挂载命令面板⌘K读当前 pathname 注入项目上下文。
// 放在 RootLayout 内,对所有页面生效。
export function CommandPaletteMount() {
const pathname = usePathname();
return <CommandPalette pathname={pathname ?? "/"} />;
}

View File

@@ -0,0 +1,97 @@
"use client";
import type { CharacterCardView } from "@/lib/api/types";
interface CharacterCardItemProps {
card: CharacterCardView;
selected: boolean;
onToggle: () => void;
// 入库前作者就地编辑名/弧光(最小可编辑面,避免重型表单)。
onEdit?: (patch: Partial<CharacterCardView>) => void;
}
// 单张角色预览卡UX §6.6):勾选入库 + 关键字段就地编辑。
export function CharacterCardItem({
card,
selected,
onToggle,
onEdit,
}: CharacterCardItemProps) {
return (
<article
className={`rounded border p-3 text-sm ${
selected ? "border-cinnabar bg-[var(--color-cinnabar-wash)]" : "border-line bg-panel"
}`}
>
<header className="mb-2 flex items-center gap-2">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
aria-label={`选择角色 ${card.name}`}
className="size-4 accent-cinnabar"
/>
{onEdit ? (
<input
value={card.name}
onChange={(e) => onEdit({ name: e.target.value })}
aria-label="角色名"
className="flex-1 rounded border border-line bg-bg px-2 py-1 font-serif text-base text-ink"
/>
) : (
<span className="flex-1 font-serif text-base text-ink">{card.name}</span>
)}
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{card.role}
</span>
</header>
{card.traits && card.traits.length > 0 ? (
<p className="mb-1 text-ink-soft">
<span className="text-ink"></span>
{card.traits.join("、")}
</p>
) : null}
<p className="mb-1 text-ink-soft">
<span className="text-ink"></span>
{card.backstory}
</p>
<div className="mb-1 flex items-start gap-1 text-ink-soft">
<span className="shrink-0 text-ink"></span>
{onEdit ? (
<input
value={card.arc}
onChange={(e) => onEdit({ arc: e.target.value })}
aria-label="人物弧光"
className="flex-1 rounded border border-line bg-bg px-2 py-1 text-ink"
/>
) : (
<span>{card.arc}</span>
)}
</div>
{card.speech_tics && card.speech_tics.length > 0 ? (
<p className="mb-1 text-ink-soft">
<span className="text-ink"></span>
{card.speech_tics.join("、")}
</p>
) : null}
{card.relations && card.relations.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1">
{card.relations.map((r, i) => (
<li
key={`${r.name}-${i}`}
className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft"
>
{r.kind}{r.name}
{r.note ? `${r.note}` : ""}
</li>
))}
</ul>
) : null}
</article>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CharacterCardView } from "@/lib/api/types";
import {
MAX_CHARACTER_COUNT,
MIN_CHARACTER_COUNT,
updateCard,
} from "@/lib/generation/cards";
import { useCharacterGen } from "@/lib/generation/useCharacterGen";
import { CharacterCardItem } from "./CharacterCardItem";
import { ConflictAdjudication } from "./ConflictAdjudication";
interface CharacterGeneratorProps {
projectId: string;
// 入库成功后回调(如刷新 Codex 本地列表)。
onIngested?: (created: string[], cards: CharacterCardView[]) => void;
}
// 角色生成器UX §6.6):一句话需求 + 数量 + 定位 → 预览卡 → 选/改 → 入库(处理 409 裁决)。
export function CharacterGenerator({
projectId,
onIngested,
}: CharacterGeneratorProps) {
const gen = useCharacterGen();
const [brief, setBrief] = useState("");
const [count, setCount] = useState(MIN_CHARACTER_COUNT);
const [role, setRole] = useState("");
// 编辑态卡片(预览返回后拷贝进本地,允许就地改)。
const [drafts, setDrafts] = useState<CharacterCardView[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
// 预览返回 → 同步进本地编辑态 + 默认全选。
const syncDrafts = useCallback((cards: CharacterCardView[]) => {
setDrafts(cards);
setSelected(new Set(cards.map((_, i) => i)));
}, []);
const onGenerate = useCallback(async () => {
await gen.generate({ projectId, brief, count, role });
}, [gen, projectId, brief, count, role]);
// gen.cards 变化(新预览)→ 重新同步本地草稿。
const previewKey = gen.cards.map((c) => c.name).join("|");
useEffect(() => {
if (gen.genStatus === "preview") syncDrafts(gen.cards);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewKey, gen.genStatus]);
const toggle = (i: number): void => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
});
};
const selectedCards = useMemo(
() => drafts.filter((_, i) => selected.has(i)),
[drafts, selected],
);
const doIngest = useCallback(
async (acknowledge: boolean) => {
const ok = await gen.ingest(projectId, selectedCards, acknowledge);
if (ok) {
onIngested?.(gen.created, selectedCards);
setDrafts([]);
setSelected(new Set());
}
},
[gen, projectId, selectedCards, onIngested],
);
const generating = gen.genStatus === "generating";
const ingesting = gen.ingestStatus === "ingesting";
return (
<section className="flex flex-col gap-4" aria-label="角色生成器">
<div className="rounded border border-line bg-panel p-4">
<h2 className="mb-3 font-serif text-base text-ink"></h2>
<label className="mb-2 block text-sm text-ink-soft">
<input
value={brief}
onChange={(e) => setBrief(e.target.value)}
placeholder="如:一个亦正亦邪的剑修,背负灭门血仇"
className="mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/>
</label>
<div className="flex flex-wrap items-end gap-3">
<label className="text-sm text-ink-soft">
<input
type="number"
min={MIN_CHARACTER_COUNT}
max={MAX_CHARACTER_COUNT}
value={count}
onChange={(e) => setCount(Number(e.target.value))}
className="mt-1 block w-20 rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
/>
</label>
<label className="flex-1 text-sm text-ink-soft">
<input
value={role}
onChange={(e) => setRole(e.target.value)}
placeholder="主角 / CP / 对手 / 导师 / 工具人"
className="mt-1 block w-full rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
/>
</label>
<button
type="button"
onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0}
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "生成中…" : "生成"}
</button>
</div>
</div>
{gen.genStatus === "preview" && drafts.length > 0 ? (
<div className="flex flex-col gap-3">
<p className="text-xs text-ink-soft">
{drafts.length} {selectedCards.length} /
</p>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{drafts.map((card, i) => (
<CharacterCardItem
key={i}
card={card}
selected={selected.has(i)}
onToggle={() => toggle(i)}
onEdit={(patch) =>
setDrafts((prev) =>
prev.map((c, j) => (j === i ? updateCard(c, patch) : c)),
)
}
/>
))}
</div>
{gen.ingestStatus === "conflict" && gen.conflicts ? (
<ConflictAdjudication
conflicts={gen.conflicts}
busy={ingesting}
onAcknowledge={() => void doIngest(true)}
onCancel={() => gen.reset()}
/>
) : (
<button
type="button"
onClick={() => void doIngest(false)}
disabled={ingesting || selectedCards.length === 0}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{ingesting ? "入库中…" : `入库选中 ${selectedCards.length}`}
</button>
)}
</div>
) : null}
{gen.ingestStatus === "done" && gen.created.length > 0 ? (
<p className="text-sm text-pass">{gen.created.join("、")}</p>
) : null}
</section>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import type { IngestConflicts } from "@/lib/generation/cards";
interface ConflictAdjudicationProps {
conflicts: IngestConflicts;
busy: boolean;
// 作者已查看冲突,确认带 acknowledge 重发入库。
onAcknowledge: () => void;
onCancel: () => void;
}
// 入库 409 CONFLICT_UNRESOLVED 裁决面板UX §7 / 不变量#3
// 展示 continuity 预检冲突 → 作者裁决(确认入库 = acknowledge 重发 / 取消回去改卡)。
export function ConflictAdjudication({
conflicts,
busy,
onAcknowledge,
onCancel,
}: ConflictAdjudicationProps) {
return (
<div
role="alertdialog"
aria-label="continuity 冲突裁决"
className="rounded border border-conflict bg-panel p-4"
>
<h3 className="mb-1 font-serif text-base text-conflict">
{conflicts.conflictCount}
</h3>
<p className="mb-3 text-xs text-ink-soft">
</p>
<ul className="mb-3 flex flex-col gap-2">
{conflicts.conflicts.map((c, i) => (
<li key={i} className="rounded border border-line bg-bg p-2 text-sm">
<div className="mb-1 flex items-center gap-2">
<span className="rounded bg-conflict/10 px-2 py-0.5 text-xs text-conflict">
{c.type}
</span>
{c.where ? (
<span className="text-xs text-ink-soft">{c.where}</span>
) : null}
</div>
{c.refs.length > 0 ? (
<p className="mb-1 text-xs text-ink-soft">
{c.refs.join("、")}
</p>
) : null}
{c.suggestion ? (
<p className="text-ink">{c.suggestion}</p>
) : null}
</li>
))}
</ul>
<div className="flex gap-2">
<button
type="button"
onClick={onAcknowledge}
disabled={busy}
className="rounded bg-conflict px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
</button>
<button
type="button"
onClick={onCancel}
disabled={busy}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-50"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useCallback, useState } from "react";
import { worldEntityRules } from "@/lib/generation/cards";
import { useWorldGen } from "@/lib/generation/useWorldGen";
interface WorldGeneratorProps {
projectId: string;
}
// 世界观设计器UX §6.5):一句话需求 → 预览实体卡type/name/rules
// 本期世界观入库端点未建(仅预览);预览结果供作者参考/复制。
export function WorldGenerator({ projectId }: WorldGeneratorProps) {
const gen = useWorldGen();
const [brief, setBrief] = useState("");
const onGenerate = useCallback(async () => {
await gen.generate(projectId, brief);
}, [gen, projectId, brief]);
const generating = gen.status === "generating";
return (
<section className="flex flex-col gap-4" aria-label="世界观设计器">
<div className="rounded border border-line bg-panel p-4">
<h2 className="mb-3 font-serif text-base text-ink"></h2>
<label className="mb-3 block text-sm text-ink-soft">
<textarea
value={brief}
onChange={(e) => setBrief(e.target.value)}
placeholder="如:东方修真世界,灵气复苏,宗门林立,强者寿元有限"
rows={3}
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/>
</label>
<button
type="button"
onClick={() => void onGenerate()}
disabled={generating || brief.trim().length === 0}
className="rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "生成中…" : "生成世界观"}
</button>
</div>
{gen.status === "done" && gen.entities.length > 0 ? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{gen.entities.map((entity, i) => (
<article
key={`${entity.name}-${i}`}
className="rounded border border-line bg-panel p-3 text-sm"
>
<header className="mb-2 flex items-center gap-2">
<span className="font-serif text-base text-ink">
{entity.name}
</span>
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{entity.type}
</span>
</header>
{worldEntityRules(entity).length > 0 ? (
<ul className="flex list-disc flex-col gap-1 pl-5 text-ink-soft">
{worldEntityRules(entity).map((rule, j) => (
<li key={j}>{rule}</li>
))}
</ul>
) : (
<p className="text-ink-soft"></p>
)}
</article>
))}
</div>
) : null}
</section>
);
}

View File

@@ -20,12 +20,16 @@ import {
} from "@/lib/review/history"; } from "@/lib/review/history";
import { useAccept } from "@/lib/review/useAccept"; import { useAccept } from "@/lib/review/useAccept";
import { useReviewStream } from "@/lib/review/useReviewStream"; import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict } from "@/lib/review/sse"; import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
import { normalizeStyleDrift } from "@/lib/style/style";
import { useToast } from "@/components/Toast";
import { AcceptPanel } from "./AcceptPanel"; import { AcceptPanel } from "./AcceptPanel";
import { AnnotatedText } from "./AnnotatedText"; import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard"; import { ConflictCard } from "./ConflictCard";
import { ForeshadowSuggestions } from "./ForeshadowSuggestions"; import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
import { PacePanel } from "./PacePanel"; import { PacePanel } from "./PacePanel";
import { StylePanel } from "@/components/style/StylePanel";
import { RefineView } from "@/components/style/RefineView";
interface ReviewReportProps { interface ReviewReportProps {
project: ProjectResponse; project: ProjectResponse;
@@ -45,6 +49,7 @@ export function ReviewReport({
}: ReviewReportProps) { }: ReviewReportProps) {
const review = useReviewStream(); const review = useReviewStream();
const accept = useAccept(); const accept = useAccept();
const toast = useToast();
const [finalText, setFinalText] = useState(initialDraft); const [finalText, setFinalText] = useState(initialDraft);
const [drafts, setDrafts] = useState<DecisionDraft[]>(() => const [drafts, setDrafts] = useState<DecisionDraft[]>(() =>
@@ -52,6 +57,10 @@ export function ReviewReport({
); );
const [focusedIndex, setFocusedIndex] = useState<number | null>(null); const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [missing, setMissing] = useState<Set<number>>(new Set()); const [missing, setMissing] = useState<Set<number>>(new Set());
// 回炉中的漂移段null=未打开 RefineView
const [refineSegment, setRefineSegment] = useState<StyleDriftSegment | null>(
null,
);
const seededRef = useRef(false); const seededRef = useRef(false);
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。 // 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
@@ -67,26 +76,33 @@ export function ReviewReport({
() => normalizePace(initialReview), () => normalizePace(initialReview),
[initialReview], [initialReview],
); );
const seededStyle = useMemo(
() => normalizeStyleDrift(initialReview),
[initialReview],
);
useEffect(() => { useEffect(() => {
if (seededRef.current) return; if (seededRef.current) return;
seededRef.current = true; seededRef.current = true;
const hasSeed = const hasSeed =
seededConflicts.length > 0 || seededConflicts.length > 0 ||
seededForeshadow.length > 0 || seededForeshadow.length > 0 ||
seededPace !== null; seededPace !== null ||
seededStyle !== null;
if (hasSeed) { if (hasSeed) {
review.seed({ review.seed({
conflicts: seededConflicts, conflicts: seededConflicts,
foreshadow: seededForeshadow, foreshadow: seededForeshadow,
pace: seededPace, pace: seededPace,
style: seededStyle,
}); });
} }
}, [review, seededConflicts, seededForeshadow, seededPace]); }, [review, seededConflicts, seededForeshadow, seededPace, seededStyle]);
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。 // 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
const conflicts: ReviewConflict[] = review.state.conflicts; const conflicts: ReviewConflict[] = review.state.conflicts;
const foreshadow = review.state.foreshadow; const foreshadow = review.state.foreshadow;
const pace = review.state.pace; const pace = review.state.pace;
const style = review.state.style;
const sectionStatus = (name: string): string | undefined => const sectionStatus = (name: string): string | undefined =>
review.state.sections.find((s) => s.name === name)?.status; review.state.sections.find((s) => s.name === name)?.status;
@@ -147,6 +163,26 @@ export function ReviewReport({
} }
}; };
// 漂移段 idx → 终稿对应段正文(按空行切段;越界则空串)。
const segmentText = (idx: number): string => {
const paras = finalText.split(/\n{2,}/);
return paras[idx]?.trim() ?? "";
};
// 采纳:把重写段替换终稿中的原段(首个匹配),合入终稿(经既有 draft 路径)。
const onAdopt = (original: string, refined: string): void => {
setFinalText((prev) => {
const idx = prev.indexOf(original);
if (idx === -1) {
toast("未在终稿中找到原段,请手动合入。", "error");
return prev;
}
return prev.slice(0, idx) + refined + prev.slice(idx + original.length);
});
setRefineSegment(null);
toast("已合入终稿,记得验收时复核。", "success");
};
const resolved = allResolved(drafts); const resolved = allResolved(drafts);
const unresolved = unresolvedIndices(drafts).length; const unresolved = unresolvedIndices(drafts).length;
const reviewing = review.isReviewing; const reviewing = review.isReviewing;
@@ -279,6 +315,20 @@ export function ReviewReport({
pace={pace} pace={pace}
incomplete={sectionStatus("pace") === "incomplete"} incomplete={sectionStatus("pace") === "incomplete"}
/> />
<StylePanel
style={style}
incomplete={sectionStatus("style") === "incomplete"}
onRefine={setRefineSegment}
/>
{refineSegment !== null ? (
<RefineView
projectId={project.id}
chapterNo={chapterNo}
segment={segmentText(refineSegment.idx)}
onAdopt={onAdopt}
onClose={() => setRefineSegment(null)}
/>
) : null}
</div> </div>
<section className="mt-4"> <section className="mt-4">

View File

@@ -0,0 +1,107 @@
"use client";
import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse, RuleView } from "@/lib/api/types";
import {
RULE_LEVELS,
RULE_LEVEL_LABELS,
groupByLevel,
type RuleLevel,
} from "@/lib/rules/rules";
import { useRules } from "@/lib/rules/useRules";
interface RulesPageProps {
project: ProjectResponse;
initialRules: RuleView[];
}
// 规则页UX §7四级规则列表 + 新增(乐观 + 回滚)。
export function RulesPage({ project, initialRules }: RulesPageProps) {
const { items, busy, add } = useRules(initialRules);
const [level, setLevel] = useState<RuleLevel>("project");
const [content, setContent] = useState("");
const groups = useMemo(() => groupByLevel(items), [items]);
const onSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
const ok = await add(project.id, level, content);
if (ok) setContent("");
};
return (
<AppShell
title={`${project.title}`}
subtitle="规则"
projectId={project.id}
activeNav="rules"
>
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
<form
onSubmit={onSubmit}
className="rounded border border-line bg-panel p-4"
>
<h1 className="mb-3 font-serif text-lg text-ink"></h1>
<div className="mb-3 flex items-end gap-3">
<label className="text-sm text-ink-soft">
<select
value={level}
onChange={(e) => setLevel(e.target.value as RuleLevel)}
className="mt-1 block rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink"
>
{RULE_LEVELS.map((lv) => (
<option key={lv} value={lv}>
{RULE_LEVEL_LABELS[lv]}
</option>
))}
</select>
</label>
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="如:本作禁用现代科技词汇;称呼一律用「道友」"
rows={3}
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
/>
<button
type="submit"
disabled={busy || content.trim().length === 0}
className="mt-3 rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{busy ? "保存中…" : "新增规则"}
</button>
</form>
<div className="flex flex-col gap-4">
{RULE_LEVELS.map((lv) => (
<section key={lv}>
<h2 className="mb-2 font-serif text-sm text-ink">
{RULE_LEVEL_LABELS[lv]}
<span className="ml-2 text-xs text-ink-soft">
{groups[lv].length}
</span>
</h2>
{groups[lv].length === 0 ? (
<p className="text-xs text-ink-soft"></p>
) : (
<ul className="flex flex-col gap-2">
{groups[lv].map((rule, i) => (
<li
key={i}
className="rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
>
{rule.content}
</li>
))}
</ul>
)}
</section>
))}
</div>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { authOpenUrl, formatExpiresAt } from "@/lib/settings/kimiOauth";
import { useKimiOauth } from "@/lib/settings/useKimiOauth";
interface KimiCodeOauthProps {
initialConnected: boolean;
initialExpiresAt: string | null;
}
// Kimi CodeOAuth 订阅 plan连接区K1.4)。
// 与 API-key 凭据行分离:无 key 输入,连接经 device flow显示 user_code + 开授权页 + 轮询 job
export function KimiCodeOauth({
initialConnected,
initialExpiresAt,
}: KimiCodeOauthProps) {
const oauth = useKimiOauth({ initialConnected, initialExpiresAt });
const { phase, device, expiresAt, busy, error } = oauth;
const expiresLabel = formatExpiresAt(expiresAt);
return (
<section className="mb-10">
<h2 className="mb-1 font-serif text-xl text-ink">Kimi CodeOAuth</h2>
<p className="mb-3 text-sm text-ink-soft">
plan OAuth API Key
<span className="font-mono"> kimi-code · kimi-for-coding</span>
</p>
<div className="rounded border border-line bg-panel p-4">
<div className="flex flex-wrap items-center gap-3">
<span
className={`h-2 w-2 rounded-full ${
phase === "connected" ? "bg-pass" : "bg-line"
}`}
aria-hidden="true"
/>
<span className="text-sm text-ink">
{phase === "connected" ? "已连接" : "未连接"}
</span>
{phase === "connected" && expiresLabel ? (
<span className="font-mono text-xs text-ink-soft">
{expiresLabel}
</span>
) : null}
<div className="ml-auto flex gap-2">
{phase === "connected" ? (
<button
type="button"
onClick={() => void oauth.disconnect()}
disabled={busy}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-40"
>
{busy ? "处理中…" : "断开"}
</button>
) : (
<button
type="button"
onClick={() => void oauth.connect()}
disabled={busy}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
>
{busy
? "等待授权…"
: phase === "error"
? "重新连接 Kimi CodeOAuth"
: "连接 Kimi CodeOAuth"}
</button>
)}
</div>
</div>
{phase === "awaiting" && device ? (
<DeviceInstructions
userCode={device.userCode}
openUrl={authOpenUrl(device)}
/>
) : null}
{phase === "error" && error ? (
<p className="mt-3 text-sm text-conflict" role="alert">
{error}
</p>
) : null}
</div>
</section>
);
}
interface DeviceInstructionsProps {
userCode: string;
openUrl: string;
}
// 设备授权指引:突出展示 user_code可读、可选、可聚焦+ 打开授权页按钮。
function DeviceInstructions({ userCode, openUrl }: DeviceInstructionsProps) {
const openAuth = (): void => {
if (typeof window !== "undefined") {
window.open(openUrl, "_blank", "noopener,noreferrer");
}
};
return (
<div className="motion-safe:transition-opacity mt-4 rounded border border-dashed border-line bg-bg p-4">
<p className="mb-2 text-sm text-ink-soft">
</p>
<output
aria-label="设备授权码"
tabIndex={0}
className="mb-3 block select-all rounded bg-panel px-4 py-3 text-center font-mono text-2xl tracking-[0.3em] text-ink"
>
{userCode}
</output>
<button
type="button"
onClick={openAuth}
className="rounded border border-cinnabar px-3 py-1.5 text-sm text-cinnabar"
>
</button>
<p className="mt-2 text-xs text-ink-soft">
</p>
</div>
);
}

View File

@@ -4,15 +4,25 @@ import { useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { KNOWN_PROVIDERS, TIER_LABELS } from "@/lib/settings/providers"; import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
import {
API_KEY_PROVIDERS,
KNOWN_PROVIDERS,
TIER_LABELS,
applyProviderChange,
draftsToRoutingInput,
toRoutingDrafts,
type RoutingDraft,
} from "@/lib/settings/providers";
import type { import type {
CapabilitiesView, CapabilitiesView,
ProvidersResponse, ProvidersResponse,
TierRoutingView,
} from "@/lib/api/types"; } from "@/lib/api/types";
interface ProvidersSettingsProps { interface ProvidersSettingsProps {
initial: ProvidersResponse; initial: ProvidersResponse;
// Kimi Code OAuth 连接态GET .../oauth/status进页 Server Component 取)。
kimiOauth: { connected: boolean; expiresAt: string | null };
} }
interface TestResult { interface TestResult {
@@ -20,11 +30,17 @@ interface TestResult {
capabilities: CapabilitiesView; capabilities: CapabilitiesView;
} }
// 设置页主体UX §6.10):档位路由(只读展示)+ 凭据行(脱敏 / 添加更新 / 测试连接 // 设置页主体UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接
export function ProvidersSettings({ initial }: ProvidersSettingsProps) { export function ProvidersSettings({
initial,
kimiOauth,
}: ProvidersSettingsProps) {
const toast = useToast(); const toast = useToast();
const [providers, setProviders] = useState(initial.providers ?? []); const [providers, setProviders] = useState(initial.providers ?? []);
const tierRouting: TierRoutingView[] = initial.tier_routing ?? []; const [routing, setRouting] = useState<RoutingDraft[]>(
toRoutingDrafts(initial.tier_routing ?? []),
);
const [savingRouting, setSavingRouting] = useState(false);
const [drafts, setDrafts] = useState<Record<string, string>>({}); const [drafts, setDrafts] = useState<Record<string, string>>({});
const [savingId, setSavingId] = useState<string | null>(null); const [savingId, setSavingId] = useState<string | null>(null);
const [testingId, setTestingId] = useState<string | null>(null); const [testingId, setTestingId] = useState<string | null>(null);
@@ -33,6 +49,35 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
const maskedFor = (id: string): string | null => const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null; providers.find((p) => p.provider === id)?.masked_key ?? null;
const updateRouting = (tier: string, providerId: string): void => {
setRouting((prev) =>
prev.map((d) =>
d.tier === tier ? applyProviderChange(d, providerId) : d,
),
);
};
const updateRoutingModel = (tier: string, model: string): void => {
setRouting((prev) =>
prev.map((d) => (d.tier === tier ? { ...d, model } : d)),
);
};
const saveRouting = async (): Promise<void> => {
setSavingRouting(true);
const { data, error } = await api.PUT("/settings/providers", {
body: { tier_routing: draftsToRoutingInput(routing) },
});
setSavingRouting(false);
if (error || !data) {
toast("保存档位路由失败,请重试", "error");
return;
}
setProviders(data.providers ?? []);
setRouting(toRoutingDrafts(data.tier_routing ?? []));
toast("档位路由已保存", "success");
};
const saveCredential = async (providerId: string): Promise<void> => { const saveCredential = async (providerId: string): Promise<void> => {
const apiKey = (drafts[providerId] ?? "").trim(); const apiKey = (drafts[providerId] ?? "").trim();
if (!apiKey) { if (!apiKey) {
@@ -74,34 +119,61 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
<div> <div>
<section className="mb-10"> <section className="mb-10">
<h2 className="mb-3 font-serif text-xl text-ink"></h2> <h2 className="mb-3 font-serif text-xl text-ink"></h2>
{tierRouting.length === 0 ? (
<p className="rounded border border-dashed border-line bg-panel p-4 text-sm text-ink-soft">
使
</p>
) : (
<ul className="divide-y divide-line rounded border border-line bg-panel"> <ul className="divide-y divide-line rounded border border-line bg-panel">
{tierRouting.map((t) => ( {routing.map((row) => (
<li <li
key={t.tier} key={row.tier}
className="flex items-center gap-4 px-4 py-3 text-sm" className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm"
> >
<span className="w-20 text-ink"> <span className="w-20 text-ink">
{TIER_LABELS[t.tier] ?? t.tier} {TIER_LABELS[row.tier] ?? row.tier}
</span> </span>
<span className="font-mono text-ink-soft"> <label className="sr-only" htmlFor={`route-provider-${row.tier}`}>
{t.provider} · {t.model} {TIER_LABELS[row.tier] ?? row.tier}
</span> </label>
{t.fallback && t.fallback.length > 0 ? ( <select
<span className="ml-auto font-mono text-xs text-ink-soft"> id={`route-provider-${row.tier}`}
退 {t.fallback.join(", ")} value={row.provider}
</span> onChange={(e) => updateRouting(row.tier, e.target.value)}
) : null} className="rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
>
<option value=""></option>
{KNOWN_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
{TIER_LABELS[row.tier] ?? row.tier}
</label>
<input
id={`route-model-${row.tier}`}
value={row.model}
onChange={(e) => updateRoutingModel(row.tier, e.target.value)}
placeholder="model"
className="min-w-[10rem] flex-1 rounded border border-line bg-bg px-3 py-1.5 font-mono text-sm text-ink focus:border-cinnabar focus:outline-none"
/>
</li> </li>
))} ))}
</ul> </ul>
)} <div className="mt-3 flex justify-end">
<button
type="button"
onClick={() => void saveRouting()}
disabled={savingRouting}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
>
{savingRouting ? "保存中…" : "保存档位路由"}
</button>
</div>
</section> </section>
<KimiCodeOauth
initialConnected={kimiOauth.connected}
initialExpiresAt={kimiOauth.expiresAt}
/>
<section> <section>
<h2 className="mb-3 font-serif text-xl text-ink"></h2> <h2 className="mb-3 font-serif text-xl text-ink"></h2>
{providers.length === 0 ? ( {providers.length === 0 ? (
@@ -111,7 +183,7 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
</p> </p>
) : null} ) : null}
<ul className="divide-y divide-line rounded border border-line bg-panel"> <ul className="divide-y divide-line rounded border border-line bg-panel">
{KNOWN_PROVIDERS.map((prov) => { {API_KEY_PROVIDERS.map((prov) => {
const masked = maskedFor(prov.id); const masked = maskedFor(prov.id);
const result = results[prov.id]; const result = results[prov.id];
return ( return (

View File

@@ -0,0 +1,77 @@
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse, SkillView } from "@/lib/api/types";
import { groupByScope, scopeLabel, tierLabel } from "@/lib/skills/skills";
interface SkillsPageProps {
project: ProjectResponse;
skills: SkillView[];
}
// 技能库UX §7只读注册表视图name/scope/tier/reads/writes。Server Component纯读
export function SkillsPage({ project, skills }: SkillsPageProps) {
const groups = groupByScope(skills);
return (
<AppShell
title={`${project.title}`}
subtitle="技能库"
projectId={project.id}
activeNav="skills"
>
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-6">
<h1 className="font-serif text-lg text-ink"></h1>
<p className="text-xs text-ink-soft">
Skill /
</p>
{groups.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
) : (
groups.map((group) => (
<section key={group.scope}>
<h2 className="mb-2 font-serif text-sm text-ink">
{scopeLabel(group.scope)}
<span className="ml-2 text-xs text-ink-soft">
{group.skills.length}
</span>
</h2>
<ul className="flex flex-col gap-2">
{group.skills.map((skill) => (
<li
key={skill.name}
className="rounded border border-line bg-panel p-3 text-sm"
>
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="font-mono text-ink">{skill.name}</span>
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{tierLabel(skill.tier)}
</span>
{skill.genre ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{skill.genre}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-4 text-xs text-ink-soft">
<span>
{skill.reads && skill.reads.length > 0
? skill.reads.join("、")
: "—"}
</span>
<span>
{skill.writes && skill.writes.length > 0
? skill.writes.join("、")
: "—"}
</span>
</div>
</li>
))}
</ul>
</section>
))
)}
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import type { Fingerprint } from "@/lib/style/style";
interface FingerprintViewProps {
fingerprint: Fingerprint | null;
}
// 文风指纹展示UX §6.916 维行(维度名 + 值 + 可展开原文证据)。
export function FingerprintView({ fingerprint }: FingerprintViewProps) {
if (fingerprint === null) {
return (
<p className="rounded border border-dashed border-line p-4 text-sm text-ink-soft">
</p>
);
}
return (
<div>
<div className="mb-3 flex items-center gap-2">
<h2 className="font-serif text-lg text-ink"></h2>
<span className="font-mono text-xs text-ink-soft">
v{fingerprint.version} · {fingerprint.dimensions.length}
</span>
</div>
{fingerprint.dimensions.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
) : (
<ul className="space-y-2">
{fingerprint.dimensions.map((dim) => (
<li
key={dim.name}
className="rounded border border-line bg-panel p-3"
>
<div className="flex items-baseline gap-3">
<span className="w-28 shrink-0 text-sm font-semibold text-ink">
{dim.name}
</span>
<span className="text-sm text-ink-soft">{dim.value}</span>
</div>
{dim.evidence.length > 0 ? (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-ink-soft hover:text-cinnabar">
{dim.evidence.length}
</summary>
<ul className="mt-1 space-y-1 border-l-2 border-line pl-3">
{dim.evidence.map((quote, i) => (
<li
key={i}
className="text-xs italic leading-relaxed text-ink-soft"
>
{quote}
</li>
))}
</ul>
</details>
) : null}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useRefine } from "@/lib/style/useRefine";
import { useEffect } from "react";
interface RefineViewProps {
projectId: string;
chapterNo: number;
// 选中漂移段的原文(从终稿按段切出;空则提示先编辑终稿)。
segment: string;
// 采纳:把 refined 合入终稿(经审稿页 finalText → 既有 draft 自动保存路径)。
onAdopt: (original: string, refined: string) => void;
onClose: () => void;
}
// 回炉 diffUX §8.3POST .../refine → 展示原段/重写段对比 → 采纳合入正文。
// 不另写库(不变量#3采纳经既有 draft 自动保存合入。
export function RefineView({
projectId,
chapterNo,
segment,
onAdopt,
onClose,
}: RefineViewProps) {
const refiner = useRefine();
// 进入即触发回炉(段非空)。
useEffect(() => {
if (segment.trim().length > 0) {
void refiner.refine(projectId, chapterNo, segment);
}
// 仅在段变化时触发。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segment, projectId, chapterNo]);
return (
<div
className="rounded border border-cinnabar bg-panel p-3 text-xs"
role="dialog"
aria-label="回炉对比"
>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-ink"></h3>
<button
type="button"
onClick={onClose}
className="text-ink-soft hover:text-cinnabar"
aria-label="关闭回炉对比"
>
</button>
</div>
{segment.trim().length === 0 ? (
<p className="text-overdue">稿稿</p>
) : refiner.status === "refining" ? (
<p className="text-info" aria-live="polite">
</p>
) : refiner.status === "error" ? (
<p className="text-conflict"></p>
) : refiner.result ? (
<div className="space-y-2">
<div>
<p className="mb-1 text-ink-soft"></p>
<p className="whitespace-pre-wrap rounded border border-line bg-bg p-2 text-ink-soft line-through">
{refiner.result.original}
</p>
</div>
<div>
<p className="mb-1 text-cinnabar"></p>
<p className="whitespace-pre-wrap rounded border border-cinnabar bg-bg p-2 text-ink">
{refiner.result.refined}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
if (refiner.result) {
onAdopt(refiner.result.original, refiner.result.refined);
}
}}
className="rounded bg-cinnabar px-3 py-1.5 text-panel hover:opacity-90"
>
</button>
<button
type="button"
onClick={onClose}
className="rounded border border-line px-3 py-1.5 text-ink hover:border-cinnabar"
>
</button>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse } from "@/lib/api/types";
import { useStyleLearn } from "@/lib/style/useStyleLearn";
import type { Fingerprint, StyleLearnMode } from "@/lib/style/style";
import { FingerprintView } from "./FingerprintView";
import { StyleUpload } from "./StyleUpload";
interface StylePageProps {
project: ProjectResponse;
initialFingerprint: Fingerprint | null;
}
// 文风页UX §6.9):左侧上传样本学文风,右侧展示 16 维指纹 + 证据。
export function StylePage({ project, initialFingerprint }: StylePageProps) {
const learn = useStyleLearn(initialFingerprint);
const onLearn = (samples: string[], mode: StyleLearnMode): void => {
void learn.learn(project.id, samples, mode);
};
return (
<AppShell
title={`${project.title}`}
subtitle="文风指纹"
projectId={project.id}
activeNav="style"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[28rem_1fr]">
<section className="flex flex-col overflow-auto border-r border-line bg-panel px-6 py-6">
<h1 className="mb-3 font-serif text-lg text-ink"></h1>
<StyleUpload
busy={learn.busy}
pollStatus={learn.pollStatus === "idle" ? "idle" : learn.pollStatus}
progress={learn.progress}
hasFingerprint={learn.fingerprint !== null}
onLearn={onLearn}
/>
</section>
<section className="overflow-auto bg-bg px-6 py-6">
<FingerprintView fingerprint={learn.fingerprint} />
</section>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
interface StylePanelProps {
style: StyleDriftReport | null;
incomplete: boolean;
// 选中段做回炉(打开 RefineView
onRefine: (segment: StyleDriftSegment) => void;
}
// 整体相似度的四分圆字符档(◔◑◕●),按 score 映射UX §6.4 ③)。
const QUARTER_CHARS = ["○", "◔", "◑", "◕", "●"] as const;
function quarterChar(score: number): string {
const clamped = Math.min(100, Math.max(0, score));
const idx = Math.round((clamped / 100) * (QUARTER_CHARS.length - 1));
return QUARTER_CHARS[idx] ?? QUARTER_CHARS[0];
}
// 文风审区UX §6.4 ③):整体相似度 ◔% + 漂移段列表,每段可一键回炉。只读建议。
export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(style-auditor)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
) : style === null ? (
<p className="mt-1 text-xs text-ink-soft">
</p>
) : (
<div className="mt-2 space-y-2 text-xs">
<p
className="flex items-center gap-1.5"
aria-label={`整体文风相似度 ${style.score}%`}
role="img"
>
<span className="text-lg leading-none text-cinnabar" aria-hidden="true">
{quarterChar(style.score)}
</span>
<span className="text-ink">
<span className="font-mono">{style.score}%</span>
</span>
</p>
{style.segments.length === 0 ? (
<p className="text-pass"> </p>
) : (
<ul className="space-y-2">
{style.segments.map((seg, i) => (
<li
key={i}
className="rounded border border-line bg-panel p-2"
data-testid="drift-segment"
>
<div className="flex items-center gap-2">
<span className="font-mono text-overdue">
{seg.idx}
</span>
<span className="font-mono text-ink-soft">
{seg.score}%
</span>
{seg.label ? (
<span className="text-ink-soft">{seg.label}</span>
) : null}
</div>
<div className="mt-1.5 flex gap-2">
<button
type="button"
onClick={() => onRefine(seg)}
className="rounded bg-cinnabar px-2 py-1 text-panel hover:opacity-90"
>
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useCallback, useState, type ChangeEvent } from "react";
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
import { useToast } from "@/components/Toast";
interface StyleUploadProps {
busy: boolean;
pollStatus: "idle" | "polling" | "done" | "error";
progress: number;
// 是否已有指纹(决定默认 mode = update
hasFingerprint: boolean;
onLearn: (samples: string[], mode: StyleLearnMode) => void;
}
// 学文风输入UX §6.9):多段文本粘贴 + 文件选择(在浏览器读成文本)。
// 样本以正文文本入 body无对象存储确认决策
export function StyleUpload({
busy,
pollStatus,
progress,
hasFingerprint,
onLearn,
}: StyleUploadProps) {
const [text, setText] = useState("");
const toast = useToast();
// 读取选中的文本文件,追加到文本框(多文件用空行分隔)。
const onFiles = useCallback(
async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
const files = Array.from(e.target.files ?? []);
if (files.length === 0) return;
try {
const contents = await Promise.all(files.map((f) => f.text()));
setText((prev) => {
const joined = contents.join("\n\n");
return prev.trim().length > 0 ? `${prev}\n\n${joined}` : joined;
});
} catch {
toast("读取文件失败,请改用粘贴。", "error");
} finally {
e.target.value = ""; // 允许重复选同一文件。
}
},
[toast],
);
const submit = (): void => {
// 以空行切分成多段样本(对齐后端 samples:list[str])。
const samples = text
.split(/\n{2,}/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (!hasUsableSamples(samples)) {
toast("请粘贴或选择至少一段样本正文。", "error");
return;
}
onLearn(samples, hasFingerprint ? "update" : "create");
};
return (
<div className="space-y-3">
<div>
<label
htmlFor="style-samples"
className="mb-1 block text-sm font-semibold text-ink"
>
</label>
<textarea
id="style-samples"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="粘贴你想学习文风的章节正文…"
className="min-h-[30vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[15px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
/>
</div>
<div className="flex items-center gap-3">
<label className="cursor-pointer rounded border border-line px-3 py-1.5 text-sm text-ink hover:border-cinnabar hover:text-cinnabar">
<input
type="file"
accept=".txt,.md,text/plain,text/markdown"
multiple
onChange={(e) => void onFiles(e)}
className="sr-only"
/>
</label>
<button
type="button"
onClick={submit}
disabled={busy}
className="rounded bg-cinnabar px-4 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-50"
>
{hasFingerprint ? "重新学习文风" : "学习文风"}
</button>
</div>
{pollStatus === "polling" ? (
<p className="text-xs text-info" aria-live="polite">
{progress}%
</p>
) : pollStatus === "error" ? (
<p className="text-xs text-conflict"></p>
) : null}
</div>
);
}

View File

@@ -7,20 +7,25 @@ import { AppShell } from "@/components/AppShell";
import type { ProjectResponse } from "@/lib/api/types"; import type { ProjectResponse } from "@/lib/api/types";
import { useAutosave } from "@/lib/autosave/useAutosave"; import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream"; import { useDraftStream } from "@/lib/stream/useDraftStream";
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
import { ChapterList } from "./ChapterList"; import { ChapterList } from "./ChapterList";
import { ChapterAssistant } from "./ChapterAssistant"; import { ChapterAssistant } from "./ChapterAssistant";
import { Editor } from "./Editor"; import { Editor } from "./Editor";
interface WorkbenchProps { interface WorkbenchProps {
project: ProjectResponse; project: ProjectResponse;
// RSC 种入的已存草稿正文GET .../draft无草稿404→ ""(空编辑器)。
initialText?: string;
} }
// M1单章工作台。章节列表为占位无章节列表端点默认第 1 章。 // M1单章工作台。章节列表为占位无章节列表端点默认第 1 章。
const M1_CHAPTER_NO = 1; // WORKBENCH_CHAPTER_NO 移到 lib/workbench/chapter.ts非客户端模块供 Server Component 安全导入。
export function Workbench({ project }: WorkbenchProps) { export function Workbench({ project, initialText = "" }: WorkbenchProps) {
const chapterNo = M1_CHAPTER_NO; const chapterNo = WORKBENCH_CHAPTER_NO;
const [text, setText] = useState(""); // 用已存草稿作初值;流式开始后由下方 effect 覆盖(流不会被初值反扑——
// stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。
const [text, setText] = useState(initialText);
const autosave = useAutosave(project.id, chapterNo); const autosave = useAutosave(project.id, chapterNo);
const stream = useDraftStream(); const stream = useDraftStream();
const lastStreamText = useRef(""); const lastStreamText = useRef("");
@@ -137,7 +142,7 @@ function Toolbar({
</button> </button>
)} )}
<span className="font-mono text-xs text-ink-soft"> <span className="font-mono text-xs text-ink-soft">
{wordCount.toLocaleString()} {wordCount.toLocaleString("en-US")}
</span> </span>
<Link <Link
href={`/projects/${projectId}/outline`} href={`/projects/${projectId}/outline`}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchDraft, fetchOutline } from "./server";
// 纯加载/降级逻辑mock fetch无网络/DOM大纲解包/降级、草稿 404→null。
function mockFetch(status: number, body: unknown): void {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
),
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("fetchOutline", () => {
it("returns the persisted chapters on 200", async () => {
mockFetch(200, { chapters: [{ no: 1, volume: 1, beats: ["开场"] }] });
const chapters = await fetchOutline("p1");
expect(chapters).toHaveLength(1);
expect(chapters[0].no).toBe(1);
});
it("returns empty array when the project has no outline", async () => {
mockFetch(200, { chapters: [] });
expect(await fetchOutline("p1")).toEqual([]);
});
it("falls back to empty array on any error (does not throw)", async () => {
mockFetch(404, { error: { code: "NOT_FOUND", message: "no project" } });
expect(await fetchOutline("missing")).toEqual([]);
});
});
describe("fetchDraft", () => {
it("returns the saved draft view on 200", async () => {
mockFetch(200, {
project_id: "p1",
chapter_no: 1,
volume: 1,
status: "draft",
version: 2,
content: "第一章正文",
length: 5,
});
const draft = await fetchDraft("p1", 1);
expect(draft?.content).toBe("第一章正文");
expect(draft?.version).toBe(2);
});
it("returns null when no draft exists (404 → empty editor)", async () => {
mockFetch(404, { error: { code: "NOT_FOUND", message: "no draft" } });
expect(await fetchDraft("p1", 9)).toBeNull();
});
});

View File

@@ -1,10 +1,19 @@
import { serverApiBase } from "./config"; import { serverApiBase } from "./config";
import type { import type {
CharacterListResponse,
DraftView,
ForeshadowBoardResponse, ForeshadowBoardResponse,
OutlineChapterView,
OutlineResponse,
ProjectListResponse, ProjectListResponse,
ProjectResponse, ProjectResponse,
OAuthStatusResponse,
ProvidersResponse, ProvidersResponse,
ReviewHistoryResponse, ReviewHistoryResponse,
RuleListResponse,
SkillListResponse,
StyleFingerprintResponse,
WorldEntityListResponse,
} from "./types"; } from "./types";
// 服务端只读取数据Server Components。失败时抛出由页面边界处理。 // 服务端只读取数据Server Components。失败时抛出由页面边界处理。
@@ -16,6 +25,16 @@ async function getJson<T>(path: string): Promise<T> {
return (await res.json()) as T; return (await res.json()) as T;
} }
// 像上面一样读取,但 404无指纹返回 null 而非抛出(首学前页面照常渲染)。
async function getJsonOrNull<T>(path: string): Promise<T | null> {
const res = await fetch(`${serverApiBase()}${path}`, { cache: "no-store" });
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`请求失败 ${res.status}: ${path}`);
}
return (await res.json()) as T;
}
export async function fetchProjects(): Promise<ProjectListResponse> { export async function fetchProjects(): Promise<ProjectListResponse> {
return getJson<ProjectListResponse>("/projects"); return getJson<ProjectListResponse>("/projects");
} }
@@ -28,6 +47,13 @@ export async function fetchProviders(): Promise<ProvidersResponse> {
return getJson<ProvidersResponse>("/settings/providers"); return getJson<ProvidersResponse>("/settings/providers");
} }
// Kimi Code OAuth 连接态GET .../oauth/status无 token 本体)。
export async function fetchKimiOauthStatus(): Promise<OAuthStatusResponse> {
return getJson<OAuthStatusResponse>(
"/settings/providers/kimi-code/oauth/status",
);
}
export async function fetchReviews( export async function fetchReviews(
projectId: string, projectId: string,
chapterNo: number, chapterNo: number,
@@ -43,3 +69,66 @@ export async function fetchForeshadow(
): Promise<ForeshadowBoardResponse> { ): Promise<ForeshadowBoardResponse> {
return getJson<ForeshadowBoardResponse>(`/projects/${projectId}/foreshadow`); return getJson<ForeshadowBoardResponse>(`/projects/${projectId}/foreshadow`);
} }
// 最新文风指纹GET /style无指纹404→ null首学前页面照常渲染
export async function fetchStyleFingerprint(
projectId: string,
): Promise<StyleFingerprintResponse | null> {
return getJsonOrNull<StyleFingerprintResponse>(
`/projects/${projectId}/style`,
);
}
// 规则列表GET .../rules规则页
export async function fetchRules(
projectId: string,
): Promise<RuleListResponse> {
return getJson<RuleListResponse>(`/projects/${projectId}/rules`);
}
// 技能库注册表GET /skills全局只读
export async function fetchSkills(): Promise<SkillListResponse> {
return getJson<SkillListResponse>("/skills");
}
// 设定库 Codex跨会话全量已入库角色GET .../characters无行→空列表非 404
export async function fetchCharacters(
projectId: string,
): Promise<CharacterListResponse> {
return getJson<CharacterListResponse>(`/projects/${projectId}/characters`);
}
// 设定库 Codex跨会话全量已入库世界观实体GET .../world_entities无行→空列表
export async function fetchWorldEntities(
projectId: string,
): Promise<WorldEntityListResponse> {
return getJson<WorldEntityListResponse>(
`/projects/${projectId}/world_entities`,
);
}
// 已存大纲GET .../outline与 POST 同形,按 chapter_no 排序)。
// 无大纲→空列表;任何错误亦降级为空(进页不阻塞,生成流照常工作)。
export async function fetchOutline(
projectId: string,
): Promise<OutlineChapterView[]> {
try {
const res = await getJson<OutlineResponse>(
`/projects/${projectId}/outline`,
);
return res.chapters ?? [];
} catch {
return [];
}
}
// 已存草稿GET .../draft含正文供工作台重访重载编辑器
// 404尚无草稿新章→ null由调用方当空编辑器处理不抛错。
export async function fetchDraft(
projectId: string,
chapterNo: number,
): Promise<DraftView | null> {
return getJsonOrNull<DraftView>(
`/projects/${projectId}/chapters/${chapterNo}/draft`,
);
}

View File

@@ -6,6 +6,8 @@ export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"]
export type ProjectListResponse = components["schemas"]["ProjectListResponse"]; export type ProjectListResponse = components["schemas"]["ProjectListResponse"];
export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"]; export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"];
export type DraftResponse = components["schemas"]["DraftResponse"]; export type DraftResponse = components["schemas"]["DraftResponse"];
// GET .../draft 读端点含正文供工作台重访时重载编辑器404→空编辑器
export type DraftView = components["schemas"]["DraftView"];
export type ProvidersResponse = components["schemas"]["ProvidersResponse"]; export type ProvidersResponse = components["schemas"]["ProvidersResponse"];
export type ProviderView = components["schemas"]["ProviderView"]; export type ProviderView = components["schemas"]["ProviderView"];
export type TierRoutingView = components["schemas"]["TierRoutingView"]; export type TierRoutingView = components["schemas"]["TierRoutingView"];
@@ -40,3 +42,48 @@ export type OutlineChapterView = components["schemas"]["OutlineChapterView"];
export type OutlineGenerateRequest = export type OutlineGenerateRequest =
components["schemas"]["OutlineGenerateRequest"]; components["schemas"]["OutlineGenerateRequest"];
export type OutlineResponse = components["schemas"]["OutlineResponse"]; export type OutlineResponse = components["schemas"]["OutlineResponse"];
// M4 文风学文风jobs 异步)+ 最新指纹 + 回炉C3 扩 T4.3)。
export type StyleLearnRequest = components["schemas"]["StyleLearnRequest"];
export type StyleLearnResponse = components["schemas"]["StyleLearnResponse"];
export type StyleFingerprintResponse =
components["schemas"]["StyleFingerprintResponse"];
export type RefineRequest = components["schemas"]["RefineRequest"];
export type RefineResponse = components["schemas"]["RefineResponse"];
// M5 生成 + 设定库C3 扩 T5.2)。
export type WorldGenerateRequest =
components["schemas"]["WorldGenerateRequest"];
export type WorldGenPreviewResponse =
components["schemas"]["WorldGenPreviewResponse"];
export type WorldEntityCardView =
components["schemas"]["WorldEntityCardView"];
export type CharacterGenerateRequest =
components["schemas"]["CharacterGenerateRequest"];
export type CharacterGenPreviewResponse =
components["schemas"]["CharacterGenPreviewResponse"];
export type CharacterCardView = components["schemas"]["CharacterCardView"];
export type CharacterRelationView =
components["schemas"]["CharacterRelationView"];
export type CharacterIngestRequest =
components["schemas"]["CharacterIngestRequest"];
export type CharacterIngestResponse =
components["schemas"]["CharacterIngestResponse"];
// 设定库 Codex 读端点C3 扩 M5 R1 follow-up跨会话全量已入库角色/世界观。
export type CharacterListResponse =
components["schemas"]["CharacterListResponse"];
export type WorldEntityListResponse =
components["schemas"]["WorldEntityListResponse"];
// K1 Kimi Code OAuth device-flowC3 扩 K1.3)。
export type OAuthStartResponse = components["schemas"]["OAuthStartResponse"];
export type OAuthDisconnectResponse =
components["schemas"]["OAuthDisconnectResponse"];
export type OAuthStatusResponse = components["schemas"]["OAuthStatusResponse"];
// M5 规则 + 技能C3 扩 T5.2 / T5.5)。
export type RuleCreateRequest = components["schemas"]["RuleCreateRequest"];
export type RuleView = components["schemas"]["RuleView"];
export type RuleListResponse = components["schemas"]["RuleListResponse"];
export type SkillView = components["schemas"]["SkillView"];
export type SkillListResponse = components["schemas"]["SkillListResponse"];

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
clampHighlight,
filterCommands,
globalCommands,
moveHighlight,
projectCommands,
} from "./palette";
describe("projectCommands", () => {
it("includes nav + action commands wired to the project id", () => {
const cmds = projectCommands("p1");
const write = cmds.find((c) => c.id === "nav-write");
expect(write?.href).toBe("/projects/p1/write");
const genChar = cmds.find((c) => c.id === "action-gen-character");
expect(genChar?.kind).toBe("action");
expect(genChar?.href).toBe("/projects/p1/codex?gen=character");
});
});
describe("globalCommands", () => {
it("offers home + settings", () => {
expect(globalCommands().map((c) => c.id)).toEqual([
"nav-home",
"nav-settings",
]);
});
});
describe("filterCommands", () => {
const cmds = projectCommands("p1");
it("returns all on empty query", () => {
expect(filterCommands(cmds, " ")).toHaveLength(cmds.length);
});
it("matches on title", () => {
const out = filterCommands(cmds, "审稿");
expect(out.map((c) => c.id)).toContain("nav-review");
});
it("matches on keyword case-insensitively", () => {
const out = filterCommands(cmds, "FORESHADOW");
expect(out.map((c) => c.id)).toContain("nav-foreshadow");
});
it("matches generation actions by 角色/世界观", () => {
expect(filterCommands(cmds, "角色").map((c) => c.id)).toContain(
"action-gen-character",
);
expect(filterCommands(cmds, "世界观").map((c) => c.id)).toContain(
"action-gen-world",
);
});
it("returns empty when nothing matches", () => {
expect(filterCommands(cmds, "zzzz-no-match")).toEqual([]);
});
});
describe("clampHighlight / moveHighlight", () => {
it("wraps within [0, len)", () => {
expect(clampHighlight(0, 3)).toBe(0);
expect(clampHighlight(3, 3)).toBe(0);
expect(clampHighlight(-1, 3)).toBe(2);
expect(clampHighlight(5, 0)).toBe(0);
});
it("moves up/down with wrap", () => {
expect(moveHighlight(0, 1, 3)).toBe(1);
expect(moveHighlight(2, 1, 3)).toBe(0);
expect(moveHighlight(0, -1, 3)).toBe(2);
});
});

View File

@@ -0,0 +1,154 @@
// 命令面板⌘K纯逻辑命令清单组装 + 模糊过滤 + 键盘选择状态机。
// 对齐 UX §7快速导航/动作)。纯逻辑,便于 node 环境单测(不依赖 DOM
export type CommandKind = "navigate" | "action";
export interface Command {
id: string;
// 展示标题(如「写本章」「跳转伏笔」)。
title: string;
// 用于匹配的关键词(拼音/别名),与 title 一起参与过滤。
keywords: string[];
kind: CommandKind;
// navigate跳转路径action交由调用方按 id 分派。
href?: string;
group: string;
}
// 项目内可用命令projectId 已知)。导航项给 href动作项生成角色等给 id。
export function projectCommands(projectId: string): Command[] {
const p = `/projects/${projectId}`;
return [
{
id: "nav-write",
title: "写本章",
keywords: ["write", "写作", "工作台", "起草"],
kind: "navigate",
href: `${p}/write`,
group: "导航",
},
{
id: "nav-review",
title: "审稿",
keywords: ["review", "质检", "冲突", "裁决"],
kind: "navigate",
href: `${p}/review`,
group: "导航",
},
{
id: "nav-outline",
title: "大纲",
keywords: ["outline", "节拍", "beats"],
kind: "navigate",
href: `${p}/outline`,
group: "导航",
},
{
id: "nav-foreshadow",
title: "跳转伏笔",
keywords: ["foreshadow", "伏笔", "看板", "线索"],
kind: "navigate",
href: `${p}/foreshadow`,
group: "导航",
},
{
id: "nav-style",
title: "文风",
keywords: ["style", "文风", "指纹", "漂移"],
kind: "navigate",
href: `${p}/style`,
group: "导航",
},
{
id: "nav-codex",
title: "搜设定",
keywords: ["codex", "设定库", "人物", "世界观", "角色"],
kind: "navigate",
href: `${p}/codex`,
group: "导航",
},
{
id: "nav-rules",
title: "规则",
keywords: ["rules", "规则", "硬规则"],
kind: "navigate",
href: `${p}/rules`,
group: "导航",
},
{
id: "nav-skills",
title: "技能库",
keywords: ["skills", "技能", "注册表"],
kind: "navigate",
href: `${p}/skills`,
group: "导航",
},
{
id: "action-gen-character",
title: "生成角色",
keywords: ["character", "角色", "生成器", "群像"],
kind: "action",
href: `${p}/codex?gen=character`,
group: "动作",
},
{
id: "action-gen-world",
title: "生成世界观",
keywords: ["world", "世界观", "设定", "设计器"],
kind: "action",
href: `${p}/codex?gen=world`,
group: "动作",
},
];
}
// 全局命令(无 projectId 时;如作品库/设置)。
export function globalCommands(): Command[] {
return [
{
id: "nav-home",
title: "作品库",
keywords: ["home", "作品", "library", "projects"],
kind: "navigate",
href: "/",
group: "导航",
},
{
id: "nav-settings",
title: "设置",
keywords: ["settings", "设置", "凭据", "provider", "档位"],
kind: "navigate",
href: "/settings/providers",
group: "导航",
},
];
}
// 模糊过滤:空查询返回全部;否则在 title + keywords 上做大小写无关子串匹配(保持原顺序)。
export function filterCommands(
commands: readonly Command[],
query: string,
): Command[] {
const q = query.trim().toLowerCase();
if (!q) return [...commands];
return commands.filter((cmd) => {
if (cmd.title.toLowerCase().includes(q)) return true;
return cmd.keywords.some((kw) => kw.toLowerCase().includes(q));
});
}
// 键盘高亮索引在 [0, len) 内循环(空列表归 0
export function clampHighlight(index: number, length: number): number {
if (length <= 0) return 0;
const wrapped = index % length;
return wrapped < 0 ? wrapped + length : wrapped;
}
// 上/下方向移动高亮(循环)。
export function moveHighlight(
index: number,
delta: number,
length: number,
): number {
return clampHighlight(index + delta, length);
}

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import type {
CharacterCardView,
WorldEntityCardView,
} from "@/lib/api/types";
import {
buildCharacterGenerateRequest,
buildCharacterIngestRequest,
buildWorldGenerateRequest,
clampCharacterCount,
errorCode,
extractIngestConflicts,
generationErrorMessage,
MAX_CHARACTER_COUNT,
mergeCharacterCards,
mergeWorldEntities,
MIN_CHARACTER_COUNT,
updateCard,
worldEntityRules,
} from "./cards";
const card = (over: Partial<CharacterCardView> = {}): CharacterCardView => ({
name: "甲",
role: "主角",
backstory: "出身寒门",
arc: "由怯转勇",
...over,
});
describe("buildWorldGenerateRequest", () => {
it("trims brief", () => {
expect(buildWorldGenerateRequest(" 修真世界 ")).toEqual({
brief: "修真世界",
});
});
});
describe("clampCharacterCount", () => {
it("clamps to [MIN, MAX] and rounds", () => {
expect(clampCharacterCount(0)).toBe(MIN_CHARACTER_COUNT);
expect(clampCharacterCount(99)).toBe(MAX_CHARACTER_COUNT);
expect(clampCharacterCount(3.6)).toBe(4);
expect(clampCharacterCount(Number.NaN)).toBe(MIN_CHARACTER_COUNT);
});
});
describe("buildCharacterGenerateRequest", () => {
it("trims brief, clamps count, omits empty role", () => {
expect(buildCharacterGenerateRequest(" 剑修 ", 99)).toEqual({
brief: "剑修",
count: MAX_CHARACTER_COUNT,
});
});
it("includes trimmed role when present", () => {
expect(buildCharacterGenerateRequest("剑修", 2, " 对手 ")).toEqual({
brief: "剑修",
count: 2,
role: "对手",
});
});
});
describe("buildCharacterIngestRequest", () => {
it("copies cards and carries acknowledge flag", () => {
const cards = [card()];
const req = buildCharacterIngestRequest(cards, true);
expect(req.acknowledge_conflicts).toBe(true);
expect(req.cards).toEqual(cards);
expect(req.cards[0]).not.toBe(cards[0]); // immutable copy
});
it("defaults acknowledge to false", () => {
expect(buildCharacterIngestRequest([card()]).acknowledge_conflicts).toBe(
false,
);
});
});
describe("updateCard", () => {
it("returns a new card with patch applied (no mutation)", () => {
const original = card();
const next = updateCard(original, { name: "乙" });
expect(next.name).toBe("乙");
expect(original.name).toBe("甲");
expect(next).not.toBe(original);
});
});
describe("extractIngestConflicts", () => {
it("narrows CONFLICT_UNRESOLVED envelope details", () => {
const out = extractIngestConflicts({
error: {
code: "CONFLICT_UNRESOLVED",
details: {
conflicts: [
{ type: "性格漂移", where: "第3章", refs: ["甲"], suggestion: "改" },
"junk",
],
conflict_count: 2,
},
},
});
expect(out).toEqual({
conflictCount: 2,
conflicts: [
{ type: "性格漂移", where: "第3章", refs: ["甲"], suggestion: "改" },
{ type: "未分类", where: "", refs: [], suggestion: "" },
],
});
});
it("returns null for non-conflict errors", () => {
expect(extractIngestConflicts({ error: { code: "LLM_UNAVAILABLE" } })).toBeNull();
expect(extractIngestConflicts(null)).toBeNull();
});
it("falls back conflict_count to conflicts length", () => {
const out = extractIngestConflicts({
error: {
code: "CONFLICT_UNRESOLVED",
details: { conflicts: [{ type: "设定违例" }] },
},
});
expect(out?.conflictCount).toBe(1);
});
});
describe("errorCode / generationErrorMessage", () => {
it("extracts code and maps 503 to settings prompt", () => {
expect(errorCode({ error: { code: "LLM_UNAVAILABLE" } })).toBe(
"LLM_UNAVAILABLE",
);
expect(generationErrorMessage({ error: { code: "LLM_UNAVAILABLE" } })).toContain(
"设置",
);
expect(generationErrorMessage({ error: { code: "INTERNAL" } })).toContain(
"重试",
);
});
});
describe("worldEntityRules", () => {
it("returns rules or empty array", () => {
expect(worldEntityRules({ type: "势力", name: "宗门", rules: ["a"] })).toEqual([
"a",
]);
expect(worldEntityRules({ type: "势力", name: "宗门" })).toEqual([]);
});
});
describe("mergeCharacterCards", () => {
it("appends session cards not already in the persisted list", () => {
const persisted = [card({ name: "甲" })];
const session = [card({ name: "乙" })];
const out = mergeCharacterCards(persisted, session);
expect(out.map((c) => c.name)).toEqual(["甲", "乙"]);
});
it("dedups by name (persisted wins, no duplicate on reload echo)", () => {
const persisted = [card({ name: "甲", role: "主角" })];
const session = [card({ name: "甲", role: "对手" })];
const out = mergeCharacterCards(persisted, session);
expect(out).toHaveLength(1);
expect(out[0].role).toBe("主角"); // persisted version kept
});
it("returns persisted list when no session cards", () => {
const persisted = [card({ name: "甲" })];
expect(mergeCharacterCards(persisted, [])).toEqual(persisted);
});
});
describe("mergeWorldEntities", () => {
const entity = (
over: Partial<WorldEntityCardView> = {},
): WorldEntityCardView => ({ type: "势力", name: "宗门", ...over });
it("appends entities not already present (keyed by type+name)", () => {
const persisted = [entity({ name: "天剑宗" })];
const session = [entity({ name: "魔渊" })];
expect(mergeWorldEntities(persisted, session).map((e) => e.name)).toEqual([
"天剑宗",
"魔渊",
]);
});
it("dedups by type+name; same name different type kept separate", () => {
const persisted = [entity({ type: "势力", name: "玄" })];
const session = [
entity({ type: "势力", name: "玄" }),
entity({ type: "地点", name: "玄" }),
];
const out = mergeWorldEntities(persisted, session);
expect(out).toHaveLength(2);
expect(out.map((e) => e.type)).toEqual(["势力", "地点"]);
});
});

Binary file not shown.

View File

@@ -0,0 +1,150 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type {
CharacterCardView,
CharacterIngestResponse,
} from "@/lib/api/types";
import {
buildCharacterGenerateRequest,
buildCharacterIngestRequest,
extractIngestConflicts,
generationErrorMessage,
type IngestConflicts,
} from "./cards";
export type CharacterGenStatus = "idle" | "generating" | "preview" | "error";
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error";
export interface CharacterGenInput {
projectId: string;
brief: string;
count: number;
role?: string | null;
}
export interface UseCharacterGen {
genStatus: CharacterGenStatus;
cards: CharacterCardView[];
ingestStatus: IngestStatus;
// 409 冲突详情(待作者裁决);裁决后带 acknowledge 重发。
conflicts: IngestConflicts | null;
created: string[];
generate: (input: CharacterGenInput) => Promise<void>;
// 入库选中卡acknowledge 用于过 409已查看冲突后重发
ingest: (
projectId: string,
cards: readonly CharacterCardView[],
acknowledge?: boolean,
) => Promise<boolean>;
reset: () => void;
}
// 角色生成器UX §6.6):生成预览 → 作者选/改 → 入库(处理 409 裁决流)。
export function useCharacterGen(): UseCharacterGen {
const [genStatus, setGenStatus] = useState<CharacterGenStatus>("idle");
const [cards, setCards] = useState<CharacterCardView[]>([]);
const [ingestStatus, setIngestStatus] = useState<IngestStatus>("idle");
const [conflicts, setConflicts] = useState<IngestConflicts | null>(null);
const [created, setCreated] = useState<string[]>([]);
const toast = useToast();
const generate = useCallback<UseCharacterGen["generate"]>(
async ({ projectId, brief, count, role }) => {
setGenStatus("generating");
setCards([]);
setConflicts(null);
setIngestStatus("idle");
try {
const { data, error } = await api.POST(
"/projects/{project_id}/characters/generate",
{
params: { path: { project_id: projectId } },
body: buildCharacterGenerateRequest(brief, count, role),
},
);
if (error || !data) {
setGenStatus("error");
toast(generationErrorMessage(error), "error");
return;
}
setCards(data.cards ?? []);
setGenStatus("preview");
} catch {
setGenStatus("error");
toast("生成请求异常,请检查网络。", "error");
}
},
[toast],
);
const ingest = useCallback<UseCharacterGen["ingest"]>(
async (projectId, ingestCards, acknowledge = false) => {
if (ingestCards.length === 0) {
toast("请至少选择一张角色卡。", "error");
return false;
}
setIngestStatus("ingesting");
try {
const { data, error } = await api.POST(
"/projects/{project_id}/characters",
{
params: { path: { project_id: projectId } },
body: buildCharacterIngestRequest(ingestCards, acknowledge),
},
);
if (error || !data) {
const conflict = extractIngestConflicts(error);
if (conflict) {
// 409 CONFLICT_UNRESOLVED展示冲突让作者裁决再带 acknowledge 重发。
setConflicts(conflict);
setIngestStatus("conflict");
return false;
}
setIngestStatus("error");
toast(generationErrorMessage(error), "error");
return false;
}
const result = data as CharacterIngestResponse;
setCreated(result.created ?? []);
setConflicts(null);
setIngestStatus("done");
toast(`已入库 ${result.created?.length ?? 0} 个角色`, "success");
if (result.rejected_tables && result.rejected_tables.length > 0) {
toast(
`越权写表被丢弃:${result.rejected_tables.join("、")}`,
"info",
);
}
return true;
} catch {
setIngestStatus("error");
toast("入库请求异常,请检查网络。", "error");
return false;
}
},
[toast],
);
const reset = useCallback((): void => {
setGenStatus("idle");
setCards([]);
setIngestStatus("idle");
setConflicts(null);
setCreated([]);
}, []);
return {
genStatus,
cards,
ingestStatus,
conflicts,
created,
generate,
ingest,
reset,
};
}

View File

@@ -0,0 +1,58 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { WorldEntityCardView } from "@/lib/api/types";
import { buildWorldGenerateRequest, generationErrorMessage } from "./cards";
export type WorldGenStatus = "idle" | "generating" | "done" | "error";
export interface UseWorldGen {
status: WorldGenStatus;
entities: WorldEntityCardView[];
generate: (projectId: string, brief: string) => Promise<void>;
reset: () => void;
}
// 世界观设计器POST /world/generate → 预览实体(不入库)。
export function useWorldGen(): UseWorldGen {
const [status, setStatus] = useState<WorldGenStatus>("idle");
const [entities, setEntities] = useState<WorldEntityCardView[]>([]);
const toast = useToast();
const generate = useCallback<UseWorldGen["generate"]>(
async (projectId, brief) => {
setStatus("generating");
setEntities([]);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/world/generate",
{
params: { path: { project_id: projectId } },
body: buildWorldGenerateRequest(brief),
},
);
if (error || !data) {
setStatus("error");
toast(generationErrorMessage(error), "error");
return;
}
setEntities(data.entities ?? []);
setStatus("done");
} catch {
setStatus("error");
toast("生成请求异常,请检查网络。", "error");
}
},
[toast],
);
const reset = useCallback((): void => {
setStatus("idle");
setEntities([]);
}, []);
return { status, entities, generate, reset };
}

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
initialPollState,
isTerminal,
narrowJob,
reducePoll,
styleLearnResult,
type JobView,
} from "./job";
describe("narrowJob", () => {
it("narrows a well-formed loose dict into JobView", () => {
const job = narrowJob({
id: "j1",
kind: "style_learn",
status: "running",
progress: 42,
result: null,
error: null,
});
expect(job).toEqual({
id: "j1",
kind: "style_learn",
status: "running",
progress: 42,
result: null,
error: null,
});
});
it("falls back safely for missing/wrong-typed fields", () => {
expect(narrowJob({})).toEqual({
id: "",
kind: "",
status: "queued",
progress: 0,
result: null,
error: null,
});
// unknown status → queued; out-of-range progress clamped.
expect(narrowJob({ status: "weird", progress: 250 }).status).toBe("queued");
expect(narrowJob({ progress: 250 }).progress).toBe(100);
expect(narrowJob({ progress: -5 }).progress).toBe(0);
});
it("keeps result only when an object (not array)", () => {
expect(narrowJob({ result: { version: 2 } }).result).toEqual({ version: 2 });
expect(narrowJob({ result: [1, 2] }).result).toBeNull();
});
it("narrows non-object input", () => {
expect(narrowJob(null).status).toBe("queued");
expect(narrowJob("nope").id).toBe("");
});
});
describe("isTerminal", () => {
it("done and failed are terminal", () => {
expect(isTerminal("done")).toBe(true);
expect(isTerminal("failed")).toBe(true);
expect(isTerminal("queued")).toBe(false);
expect(isTerminal("running")).toBe(false);
});
});
describe("reducePoll", () => {
const job = (over: Partial<JobView>): JobView => ({
id: "j1",
kind: "style_learn",
status: "running",
progress: 0,
result: null,
error: null,
...over,
});
it("stays polling for queued/running and tracks progress", () => {
const out = reducePoll(initialPollState, {
type: "tick",
job: job({ status: "running", progress: 30 }),
});
expect(out.status).toBe("polling");
expect(out.progress).toBe(30);
});
it("transitions to done with progress 100", () => {
const out = reducePoll(initialPollState, {
type: "tick",
job: job({ status: "done", result: { version: 2, dims_count: 16 } }),
});
expect(out.status).toBe("done");
expect(out.progress).toBe(100);
expect(out.job?.result).toEqual({ version: 2, dims_count: 16 });
});
it("transitions to error on failed, using job.error", () => {
const out = reducePoll(initialPollState, {
type: "tick",
job: job({ status: "failed", error: "网关爆炸", progress: 20 }),
});
expect(out.status).toBe("error");
expect(out.error).toBe("网关爆炸");
expect(out.progress).toBe(20);
});
it("defaults error text when failed without error string", () => {
const out = reducePoll(initialPollState, {
type: "tick",
job: job({ status: "failed", error: null }),
});
expect(out.error).toBe("任务失败");
});
it("handles a fail action (poll request failure)", () => {
const out = reducePoll(initialPollState, {
type: "fail",
error: "网络断了",
});
expect(out.status).toBe("error");
expect(out.error).toBe("网络断了");
});
});
describe("styleLearnResult", () => {
it("extracts version + dims_count from done result", () => {
const out = styleLearnResult({
id: "j",
kind: "style_learn",
status: "done",
progress: 100,
result: { version: 3, dims_count: 16 },
error: null,
});
expect(out).toEqual({ version: 3, dimsCount: 16 });
});
it("returns nulls for missing result fields", () => {
expect(
styleLearnResult({
id: "j",
kind: "style_learn",
status: "done",
progress: 100,
result: null,
error: null,
}),
).toEqual({ version: null, dimsCount: null });
});
});

111
apps/web/lib/jobs/job.ts Normal file
View File

@@ -0,0 +1,111 @@
// 长任务jobs轮询纯逻辑把松散 `GET /jobs/{id}` 响应安全收窄成 JobView
// 并提供一个 setTimeout 轮询状态机的纯 reducerC3.5 / ARCH §7.4)。
// schema 把 GET /jobs/{id} 标成 {[k]:unknown}T0.3 裸端点这里安全narrow。
// 后端 job 状态机C3.5queued → running → done|failed。
export type JobStatus = "queued" | "running" | "done" | "failed";
export interface JobView {
id: string;
kind: string;
status: JobStatus;
progress: number;
result: Record<string, unknown> | null;
error: string | null;
}
function asString(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function asStatus(v: unknown): JobStatus {
return v === "running" || v === "done" || v === "failed" || v === "queued"
? v
: "queued";
}
function asProgress(v: unknown): number {
if (typeof v !== "number" || !Number.isFinite(v)) return 0;
return Math.min(100, Math.max(0, Math.round(v)));
}
function asResult(v: unknown): Record<string, unknown> | null {
return typeof v === "object" && v !== null && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
// 把松散 GET /jobs/{id} 响应收窄成 JobView缺字段给安全默认
export function narrowJob(raw: unknown): JobView {
const dict =
typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
return {
id: asString(dict["id"]),
kind: asString(dict["kind"]),
status: asStatus(dict["status"]),
progress: asProgress(dict["progress"]),
result: asResult(dict["result"]),
error: typeof dict["error"] === "string" ? (dict["error"] as string) : null,
};
}
// 学文风 job 完成态的 result{version, dims_count}C3 扩 work 摘要)。
export interface StyleLearnResult {
version: number | null;
dimsCount: number | null;
}
export function styleLearnResult(job: JobView): StyleLearnResult {
const r = job.result ?? {};
const version = typeof r["version"] === "number" ? r["version"] : null;
const dimsCount =
typeof r["dims_count"] === "number" ? r["dims_count"] : null;
return { version, dimsCount };
}
// 轮询状态机pollingqueued/running→ done | errordone/failed
export type PollStatus = "polling" | "done" | "error";
export interface PollState {
status: PollStatus;
progress: number;
job: JobView | null;
error: string | null;
}
export const initialPollState: PollState = {
status: "polling",
progress: 0,
job: null,
error: null,
};
export type PollAction =
| { type: "tick"; job: JobView }
| { type: "fail"; error: string };
// 纯 reducer吃一次轮询结果映射成轮询状态。
// done → 终态 donefailed → 终态 error用 job.error 文案);其余 → 继续 polling。
export function reducePoll(state: PollState, action: PollAction): PollState {
if (action.type === "fail") {
return { ...state, status: "error", error: action.error };
}
const { job } = action;
if (job.status === "done") {
return { status: "done", progress: 100, job, error: null };
}
if (job.status === "failed") {
return {
status: "error",
progress: job.progress,
job,
error: job.error ?? "任务失败",
};
}
return { status: "polling", progress: job.progress, job, error: null };
}
// 是否终态(停止轮询)。
export function isTerminal(status: JobStatus): boolean {
return status === "done" || status === "failed";
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useCallback, useEffect, useReducer, useRef } from "react";
import { api } from "@/lib/api/client";
import {
initialPollState,
isTerminal,
narrowJob,
reducePoll,
type PollState,
} from "./job";
const POLL_INTERVAL_MS = 1500;
export interface UseJobPoll extends PollState {
// 开始轮询某个 job替换正在轮询的 job
poll: (jobId: string) => void;
// 复位到初始(停止当前轮询)。
reset: () => void;
}
// 轮询 GET /jobs/{job_id}setTimeout 循环done/failed 终态停止,卸载清理。
// 纯状态机在 job.ts可单测本 hook 只负责定时器 + fetch 编排。
export function useJobPoll(): UseJobPoll {
const [state, dispatch] = useReducer(reducePoll, initialPollState);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeRef = useRef<string | null>(null);
const clearTimer = useCallback((): void => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const tick = useCallback(
async (jobId: string): Promise<void> => {
if (activeRef.current !== jobId) return;
try {
const { data, error } = await api.GET("/jobs/{job_id}", {
params: { path: { job_id: jobId } },
});
if (activeRef.current !== jobId) return; // 中途切换/卸载。
if (error || !data) {
dispatch({ type: "fail", error: "轮询任务状态失败" });
return;
}
const job = narrowJob(data);
dispatch({ type: "tick", job });
if (isTerminal(job.status)) {
activeRef.current = null;
return;
}
timerRef.current = setTimeout(() => void tick(jobId), POLL_INTERVAL_MS);
} catch (err: unknown) {
if (activeRef.current !== jobId) return;
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", error: message });
}
},
[],
);
const poll = useCallback(
(jobId: string): void => {
clearTimer();
activeRef.current = jobId;
void tick(jobId);
},
[clearTimer, tick],
);
const reset = useCallback((): void => {
clearTimer();
activeRef.current = null;
}, [clearTimer]);
// 卸载清理。
useEffect(() => {
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
activeRef.current = null;
};
}, []);
return { ...state, poll, reset };
}

View File

@@ -42,6 +42,18 @@ export interface PaceReport {
beat_map: number[]; beat_map: number[];
} }
// 文风漂移第四审C4 扩 T4.2):整体相似度 score + 段级漂移。
export interface StyleDriftSegment {
idx: number;
score: number;
label: string | null;
}
export interface StyleDriftReport {
score: number;
segments: StyleDriftSegment[];
}
export interface SectionEvent { export interface SectionEvent {
event: "section"; event: "section";
data: { name: string; status: SectionStatus }; data: { name: string; status: SectionStatus };
@@ -68,6 +80,17 @@ export interface PaceEvent {
beat_map: number[]; beat_map: number[];
}; };
} }
export interface StyleEvent {
event: "style";
data: {
score: number;
segments: {
idx: number;
score: number;
label?: string | null;
}[];
};
}
export interface DoneEvent { export interface DoneEvent {
event: "done"; event: "done";
data: { length: number }; data: { length: number };
@@ -81,6 +104,7 @@ export type ReviewSseEvent =
| ConflictEvent | ConflictEvent
| ForeshadowEvent | ForeshadowEvent
| PaceEvent | PaceEvent
| StyleEvent
| DoneEvent | DoneEvent
| ErrorEvent; | ErrorEvent;
@@ -89,6 +113,7 @@ const KNOWN_EVENTS = new Set([
"conflict", "conflict",
"foreshadow", "foreshadow",
"pace", "pace",
"style",
"done", "done",
"error", "error",
]); ]);
@@ -160,6 +185,7 @@ export interface ReviewStreamState {
conflicts: ReviewConflict[]; conflicts: ReviewConflict[];
foreshadow: ForeshadowSuggestion[]; foreshadow: ForeshadowSuggestion[];
pace: PaceReport | null; pace: PaceReport | null;
style: StyleDriftReport | null;
error: { code: string; message: string; request_id?: string | null } | null; error: { code: string; message: string; request_id?: string | null } | null;
} }
@@ -169,6 +195,7 @@ export const initialReviewState: ReviewStreamState = {
conflicts: [], conflicts: [],
foreshadow: [], foreshadow: [],
pace: null, pace: null,
style: null,
error: null, error: null,
}; };
@@ -202,6 +229,19 @@ export function reduceReview(
phase: "reviewing", phase: "reviewing",
pace: { ...event.data }, pace: { ...event.data },
}; };
case "style":
return {
...state,
phase: "reviewing",
style: {
score: event.data.score,
segments: event.data.segments.map((s) => ({
idx: s.idx,
score: s.score,
label: s.label ?? null,
})),
},
};
case "done": case "done":
return { ...state, phase: "done" }; return { ...state, phase: "done" };
case "error": case "error":

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
initialReviewState,
parseReviewBlock,
reduceReview,
type ReviewSseEvent,
} from "./sse";
describe("parseReviewBlock — style (C4 扩 T4.2)", () => {
it("parses a style frame", () => {
const evt = parseReviewBlock(
'event:style\ndata:{"score":87,"segments":[{"idx":3,"score":60,"label":"口语化"}]}',
);
expect(evt).toEqual({
event: "style",
data: {
score: 87,
segments: [{ idx: 3, score: 60, label: "口语化" }],
},
});
});
it("parses a degrade态 style frame (无指纹 score=100/空段)", () => {
expect(parseReviewBlock('event:style\ndata:{"score":100,"segments":[]}')).toEqual(
{ event: "style", data: { score: 100, segments: [] } },
);
});
});
describe("reduceReview — style (replace-style like pace)", () => {
it("sets style report and marks reviewing", () => {
const evt: ReviewSseEvent = {
event: "style",
data: { score: 80, segments: [{ idx: 1, score: 50, label: "突兀" }] },
};
const out = reduceReview(initialReviewState, evt);
expect(out.phase).toBe("reviewing");
expect(out.style).toEqual({
score: 80,
segments: [{ idx: 1, score: 50, label: "突兀" }],
});
});
it("replaces (not accumulates) the style report and defaults label null", () => {
const first = reduceReview(initialReviewState, {
event: "style",
data: { score: 70, segments: [{ idx: 0, score: 40 }] },
});
const second = reduceReview(first, {
event: "style",
data: { score: 95, segments: [] },
});
expect(first.style?.segments[0]).toEqual({ idx: 0, score: 40, label: null });
expect(second.style).toEqual({ score: 95, segments: [] });
});
it("leaves style untouched on a section event", () => {
const seeded = reduceReview(initialReviewState, {
event: "style",
data: { score: 88, segments: [] },
});
const out = reduceReview(seeded, {
event: "section",
data: { name: "style", status: "done" },
});
expect(out.style).toEqual({ score: 88, segments: [] });
expect(out.sections).toEqual([{ name: "style", status: "done" }]);
});
});

View File

@@ -11,12 +11,14 @@ import {
type PaceReport, type PaceReport,
type ReviewConflict, type ReviewConflict,
type ReviewStreamState, type ReviewStreamState,
type StyleDriftReport,
} from "./sse"; } from "./sse";
export interface ReviewSeed { export interface ReviewSeed {
conflicts: ReviewConflict[]; conflicts: ReviewConflict[];
foreshadow: ForeshadowSuggestion[]; foreshadow: ForeshadowSuggestion[];
pace: PaceReport | null; pace: PaceReport | null;
style: StyleDriftReport | null;
} }
type Action = type Action =
@@ -46,6 +48,7 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
conflicts: action.seed.conflicts, conflicts: action.seed.conflicts,
foreshadow: action.seed.foreshadow, foreshadow: action.seed.foreshadow,
pace: action.seed.pace, pace: action.seed.pace,
style: action.seed.style,
}; };
default: default:
return state; return state;

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { RuleView } from "@/lib/api/types";
import {
buildRuleCreateRequest,
groupByLevel,
hasUsableContent,
isRuleLevel,
RULE_LEVELS,
} from "./rules";
describe("isRuleLevel", () => {
it("accepts the four known levels only", () => {
for (const lv of RULE_LEVELS) expect(isRuleLevel(lv)).toBe(true);
expect(isRuleLevel("bogus")).toBe(false);
});
});
describe("groupByLevel", () => {
it("groups rules by level, unknown level falls to project", () => {
const rules: RuleView[] = [
{ level: "global", content: "g1" },
{ level: "project", content: "p1" },
{ level: "weird", content: "x1" },
];
const groups = groupByLevel(rules);
expect(groups.global).toEqual([{ level: "global", content: "g1" }]);
expect(groups.project).toEqual([
{ level: "project", content: "p1" },
{ level: "weird", content: "x1" },
]);
expect(groups.genre).toEqual([]);
});
it("handles undefined", () => {
expect(groupByLevel(undefined).style).toEqual([]);
});
});
describe("buildRuleCreateRequest", () => {
it("trims content", () => {
expect(buildRuleCreateRequest("project", " 禁现代词 ")).toEqual({
level: "project",
content: "禁现代词",
});
});
});
describe("hasUsableContent", () => {
it("true only when non-blank", () => {
expect(hasUsableContent(" ")).toBe(false);
expect(hasUsableContent(" x ")).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
// 规则页纯逻辑:四级合并展示、请求体组装、级别文案。
// 对齐 C3 扩GET/POST .../rules。纯逻辑便于 node 环境单测。
import type { RuleCreateRequest, RuleView } from "@/lib/api/types";
// 四级规则global→genre→style→project越具体越优先对齐后端 RuleCreateRequest Literal
export type RuleLevel = "global" | "genre" | "style" | "project";
export const RULE_LEVELS: readonly RuleLevel[] = [
"global",
"genre",
"style",
"project",
] as const;
export const RULE_LEVEL_LABELS: Record<RuleLevel, string> = {
global: "全局",
genre: "题材",
style: "文风",
project: "本作",
};
export function isRuleLevel(v: string): v is RuleLevel {
return (
v === "global" || v === "genre" || v === "style" || v === "project"
);
}
export type RuleGroups = Record<RuleLevel, RuleView[]>;
function emptyGroups(): RuleGroups {
return { global: [], genre: [], style: [], project: [] };
}
// 按 level 分组(未知 level 落 project 兜底,保持服务端顺序)。
export function groupByLevel(
rules: readonly RuleView[] | undefined,
): RuleGroups {
const groups = emptyGroups();
for (const rule of rules ?? []) {
const level = isRuleLevel(rule.level) ? rule.level : "project";
groups[level] = [...groups[level], rule];
}
return groups;
}
// 组装新增规则请求体(去空白)。
export function buildRuleCreateRequest(
level: RuleLevel,
content: string,
): RuleCreateRequest {
return { level, content: content.trim() };
}
// 内容是否可提交(非空白)。
export function hasUsableContent(content: string): boolean {
return content.trim().length > 0;
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { RuleView } from "@/lib/api/types";
import { buildRuleCreateRequest, hasUsableContent, type RuleLevel } from "./rules";
export interface UseRules {
items: RuleView[];
busy: boolean;
// 新增一条规则(乐观追加 + 失败回滚 + Toast
add: (
projectId: string,
level: RuleLevel,
content: string,
) => Promise<boolean>;
}
// 规则页UX §7列出 + 新增规则。乐观追加,失败回滚(仿 useForeshadow
export function useRules(initial: RuleView[]): UseRules {
const [items, setItems] = useState<RuleView[]>(initial);
const [busy, setBusy] = useState(false);
const toast = useToast();
const add = useCallback<UseRules["add"]>(
async (projectId, level, content) => {
if (!hasUsableContent(content)) {
toast("请填写规则正文。", "error");
return false;
}
const snapshot = items;
const optimistic: RuleView = { level, content: content.trim() };
setItems((prev) => [...prev, optimistic]);
setBusy(true);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/rules",
{
params: { path: { project_id: projectId } },
body: buildRuleCreateRequest(level, content),
},
);
if (error || !data) {
setItems(snapshot); // 回滚
toast("新增规则失败,请稍后重试。", "error");
return false;
}
// 用服务端权威行替换乐观行(去掉乐观项、追加返回项)。
setItems((prev) => [...prev.slice(0, snapshot.length), data]);
toast("已新增规则", "success");
return true;
} finally {
setBusy(false);
}
},
[items, toast],
);
return { items, busy, add };
}

View File

@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import type { JobView, PollState } from "@/lib/jobs/job";
import {
authOpenUrl,
connectPhase,
formatExpiresAt,
jobConnected,
KIMI_CODE_MODEL,
KIMI_CODE_PROVIDER,
toConnectionStatus,
toDeviceDisplay,
} from "./kimiOauth";
const poll = (over: Partial<PollState>): PollState => ({
status: "polling",
progress: 0,
job: null,
error: null,
...over,
});
const job = (over: Partial<JobView>): JobView => ({
id: "j1",
kind: "kimi_oauth",
status: "done",
progress: 100,
result: null,
error: null,
...over,
});
describe("KIMI_CODE constants", () => {
it("uses the OAuth provider + coding model names", () => {
expect(KIMI_CODE_PROVIDER).toBe("kimi-code");
expect(KIMI_CODE_MODEL).toBe("kimi-for-coding");
});
});
describe("toDeviceDisplay", () => {
it("maps start response fields", () => {
const d = toDeviceDisplay({
job_id: "abc",
user_code: "WXYZ-1234",
verification_uri: "https://auth.kimi.com/device",
verification_uri_complete: "https://auth.kimi.com/device?code=WXYZ-1234",
expires_in: 600,
interval: 5,
});
expect(d).toEqual({
userCode: "WXYZ-1234",
verificationUri: "https://auth.kimi.com/device",
verificationUriComplete: "https://auth.kimi.com/device?code=WXYZ-1234",
expiresIn: 600,
interval: 5,
});
});
it("defaults missing complete uri to null", () => {
const d = toDeviceDisplay({
job_id: "abc",
user_code: "CODE",
verification_uri: "https://auth.kimi.com/device",
verification_uri_complete: null,
expires_in: 600,
interval: 5,
});
expect(d.verificationUriComplete).toBeNull();
});
});
describe("authOpenUrl", () => {
it("prefers the complete uri when present", () => {
expect(
authOpenUrl({
userCode: "C",
verificationUri: "https://auth.kimi.com/device",
verificationUriComplete: "https://auth.kimi.com/device?code=C",
expiresIn: 600,
interval: 5,
}),
).toBe("https://auth.kimi.com/device?code=C");
});
it("falls back to the plain uri", () => {
expect(
authOpenUrl({
userCode: "C",
verificationUri: "https://auth.kimi.com/device",
verificationUriComplete: null,
expiresIn: 600,
interval: 5,
}),
).toBe("https://auth.kimi.com/device");
});
});
describe("toConnectionStatus", () => {
it("narrows connected + expires_at", () => {
expect(
toConnectionStatus({ connected: true, expires_at: "2026-07-01T00:00:00Z" }),
).toEqual({ connected: true, expiresAt: "2026-07-01T00:00:00Z" });
});
it("defaults non-string expires_at to null", () => {
expect(toConnectionStatus({ connected: false })).toEqual({
connected: false,
expiresAt: null,
});
});
});
describe("jobConnected", () => {
it("true only when result.connected === true (no token expected)", () => {
expect(jobConnected(job({ result: { connected: true, provider: "kimi-code" } }))).toBe(
true,
);
expect(jobConnected(job({ result: { connected: false } }))).toBe(false);
expect(jobConnected(job({ result: null }))).toBe(false);
});
});
describe("connectPhase", () => {
it("not started + not connected → idle", () => {
expect(
connectPhase({ started: false, connected: false, poll: poll({}) }),
).toBe("idle");
});
it("not started + connected (status query) → connected", () => {
expect(
connectPhase({ started: false, connected: true, poll: poll({}) }),
).toBe("connected");
});
it("started + polling → awaiting", () => {
expect(
connectPhase({
started: true,
connected: false,
poll: poll({ status: "polling" }),
}),
).toBe("awaiting");
});
it("started + done + job.connected → connected", () => {
expect(
connectPhase({
started: true,
connected: false,
poll: poll({
status: "done",
job: job({ result: { connected: true, provider: "kimi-code" } }),
}),
}),
).toBe("connected");
});
it("started + done but job not connected → error", () => {
expect(
connectPhase({
started: true,
connected: false,
poll: poll({ status: "done", job: job({ result: { connected: false } }) }),
}),
).toBe("error");
});
it("started + poll error (expired/denied/network) → error", () => {
expect(
connectPhase({
started: true,
connected: false,
poll: poll({ status: "error", error: "授权已过期" }),
}),
).toBe("error");
});
});
describe("formatExpiresAt", () => {
it("returns null for null/invalid input", () => {
expect(formatExpiresAt(null)).toBeNull();
expect(formatExpiresAt("not-a-date")).toBeNull();
});
it("formats a valid ISO timestamp to a non-empty string", () => {
const out = formatExpiresAt("2026-07-01T08:30:00Z");
expect(typeof out).toBe("string");
expect(out).not.toBe("");
});
});

View File

@@ -0,0 +1,101 @@
// Kimi Code OAuth device-flow 连接态纯逻辑C3 扩 K1.3K1.4 前端)。
// 把「连接状态查询」「device 启动响应」「job 轮询结果」收窄/归一成一个
// 可渲染的连接阶段connect phase组件只读结果、不含分支逻辑。
// 纯函数 + node-env 单测token 永不出现job 结果只 {connected, provider})。
import type { JobView, PollState } from "@/lib/jobs/job";
import type {
OAuthStartResponse,
OAuthStatusResponse,
} from "@/lib/api/types";
// Kimi Code 是 OAuth 订阅 plan 提供商(与 API-key provider `kimi` 分离)。
export const KIMI_CODE_PROVIDER = "kimi-code";
export const KIMI_CODE_MODEL = "kimi-for-coding";
// 连接流阶段:
// - idle未发起且未连接展示「连接」按钮
// - connected状态查询/job 完成显示已连接(展示「断开」)。
// - awaiting已拿 device code正在等待用户在浏览器授权 + 轮询 job。
// - errordevice 启动失败 / 轮询失败 / 过期 / 拒绝(展示错误 + 允许重试)。
export type ConnectPhase = "idle" | "awaiting" | "connected" | "error";
// device 启动后用户面要展示的信息(无 token
export interface DeviceDisplay {
userCode: string;
verificationUri: string;
verificationUriComplete: string | null;
expiresIn: number;
interval: number;
}
// 把 OAuthStartResponse 收窄成展示用 DeviceDisplay缺字段给安全默认
export function toDeviceDisplay(res: OAuthStartResponse): DeviceDisplay {
return {
userCode: res.user_code,
verificationUri: res.verification_uri,
verificationUriComplete: res.verification_uri_complete ?? null,
expiresIn: typeof res.expires_in === "number" ? res.expires_in : 0,
interval: typeof res.interval === "number" ? res.interval : 5,
};
}
// 优先打开的授权 URL有 complete带 user_code 预填)就用它,否则裸 verification_uri。
export function authOpenUrl(device: DeviceDisplay): string {
return device.verificationUriComplete ?? device.verificationUri;
}
// 连接状态GET .../oauth/status收窄。
export interface ConnectionStatus {
connected: boolean;
expiresAt: string | null;
}
export function toConnectionStatus(
res: OAuthStatusResponse,
): ConnectionStatus {
return {
connected: res.connected === true,
expiresAt: typeof res.expires_at === "string" ? res.expires_at : null,
};
}
// kimi_oauth job 完成态的 result{connected, provider}**绝无 token**)。
export function jobConnected(job: JobView): boolean {
return job.result?.["connected"] === true;
}
// 把「是否已发起连接 + 轮询状态」映射成连接阶段。
// - 未发起started=falseconnected ? connected : idle。
// - 已发起done 且 job.connected → connectederror → error否则 awaiting。
export function connectPhase(args: {
started: boolean;
connected: boolean;
poll: PollState;
}): ConnectPhase {
const { started, connected, poll } = args;
if (!started) {
return connected ? "connected" : "idle";
}
if (poll.status === "done") {
return poll.job !== null && jobConnected(poll.job) ? "connected" : "error";
}
if (poll.status === "error") {
return "error";
}
return "awaiting";
}
// 把 ISO8601 过期时刻格式化成可读文案(无效/缺省→null
export function formatExpiresAt(iso: string | null): string | null {
if (!iso) return null;
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return null;
return new Date(ms).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
API_KEY_PROVIDERS,
KNOWN_PROVIDERS,
applyProviderChange,
defaultModelFor,
draftsToRoutingInput,
toRoutingDrafts,
type RoutingDraft,
} from "./providers";
describe("KNOWN_PROVIDERS", () => {
it("includes kimi-code as an OAuth provider with a fixed model", () => {
const kc = KNOWN_PROVIDERS.find((p) => p.id === "kimi-code");
expect(kc).toBeDefined();
expect(kc?.auth).toBe("oauth");
expect(kc?.defaultModel).toBe("kimi-for-coding");
});
it("API_KEY_PROVIDERS excludes the OAuth provider", () => {
expect(API_KEY_PROVIDERS.some((p) => p.id === "kimi-code")).toBe(false);
expect(API_KEY_PROVIDERS.some((p) => p.id === "deepseek")).toBe(true);
});
it("includes kimi-code-key as an api_key provider with a fixed model", () => {
const kck = KNOWN_PROVIDERS.find((p) => p.id === "kimi-code-key");
expect(kck).toBeDefined();
expect(kck?.auth).toBe("api_key");
expect(kck?.defaultModel).toBe("kimi-for-coding");
});
it("API_KEY_PROVIDERS includes the static-key Kimi Code provider", () => {
expect(API_KEY_PROVIDERS.some((p) => p.id === "kimi-code-key")).toBe(true);
});
});
describe("defaultModelFor", () => {
it("returns the OAuth provider's fixed model", () => {
expect(defaultModelFor("kimi-code")).toBe("kimi-for-coding");
});
it("returns the static-key Kimi Code provider's fixed model", () => {
expect(defaultModelFor("kimi-code-key")).toBe("kimi-for-coding");
});
it("returns empty string for api_key providers / unknown", () => {
expect(defaultModelFor("deepseek")).toBe("");
expect(defaultModelFor("nope")).toBe("");
});
});
describe("toRoutingDrafts", () => {
it("expands to all tiers, filling missing ones empty", () => {
const drafts = toRoutingDrafts([
{ tier: "writer", provider: "deepseek", model: "deepseek-chat" },
]);
expect(drafts.map((d) => d.tier)).toEqual(["writer", "analyst", "light"]);
expect(drafts[0]).toEqual({
tier: "writer",
provider: "deepseek",
model: "deepseek-chat",
});
expect(drafts[1]).toEqual({ tier: "analyst", provider: "", model: "" });
});
});
describe("applyProviderChange", () => {
it("auto-fills the OAuth provider's fixed model", () => {
const next = applyProviderChange(
{ tier: "writer", provider: "", model: "" },
"kimi-code",
);
expect(next).toEqual({
tier: "writer",
provider: "kimi-code",
model: "kimi-for-coding",
});
});
it("clears the model when switching to an api_key provider", () => {
const next = applyProviderChange(
{ tier: "writer", provider: "kimi-code", model: "kimi-for-coding" },
"deepseek",
);
expect(next).toEqual({ tier: "writer", provider: "deepseek", model: "" });
});
it("does not mutate the input draft", () => {
const draft: RoutingDraft = { tier: "writer", provider: "", model: "" };
applyProviderChange(draft, "kimi-code");
expect(draft).toEqual({ tier: "writer", provider: "", model: "" });
});
});
describe("draftsToRoutingInput", () => {
it("keeps only rows with both provider and model", () => {
const input = draftsToRoutingInput([
{ tier: "writer", provider: "kimi-code", model: "kimi-for-coding" },
{ tier: "analyst", provider: "", model: "" },
{ tier: "light", provider: "deepseek", model: "" },
]);
expect(input).toEqual([
{ tier: "writer", provider: "kimi-code", model: "kimi-for-coding" },
]);
});
});

View File

@@ -1,16 +1,103 @@
// 提供商鉴权方式api_key凭据行或 oauthdevice-flow 连接按钮,如 Kimi Code
export type ProviderAuthKind = "api_key" | "oauth";
export interface KnownProvider {
id: string;
label: string;
auth: ProviderAuthKind;
// OAuth 提供商在档位路由里用的默认 modelAPI-key 提供商由用户填写/后端默认)。
defaultModel?: string;
}
// 已知提供商UX §6.10)。后端按 provider 字符串识别;这里只是 UI 候选列表。 // 已知提供商UX §6.10)。后端按 provider 字符串识别;这里只是 UI 候选列表。
export const KNOWN_PROVIDERS: { id: string; label: string }[] = [ // `kimi-code` 是 OAuth 订阅 plan 提供商device flow伪造客户端头有封号风险与 API-key
{ id: "anthropic", label: "Anthropic" }, // 的 `kimi` 分离K1.4)。`kimi-code-key` 是同一订阅 plan 的**静态 Console Key** 路径ToS 合规、
{ id: "deepseek", label: "DeepSeek" }, // 无伪造头)——普通 api_key 提供商,固定 model `kimi-for-coding`。
{ id: "kimi", label: "Kimi" }, export const KNOWN_PROVIDERS: KnownProvider[] = [
{ id: "openai", label: "OpenAI" }, { id: "anthropic", label: "Anthropic", auth: "api_key" },
{ id: "qwen", label: "通义千问" }, { id: "deepseek", label: "DeepSeek", auth: "api_key" },
{ id: "glm", label: "智谱 GLM" }, { id: "kimi", label: "Kimi", auth: "api_key" },
{ id: "gemini", label: "Gemini" }, { id: "openai", label: "OpenAI", auth: "api_key" },
{ id: "qwen", label: "通义千问", auth: "api_key" },
{ id: "glm", label: "智谱 GLM", auth: "api_key" },
{ id: "gemini", label: "Gemini", auth: "api_key" },
{
id: "kimi-code-key",
label: "Kimi Code订阅 Key",
auth: "api_key",
defaultModel: "kimi-for-coding",
},
{
id: "kimi-code",
label: "Kimi CodeOAuth",
auth: "oauth",
defaultModel: "kimi-for-coding",
},
]; ];
// API-key 凭据行只展示 api_key 提供商OAuth 提供商有独立连接区。
export const API_KEY_PROVIDERS: KnownProvider[] = KNOWN_PROVIDERS.filter(
(p) => p.auth === "api_key",
);
export const TIER_LABELS: Record<string, string> = { export const TIER_LABELS: Record<string, string> = {
writer: "写手档", writer: "写手档",
analyst: "分析档", analyst: "分析档",
light: "轻量档", light: "轻量档",
}; };
// 档位顺序(路由编辑器列出全部档位,缺省的也能配)。
export const TIERS = ["writer", "analyst", "light"] as const;
export type Tier = (typeof TIERS)[number];
// 某 provider 在路由里使用的默认 modelOAuth 提供商有固定 model其余给空串占位。
export function defaultModelFor(providerId: string): string {
return (
KNOWN_PROVIDERS.find((p) => p.id === providerId)?.defaultModel ?? ""
);
}
// 单条档位路由的可编辑草稿(纯数据)。
export interface RoutingDraft {
tier: string;
provider: string;
model: string;
}
export interface TierRouting {
tier: string;
provider: string;
model: string;
fallback?: string[] | null;
}
// 把已存路由(可能缺档位)展开成「全部档位」的可编辑草稿(缺的给空)。
export function toRoutingDrafts(existing: TierRouting[]): RoutingDraft[] {
const byTier = new Map(existing.map((r) => [r.tier, r]));
return TIERS.map((tier) => {
const row = byTier.get(tier);
return {
tier,
provider: row?.provider ?? "",
model: row?.model ?? "",
};
});
}
// 选择 provider 时自动套用该 provider 的默认 modelOAuth 固定 model否则保留已填值或清空
export function applyProviderChange(
draft: RoutingDraft,
providerId: string,
): RoutingDraft {
const def = defaultModelFor(providerId);
return { ...draft, provider: providerId, model: def !== "" ? def : "" };
}
// 草稿 → PUT body 的 tier_routing只取选了 provider+model 的行(不可变)。
export function draftsToRoutingInput(
drafts: RoutingDraft[],
): { tier: string; provider: string; model: string }[] {
return drafts
.filter((d) => d.provider.trim() !== "" && d.model.trim() !== "")
.map((d) => ({ tier: d.tier, provider: d.provider, model: d.model }));
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { useJobPoll } from "@/lib/jobs/useJobPoll";
import {
authOpenUrl,
connectPhase,
toConnectionStatus,
toDeviceDisplay,
type ConnectPhase,
type DeviceDisplay,
} from "./kimiOauth";
const START = "/settings/providers/kimi-code/oauth/start";
const DISCONNECT = "/settings/providers/kimi-code/oauth/disconnect";
export interface UseKimiOauth {
// 派生连接阶段idle/awaiting/connected/error驱动 UI。
phase: ConnectPhase;
// device 启动后展示给用户的信息user_code + 验证 URL未启动→null。
device: DeviceDisplay | null;
// 已连接时的 access token 过期时刻ISO8601可能为 null
expiresAt: string | null;
// 任一在途请求start/disconnect或正在轮询。
busy: boolean;
// 错误文案device 启动失败 / 轮询失败 / 过期 / 拒绝)。
error: string | null;
// 发起连接POST start → 拿 user_code + job_id → 开浏览器 + 轮询 job。
connect: () => Promise<void>;
// 断开POST disconnect → 复位为未连接。
disconnect: () => Promise<void>;
}
// 在浏览器打开授权页device complete URL 优先。SSR/无 window 时静默跳过。
function openVerification(device: DeviceDisplay): void {
if (typeof window === "undefined") return;
window.open(authOpenUrl(device), "_blank", "noopener,noreferrer");
}
// Kimi Code OAuth device-flow 连接编排K1.4)。
// connectPOST .../oauth/start202→ 展示 user_code + 开浏览器 → 轮询 job 到 done/failed。
// status 进页传入 initialConnected/initialExpiresAtServer Component 取)。
export function useKimiOauth(args: {
initialConnected: boolean;
initialExpiresAt: string | null;
}): UseKimiOauth {
const [connected, setConnected] = useState(args.initialConnected);
const [expiresAt, setExpiresAt] = useState<string | null>(
args.initialExpiresAt,
);
const [device, setDevice] = useState<DeviceDisplay | null>(null);
const [started, setStarted] = useState(false);
const [inFlight, setInFlight] = useState(false);
const poll = useJobPoll();
const toast = useToast();
const startedRef = useRef(false);
const phase = connectPhase({ started, connected, poll });
// 轮询终态done 且 job.connected → 已连接刷新状态error → 提示。
useEffect(() => {
if (!startedRef.current) return;
if (poll.status === "done") {
void refreshStatus().then((ok) => {
if (ok) {
setConnected(true);
toast("已连接 Kimi Code。", "success");
}
});
}
if (poll.status === "error") {
toast(`连接失败:${poll.error ?? "授权未完成或已过期"}`, "error");
}
// 仅在 poll.status 变化时反应。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poll.status]);
const refreshStatus = useCallback(async (): Promise<boolean> => {
const { data, error } = await api.GET(
"/settings/providers/kimi-code/oauth/status",
);
if (error || !data) return false;
const status = toConnectionStatus(data);
setExpiresAt(status.expiresAt);
return status.connected;
}, []);
const connect = useCallback<UseKimiOauth["connect"]>(async () => {
setInFlight(true);
try {
const { data, error } = await api.POST(START, {});
if (error || !data) {
toast("发起 Kimi Code 连接失败,请稍后重试。", "error");
return;
}
const dev = toDeviceDisplay(data);
setDevice(dev);
setStarted(true);
startedRef.current = true;
openVerification(dev);
poll.poll(data.job_id);
} finally {
setInFlight(false);
}
}, [poll, toast]);
const disconnect = useCallback<UseKimiOauth["disconnect"]>(async () => {
setInFlight(true);
try {
const { data, error } = await api.POST(DISCONNECT, {});
if (error || !data) {
toast("断开 Kimi Code 失败,请稍后重试。", "error");
return;
}
poll.reset();
startedRef.current = false;
setStarted(false);
setDevice(null);
setConnected(false);
setExpiresAt(null);
toast("已断开 Kimi Code。", "success");
} finally {
setInFlight(false);
}
}, [poll, toast]);
return {
phase,
device,
expiresAt,
busy: inFlight || (startedRef.current && poll.status === "polling"),
error: poll.status === "error" ? poll.error : null,
connect,
disconnect,
};
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { SkillView } from "@/lib/api/types";
import { groupByScope, scopeLabel, tierLabel } from "./skills";
const skill = (over: Partial<SkillView>): SkillView => ({
name: "x",
scope: "builtin",
tier: "analyst",
...over,
});
describe("scopeLabel / tierLabel", () => {
it("maps known values, passes through unknown", () => {
expect(scopeLabel("builtin")).toBe("内置");
expect(scopeLabel("mystery")).toBe("mystery");
expect(tierLabel("writer")).toBe("写手");
expect(tierLabel("xl")).toBe("xl");
});
});
describe("groupByScope", () => {
it("groups by scope in first-seen order, preserves within-group order", () => {
const skills: SkillView[] = [
skill({ name: "a", scope: "builtin" }),
skill({ name: "b", scope: "custom" }),
skill({ name: "c", scope: "builtin" }),
];
const groups = groupByScope(skills);
expect(groups.map((g) => g.scope)).toEqual(["builtin", "custom"]);
expect(groups[0].skills.map((s) => s.name)).toEqual(["a", "c"]);
expect(groups[1].skills.map((s) => s.name)).toEqual(["b"]);
});
it("handles undefined", () => {
expect(groupByScope(undefined)).toEqual([]);
});
});

View File

@@ -0,0 +1,44 @@
// 技能库纯逻辑:按 scope 分组、档位文案。
// 对齐 C3 扩GET /skills只读注册表视图。纯逻辑便于 node 环境单测。
import type { SkillView } from "@/lib/api/types";
export type SkillScope = "builtin" | "custom" | "community";
export const SCOPE_LABELS: Record<string, string> = {
builtin: "内置",
custom: "自定义",
community: "社区",
};
export const TIER_LABELS: Record<string, string> = {
writer: "写手",
analyst: "分析",
light: "轻量",
};
export function scopeLabel(scope: string): string {
return SCOPE_LABELS[scope] ?? scope;
}
export function tierLabel(tier: string): string {
return TIER_LABELS[tier] ?? tier;
}
export type SkillGroups = { scope: string; skills: SkillView[] }[];
// 按 scope 分组(保持服务端 name 升序scope 顺序按首次出现。
export function groupByScope(
skills: readonly SkillView[] | undefined,
): SkillGroups {
const order: string[] = [];
const map = new Map<string, SkillView[]>();
for (const skill of skills ?? []) {
if (!map.has(skill.scope)) {
order.push(skill.scope);
map.set(skill.scope, []);
}
map.get(skill.scope)?.push(skill);
}
return order.map((scope) => ({ scope, skills: map.get(scope) ?? [] }));
}

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import type {
ReviewHistoryItem,
StyleFingerprintResponse,
} from "@/lib/api/types";
import {
buildLearnRequest,
buildRefineRequest,
hasUsableSamples,
narrowStyleEvent,
normalizeFingerprint,
normalizeStyleDrift,
} from "./style";
const reviewItem = (over: Partial<ReviewHistoryItem>): ReviewHistoryItem => ({
id: "00000000-0000-0000-0000-000000000001",
project_id: "00000000-0000-0000-0000-000000000002",
chapter_no: 1,
...over,
});
describe("normalizeFingerprint", () => {
it("aligns dims with evidence by name, preserving key order", () => {
const resp: StyleFingerprintResponse = {
dimensions: { : "偏短", : "高" },
evidence: { : ["他来了。"], : ["如龙似虎", "若即若离"] },
version: 2,
};
const fp = normalizeFingerprint(resp);
expect(fp).toEqual({
version: 2,
dimensions: [
{ name: "句长", value: "偏短", evidence: ["他来了。"] },
{ name: "比喻密度", value: "高", evidence: ["如龙似虎", "若即若离"] },
],
});
});
it("coerces non-string dim values and missing evidence", () => {
const fp = normalizeFingerprint({
dimensions: { 节奏: 5, 视角: true },
evidence: { : ["x", 1] },
version: 1,
});
expect(fp?.dimensions).toEqual([
{ name: "节奏", value: "5", evidence: ["x"] },
{ name: "视角", value: "true", evidence: [] },
]);
});
it("returns null when fingerprint is undefined", () => {
expect(normalizeFingerprint(undefined)).toBeNull();
});
});
describe("normalizeStyleDrift", () => {
it("tightens style dict into report, filtering bad segments", () => {
const out = normalizeStyleDrift(
reviewItem({
style: {
score: 87,
segments: [
{ idx: 3, score: 60, label: "口语化" },
{ idx: 5, score: 72 },
"junk",
],
},
}),
);
expect(out).toEqual({
score: 87,
segments: [
{ idx: 3, score: 60, label: "口语化" },
{ idx: 5, score: 72, label: null },
],
});
});
it("defaults score to 100 (degrade态) and returns null when missing", () => {
expect(normalizeStyleDrift(reviewItem({ style: {} }))).toEqual({
score: 100,
segments: [],
});
expect(normalizeStyleDrift(reviewItem({ style: null }))).toBeNull();
expect(normalizeStyleDrift(undefined)).toBeNull();
});
});
describe("narrowStyleEvent", () => {
it("narrows SSE style event data with label fallback", () => {
expect(
narrowStyleEvent({ score: 90, segments: [{ idx: 1, score: 50 }] }),
).toEqual({ score: 90, segments: [{ idx: 1, score: 50, label: null }] });
});
it("returns degrade态 for non-object data", () => {
expect(narrowStyleEvent(null)).toEqual({ score: 100, segments: [] });
});
});
describe("buildLearnRequest", () => {
it("trims and drops empty samples; passes mode through", () => {
expect(buildLearnRequest([" 甲 ", "", "乙"], "update")).toEqual({
samples: ["甲", "乙"],
mode: "update",
});
});
});
describe("hasUsableSamples", () => {
it("true only when at least one non-blank sample", () => {
expect(hasUsableSamples(["", " "])).toBe(false);
expect(hasUsableSamples(["", "正文"])).toBe(true);
});
});
describe("buildRefineRequest", () => {
it("trims segment and omits empty instruction", () => {
expect(buildRefineRequest(" 这一段 ")).toEqual({ segment: "这一段" });
expect(buildRefineRequest("段", " ")).toEqual({ segment: "段" });
expect(buildRefineRequest("段", " 更紧凑 ")).toEqual({
segment: "段",
instruction: "更紧凑",
});
});
});

129
apps/web/lib/style/style.ts Normal file
View File

@@ -0,0 +1,129 @@
// 文风纯逻辑指纹归一dims+evidence、漂移归一从 chapter_reviews.style
// 学文风/回炉请求体组装。纯逻辑,便于 node 环境单测C3 扩 T4.3 / C4 扩 T4.2)。
import type {
ReviewHistoryItem,
StyleFingerprintResponse,
StyleLearnRequest,
RefineRequest,
} from "@/lib/api/types";
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
export type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
// ── 指纹16 维 + 证据UX §6.9)─────────────────────────────────
// 后端 dimensions={name:value}、evidence={name:[quotes]}(松散 JSONB
export interface FingerprintDimension {
name: string;
value: string;
evidence: string[];
}
export interface Fingerprint {
dimensions: FingerprintDimension[];
version: number;
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asDisplayValue(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
// 把松散 GET /style 响应收窄成 Fingerprint。
// dimensions 的 key 顺序即维度顺序身份evidence 按维度名对齐。
export function normalizeFingerprint(
resp: StyleFingerprintResponse | undefined,
): Fingerprint | null {
if (!resp) return null;
const dims = resp.dimensions ?? {};
const evid = resp.evidence ?? {};
const names = Object.keys(dims);
const dimensions: FingerprintDimension[] = names.map((name) => ({
name,
value: asDisplayValue(dims[name]),
evidence: asStringArray(evid[name]),
}));
return { dimensions, version: resp.version };
}
// ── 漂移第四审C4 扩 T4.2)────────────────────────────────────
// chapter_reviews.style = {score:int, segments:[{idx,score,label?}]}。
// 类型 StyleDriftReport/StyleDriftSegment 在 lib/review/sse.tsreduce 复用),上面已 re-export。
function asInt(v: unknown, fallback = 0): number {
return typeof v === "number" && Number.isFinite(v) ? Math.round(v) : fallback;
}
function asOptionalString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}
// 把松散 style dict 收窄成漂移报告;缺失/非 dict → null不渲染
export function normalizeStyleDrift(
item: ReviewHistoryItem | undefined,
): StyleDriftReport | null {
const raw = item?.style;
if (typeof raw !== "object" || raw === null) return null;
const dict = raw as Record<string, unknown>;
const segRaw = Array.isArray(dict["segments"]) ? dict["segments"] : [];
const segments: StyleDriftSegment[] = segRaw
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({
idx: asInt(s["idx"]),
score: asInt(s["score"], 100),
label: asOptionalString(s["label"]),
}));
// score 缺省 100无指纹降级态对齐后端默认
return { score: asInt(dict["score"], 100), segments };
}
// 同 style SSE 事件 data 也用此收窄reduceReview case "style" 复用)。
export function narrowStyleEvent(data: unknown): StyleDriftReport {
if (typeof data !== "object" || data === null) {
return { score: 100, segments: [] };
}
const dict = data as Record<string, unknown>;
const segRaw = Array.isArray(dict["segments"]) ? dict["segments"] : [];
const segments: StyleDriftSegment[] = segRaw
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({
idx: asInt(s["idx"]),
score: asInt(s["score"], 100),
label: asOptionalString(s["label"]),
}));
return { score: asInt(dict["score"], 100), segments };
}
// ── 请求体组装 ──────────────────────────────────────────────────
export type StyleLearnMode = "create" | "update";
// 组装学文风请求体去掉空白样本mode 透传(首学/更新仅前端语义)。
export function buildLearnRequest(
samples: readonly string[],
mode: StyleLearnMode,
): StyleLearnRequest {
const cleaned = samples.map((s) => s.trim()).filter((s) => s.length > 0);
return { samples: cleaned, mode };
}
// 至少一条非空样本才可提交(对齐后端 min_length=1
export function hasUsableSamples(samples: readonly string[]): boolean {
return samples.some((s) => s.trim().length > 0);
}
// 组装回炉请求体(段去空白;空指令省略)。
export function buildRefineRequest(
segment: string,
instruction?: string | null,
): RefineRequest {
const body: RefineRequest = { segment: segment.trim() };
const trimmed = instruction?.trim();
if (trimmed) body.instruction = trimmed;
return body;
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { RefineResponse } from "@/lib/api/types";
import { buildRefineRequest } from "./style";
export type RefineStatus = "idle" | "refining" | "done" | "error";
export interface RefineResult {
original: string;
refined: string;
}
export interface UseRefine {
status: RefineStatus;
result: RefineResult | null;
// 回炉重写某段(可选改写指令)→ 返回 {original, refined} 或 null失败
refine: (
projectId: string,
chapterNo: number,
segment: string,
instruction?: string,
) => Promise<RefineResult | null>;
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 toast = useToast();
const refine = useCallback<UseRefine["refine"]>(
async (projectId, chapterNo, segment, instruction) => {
setStatus("refining");
setResult(null);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/chapters/{chapter_no}/refine",
{
params: {
path: { project_id: projectId, chapter_no: chapterNo },
},
body: buildRefineRequest(segment, instruction),
},
);
if (error || !data) {
setStatus("error");
const code = errorCode(error);
toast(
code === "LLM_UNAVAILABLE"
? "未配置提供商,请先去设置页连一家。"
: "回炉失败,请稍后重试。",
"error",
);
return null;
}
const next = data as RefineResponse;
const outcome: RefineResult = {
original: next.original,
refined: next.refined,
};
setResult(outcome);
setStatus("done");
return outcome;
} catch {
setStatus("error");
toast("回炉请求异常,请检查网络。", "error");
return null;
}
},
[toast],
);
const reset = useCallback((): void => {
setStatus("idle");
setResult(null);
}, []);
return { status, result, refine, reset };
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { StyleFingerprintResponse } from "@/lib/api/types";
import { useJobPoll } from "@/lib/jobs/useJobPoll";
import { styleLearnResult } from "@/lib/jobs/job";
import {
buildLearnRequest,
normalizeFingerprint,
type Fingerprint,
type StyleLearnMode,
} from "./style";
export interface UseStyleLearn {
// 提交中POST /style 受理)或轮询中。
busy: boolean;
pollStatus: ReturnType<typeof useJobPoll>["status"] | "idle";
progress: number;
fingerprint: Fingerprint | null;
// 学文风POST /style → 拿 job_id → 轮询 → done 后拉最新指纹。
learn: (
projectId: string,
samples: string[],
mode: StyleLearnMode,
) => 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);
const [submitting, setSubmitting] = useState(false);
const [pollStatus, setPollStatus] = useState<
ReturnType<typeof useJobPoll>["status"] | "idle"
>("idle");
const [projectId, setProjectId] = useState<string | null>(null);
const poll = useJobPoll();
const toast = useToast();
// 仅在提交过一次学文风后才反映轮询状态initialPollState.status 默认 "polling")。
const startedRef = useRef(false);
// 轮询完成 → 拉最新指纹;失败 → toast。
useEffect(() => {
if (!startedRef.current) return;
setPollStatus(poll.status);
if (poll.status === "done" && projectId) {
void refetchFingerprint(projectId).then((fp) => {
if (fp) setFingerprint(fp);
toast("文风指纹已更新。", "success");
});
}
if (poll.status === "error") {
toast(`学文风失败:${poll.error ?? "未知原因"}`, "error");
}
// 仅在 poll.status 变化时反应。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poll.status]);
const learn = useCallback<UseStyleLearn["learn"]>(
async (pid, samples, mode) => {
setSubmitting(true);
setProjectId(pid);
try {
const { data, error } = await api.POST("/projects/{project_id}/style", {
params: { path: { project_id: pid } },
body: buildLearnRequest(samples, mode),
});
if (error || !data) {
const code = errorCode(error);
toast(
code === "LLM_UNAVAILABLE"
? "未配置提供商,请先去设置页连一家。"
: "学文风受理失败,请稍后重试。",
"error",
);
return false;
}
startedRef.current = true;
poll.poll(data.job_id);
setPollStatus("polling");
return true;
} finally {
setSubmitting(false);
}
},
[poll, toast],
);
return {
busy: submitting || pollStatus === "polling",
pollStatus,
progress: poll.progress,
fingerprint,
learn,
};
}
// 客户端拉最新指纹done 后刷新展示404/失败 → null。
async function refetchFingerprint(
projectId: string,
): Promise<Fingerprint | null> {
const { data, error } = await api.GET("/projects/{project_id}/style", {
params: { path: { project_id: projectId } },
});
if (error || !data) return null;
return normalizeFingerprint(data as StyleFingerprintResponse);
}
// 仅供测试/复用:保证 job done result 的版本回显(不阻断 UI
export function learnSummary(
poll: ReturnType<typeof useJobPoll>,
): { version: number | null; dimsCount: number | null } | null {
if (poll.status !== "done" || !poll.job) return null;
return styleLearnResult(poll.job);
}

View File

@@ -0,0 +1,5 @@
// 工作台当前章号常量(纯模块,**非** "use client")。
// 必须放在非客户端模块Server Componentwrite/page.tsx要在服务端用它拼 GET draft 的 URL
// 若从客户端组件Workbench.tsx"use client"导入Next 会把它替换成客户端引用代理,
// 服务端使用时变成抛错的函数 → draft URL 变成 `/chapters/function(){…}/draft`422
export const WORKBENCH_CHAPTER_NO = 1;

File diff suppressed because one or more lines are too long

View File

@@ -16,10 +16,66 @@
- 档位路由 `resolve_route(tier)->Route(provider,model)``config.tier_defaults`M1 仅全局默认;回退/熔断属 M5/T5.4**未实现**)。 - 档位路由 `resolve_route(tier)->Route(provider,model)``config.tier_defaults`M1 仅全局默认;回退/熔断属 M5/T5.4**未实现**)。
- 记账 `SqlAlchemyLedgerSink(session)``UsageLedger`owner_id=scope.user_id 单用户 stub列名 `cache_read`)。成本表 `pricing.py`(未知 provider/model→0 - 记账 `SqlAlchemyLedgerSink(session)``UsageLedger`owner_id=scope.user_id 单用户 stub列名 `cache_read`)。成本表 `pricing.py`(未知 provider/model→0
### C1 扩展T5.4, 2026-06-19· 多 provider 韧性(回退链 + 熔断 + 能力协商/降级 owner @llm 状态: 稳定
- 来源:`ARCHITECTURE.md §4.4(能力协商/降级)/§4.5(回退/重试/熔断)`。**仅加字段/形参,不破既有调用**(旧 `Gateway(adapters, ledger, resolver=resolve_route)` 仍可用)。
- **`ServedBy` 加字段**C1 形变,**仅加可选 bool默认 False向后兼容**`ServedBy(provider, model, fell_back=False, degraded=False)``degraded` 标能力降级(所选 provider 不支持原生结构化输出,改走 instructor JSON-提示路径)。→ **OpenAPI 表面**`ServedBy` 不直接出 APIreview/draft SSE 不回 served_bysettings/providers 也不含它)→ 经核 **本次无 OpenAPI 形变,前端无需 re-gen**;若后续把 `served_by` 暴露到响应体,再触发 gen:api。
- **`Gateway` 构造扩**(关键字,全可选):`Gateway(adapters, ledger, *, chain_resolver: Callable[[Tier], list[Route]] | None=None, resolver: Callable[[Tier], Route] | None=None, max_retries=2, breaker: CircuitBreaker | None=None)``chain_resolver`(回退链)优先;`resolver`单路由M1 兼容)被自动包成单元素链;皆缺省回退到默认 `resolve_chain``run`/`stream` 行为:沿链逐 provider→瞬时失败退避重试 R 次→仍失败/熔断打开/无适配器则切下一个;首个成功者服务,非链首即 `served_by.fell_back=True`;链耗尽抛 `AppError(LLM_UNAVAILABLE)`。记账记**实际服务方** provider/model回退后不记主模型。流式仅在首块产出前可切产出后中途失败上抛不静默重连§4.5)。
- **回退链解析routing.py**`resolve_chain(tier)->list[Route]`(默认仅全局 `tier_defaults`,单元素);`chain_from_routing(tier, primary:str, fallback:list[str])->list[Route]`(据 DB `tier_routing` 行去重保序构链——供 apps/api 包成 `ChainResolver` 注入网关,**apps/api 待接线**`build_gateway_for_tier` 目前仍只注主 provider 适配器 + `resolver=resolve_route`,要启用回退须按 fallback 预备多个适配器 + 传 `chain_resolver`)。`ChainResolver = Callable[[Tier], list[Route]]` 类型别名导出。
- **熔断器**`CircuitBreaker(*, threshold=5, reset_seconds=30.0, clock=time.monotonic)``is_open(provider)`/`record_failure`/`record_success`;连续失败超阈值→短时熔断(网关直接跳过该 provider 走回退);成功清零;冷却窗口过半开放行。**默认每个 Gateway 实例自带一个 breaker**(进程内、非跨实例共享)——跨请求持久熔断需调用方注入共享 breaker。
- **瞬时错误契约**:适配器把厂商 429/超时/5xx/连接错误翻译为 `ww_llm_gateway.errors.TransientProviderError`OpenAI 兼容/Anthropic/Gemini 适配器均已包装,按异常类名 + status_code 判定);网关另把 `AppError(RATE_LIMITED)` 也视作可重试/可回退。非瞬时错误(内容策略拒绝等)原样上抛、不重试。
- **新适配器**(均经注入的客户端 Protocol**测试不联网、不硬 import 厂商 SDK**
- `AnthropicAdapter(provider, client: AnthropicClient, *, structured_client?)``capabilities()=(structured_output=True, prefix_cache=True, thinking=True)`;缓存断点经 system 块 `cache_control:{type:ephemeral}`§4.6);结构化经 `instructor.from_anthropic`(懒构建)。
- `GeminiAdapter(provider, client: GeminiClient)``capabilities()=(structured_output=True, prefix_cache=False, thinking=True)`;结构化经 `config.response_mime_type=application/json + response_schema`,文本回 `model_validate_json`
- `OpenAICompatAdapter` 不变(新增瞬时错误包装,参与回退链)。
- **依赖**`packages/llm_gateway/pyproject.toml``tenacity>=8.2`(已 `uv sync`;原为 instructor 传递依赖)。**`anthropic`/`google-genai` SDK 已由 @devops 加(已 `uv sync`**——适配器仍懒导入/注入客户端,单测零依赖。
#### C1 扩 follow-up #22026-06-19· `build_adapter` provider→适配器工厂 owner @llm 状态: 稳定
- 位置:`packages/llm_gateway/ww_llm_gateway/factory.py`,经包 `__init__` 导出 `build_adapter`
- **签名稳定apps/api `build_gateway_for_tier` 据此逐 provider 建适配器进 `adapters` dict**
`def build_adapter(provider: str, *, api_key: str, base_url: str | None = None) -> ProviderAdapter`
- **分派**`provider=="anthropic"``AnthropicAdapter`(懒 import `AsyncAnthropic(api_key=, base_url?)``provider in {"gemini","google"}``GeminiAdapter`(懒 import `genai.Client(api_key=)`**忽略 base_url**——SDK 无该形参其余deepseek/kimi/qwen/glm/openai…`OpenAICompatAdapter(provider, AsyncOpenAI(api_key=, base_url=))`
- 工厂从 api_key 构**真实**客户端(适配器内部仍是注入客户端 Protocol测试零联网故单测只断**按 provider 名选对适配器类 + provider 字段透传**`tests/test_build_adapter_factory.py`6 测),不联网。
- **@backend 行动**`build_gateway_for_tier` 现可调 `build_adapter(provider, api_key=…, base_url=…)` 替换「一律 `OpenAICompatAdapter`」,使回退链中的 anthropic/gemini 走对应专用适配器。**无 OpenAPI 形变**served_by 不出 API前端无需 re-gen。
#### C1 扩K1.2, 2026-06-19· Kimi Code 订阅 plan 适配器(伪造头 + OAuth bearer owner @llm 状态: 稳定
- 位置:`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py`,经包 `__init__` 导出 `KimiCodeAdapter`/`build_kimi_code_client`/`kimi_code_headers`/`kimi_device_id`/`kimi_device_model`/`KIMI_CODE_BASE_URL`/`KIMI_CODE_USER_AGENT`/`KIMI_CLI_VERSION`/`KIMI_CODE_PLATFORM`/`KIMI_CODE_PROVIDER`
- **provider 名**`kimi-code`(与 API-key provider `kimi` 分离,便于档位切换)。`build_adapter("kimi-code", api_key=<access_token>, base_url=None)`**K1.3 接线的缝**——`api_key` 即 OAuth **access token**OpenAI SDK 自动发 `Authorization: Bearer <token>``base_url` 缺省 → `KIMI_CODE_BASE_URL`
- **base URL**`https://api.kimi.com/coding/v1`OpenAI 兼容;`KimiCodeAdapter` 子类化 `OpenAICompatAdapter`capabilities 继承structured_output/prefix_cache=True
- **model**`kimi-for-coding` 是**路由/档位关注点**,经 `.complete(req, model)` 传入,**不**在适配器硬编码。
- **必需伪造头 = 完整 7 头opencode 规范)**`User-Agent` + 6 个 `X-Msh-*``build_kimi_code_client``kimi_code_headers()` 作为 `AsyncOpenAI(default_headers=…)`,每次请求随客户端发出。
- **头集真源校正2026-06-19= `github.com/ooojustin/opencode-kimi``src/headers.ts` `kimiHeaders()` + `src/constants.ts`1:1 镜像 kimi-cli v1.37.0**。早先曾误信 `picassio/pi-kimi-coder`(仅 UA、不带 X-Msh-*)把头集裁成 UA-only——那是分歧/错误参考,已**回退到 opencode 完整 7 头**。coding API 校验全部 7 头偏差→Moonshot 后端 `access_terminated_error: only available for Coding Agents`403。7 头精确值:
- `User-Agent` = `KimiCLI/1.37.0``f"KimiCLI/{KIMI_CLI_VERSION}"`UA 前缀 = `KimiCLI/<version>`version 必须 == `X-Msh-Version`)。
- `X-Msh-Platform` = `kimi_cli`(字面常量字符串,**非** OS 名)。
- `X-Msh-Version` = `1.37.0`= UA 里的 CLI 版本)。
- `X-Msh-Device-Name` = 主机名 ASCII 化(`socket.gethostname()`/`platform.node()`,裁非 ASCII
- `X-Msh-Device-Model` = `kimi_device_model()`macOS `f"macOS {platform.mac_ver()[0]} {platform.machine()}"`(如 `"macOS 14.5 arm64"`Windows `f"Windows {release} {machine}"`;其它 `f"{system} {release} {machine}"``platform.machine()` 原样,返回 `arm64`/`x86_64`,不归一化)。
- `X-Msh-Os-Version` = `platform.version()`OS 内核版本串,≈ Node `os.version()`)。
- `X-Msh-Device-Id` = **稳定** UUID4 hex 无连字符32 位小写)。`kimi_device_id()`env `KIMI_DEVICE_ID` 优先 → 否则读/建 `~/.kimi/device_id`(首次写一次 `uuid.uuid4().hex`、之后复用;与 kimi-cli/opencode 共享路径)。**跨调用/进程稳定,绝不每次随机**。
- 单测(不联网,断构造出的客户端 base_url + default_headers全 7 头键 + 固定字面值 + device-id 32 位小写 hex 稳定 + host 派生值非空 ASCII + bearer + 工厂分派env `KIMI_DEVICE_ID` 注入保证 CI 确定):`tests/test_kimi_code_adapter.py` + `tests/test_kimi_code_factory.py`
- **@backend 行动K1.3**OAuth device 服务刷新 token 后,`_build_provider_adapter``build_gateway_for_tier`)对 provider `kimi-code``build_adapter("kimi-code", api_key=<当前 access_token>, base_url=…)` 即得带伪造头的适配器token 刷新/获取归 K1.3,本适配器只收当前 access token。**无 OpenAPI 形变**,前端无需 re-gen。
#### C1 扩kimi-code-key, 2026-06-20· Kimi Code 订阅 plan **静态 Console Key**ToS 合规变体 owner @llm 状态: 稳定
- 位置:`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code_key.py`,经包 `__init__` 导出 `KimiCodeKeyAdapter`/`build_kimi_code_key_client`/`KIMI_CODE_KEY_PROVIDER`
- **provider 名**`kimi-code-key`(与 OAuth 的 `kimi-code`、moonshot 的 `kimi` 均分离)。`build_adapter("kimi-code-key", api_key=<console key>, base_url=None)` 走此分支。
- **与 OAuth `kimi-code` 的关键区别**`build_kimi_code_key_client` 构造**纯** `AsyncOpenAI(api_key=, base_url=KIMI_CODE_BASE_URL)`——**不设 `default_headers`**,即**不伪造 UA、不带 `X-Msh-*` 头**。Kimi Console`kimi.com/code/console`)签发的 Key 走订阅额度、命中同一 coding 端点(`https://api.kimi.com/coding/v1`+ 同 model `kimi-for-coding`,但 plain `Authorization: Bearer <key>` 即可openclaw + models.dev 确认ToS 允许第三方 Key仅 UA 篡改违规)→ 故这是**干净/合规的默认路径**(无封号风险,对照 `KimiCodeAdapter` 的伪造头变体)。
- **结构化输出**coding 端点 `kimi-for-coding` thinking 开启,与强制 `tool_choice` 互斥live 400`KimiCodeKeyAdapter` **复用** `kimi_code.build_kimi_code_structured_client`instructor `Mode.JSON`)——同 OAuth 变体的 JSON-mode 修复。
- 单测(不联网):`tests/test_kimi_code_key_factory.py`5 测:工厂分派 + coding base + bearer + **无 X-Msh-*/无 KimiCLI UA** + JSON-mode 结构化 + 显式 base_url 覆盖)。
- **@backend 接线**`kimi-code-key` 是**普通 api_key provider**auth_type="api_key",存 `api_key_enc`Fernet——经既有 `build_gateway_for_tier`/`_build_provider_adapter`/probe 路径**无改动**即可工作(只需 `provider_deps._PROVIDER_BASE_URLS` 注册 base_url。**不**触发 `_build_provider_adapter` 的 OAuth 刷新分支(其判据 `auth_type==oauth or provider=="kimi-code"` 不命中本 provider。**无 OpenAPI 形变**(用既有 `PUT /settings/providers`),前端无需 re-gen。
## C2 · DB schemaSQLAlchemy 模型 owner @db 状态: 待定义 ## C2 · DB schemaSQLAlchemy 模型 owner @db 状态: 待定义
- 来源:`ARCHITECTURE.md §3.1` DDL11 创作表 + chapter_reviews + 运营表无向量列users stub - 来源:`ARCHITECTURE.md §3.1` DDL11 创作表 + chapter_reviews + 运营表无向量列users stub
- 消费方:`@backend`(Repository/记忆服务/验收)、`@llm`(Agent reads/writes)。 - 消费方:`@backend`(Repository/记忆服务/验收)、`@llm`(Agent reads/writes)。
### C2 扩展K1.1, 2026-06-19· `provider_credentials` 加 OAuth 列 owner @db 状态: 稳定
- 背景K1 Kimi Code OAuth订阅 plan device-flow`provider_credentials` 表新增两列 + 改 `api_key_enc` 可空使一行可表「api_key 凭据」或「oauth 凭据」二选一。
- **模型变更(`packages/db/ww_db/models.py` `ProviderCredential`**
- 新增 `auth_type: Mapped[str]` = `Text, nullable=False, server_default="'api_key'"`(值域 `"api_key"` | `"oauth"`)。既有行迁移后默认 `'api_key'`,向后兼容。
- 新增 `oauth_enc: Mapped[bytes | None]` = `LargeBinary, nullable=True`。**持 Fernet 加密的 JSON 包** `{access_token, refresh_token, expires_at}`@backend K1.3 写/读:`encrypt`/`decrypt`key 取 `settings.credential_enc_key`,同 `api_key_enc` 的 Fernet
- `api_key_enc``nullable=False`**`nullable=True`**`Mapped[bytes | None]`——OAuth-only 行无 api_key既有 api_key 行迁移后仍保留其值。
- `UniqueConstraint("owner_id","project_id","provider")` **不变**OAuth provider 用独立 provider 名 `kimi-code`,与 api_key 的 `kimi` 分离,不撞约束)。
- **迁移**`packages/db/migrations/versions/1f011c42bd4d_provider_credentials_oauth_columns.py`down_revision=`220ca2e3d53f`。up: add_column auth_type/oauth_enc + alter_column api_key_enc→nullabledown 逆向。`alembic check` 无漂移。
- **⚠️ K1.3 @backend 必读**`api_key_enc` 现为 `bytes | None``apps/api/ww_api/services/credentials.py``StoredCredential.api_key_enc: bytes`dataclass 字段 line 28与 store 的 `r.api_key_enc` 赋值line 75/97mypy 报 `arg-type``bytes | None``bytes`)。**K1.1 不动 @backend 文件(目录所有权)**K1.3 接 OAuth store 时须把 `StoredCredential.api_key_enc` 改成 `bytes | None`(并加 `auth_type`/`oauth_enc` 字段/读写路径),届时 mypy 即绿。当前全仓 mypy 唯余此 2 处错(都在 credentials.py其余门禁绿ruff/format/alembic check/pytest 400 passed
## C3 · API / OpenAPI 端点 owner @backend 状态: 稳定M1 端点全落T1.7 settings + T1.4 projects/chapters, 2026-06-18 ## C3 · API / OpenAPI 端点 owner @backend 状态: 稳定M1 端点全落T1.7 settings + T1.4 projects/chapters, 2026-06-18
- 来源:`ARCHITECTURE.md §7.2` 端点清单(章节端点统一 `/projects/:id/chapters/:no/...`;含 `PUT .../draft`(自动保存)、`/refine``/jobs/:id``/reviews`)。 - 来源:`ARCHITECTURE.md §7.2` 端点清单(章节端点统一 `/projects/:id/chapters/:no/...`;含 `PUT .../draft`(自动保存)、`/refine``/jobs/:id``/reviews`)。
- 消费方:`@frontend`(经 OpenAPI→TS 客户端)。**改端点/字段 → 前端必须 `pnpm gen:api` 重生成客户端。** - 消费方:`@frontend`(经 OpenAPI→TS 客户端)。**改端点/字段 → 前端必须 `pnpm gen:api` 重生成客户端。**
@@ -54,6 +110,93 @@
- **beats 存储形**DB `outline.beats` 是 JSONB dict→写侧包成 `{"beats":[...]}` 落库API 出参 `OutlineChapterView.beats` 解包成裸 `list[str]` - **beats 存储形**DB `outline.beats` 是 JSONB dict→写侧包成 `{"beats":[...]}` 落库API 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`
- **@frontend 行动**T3.6 `pnpm gen:api` 纳入看板/大纲端点。 - **@frontend 行动**T3.6 `pnpm gen:api` 纳入看板/大纲端点。
### C3 扩展T4.3, 2026-06-19· 文风端点:学文风(jobs 异步) + 回炉 + 最新指纹(独立 `routers/style.py`,已注册)
- snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`** 重生成 TS 客户端T4.4 前置)。
- `POST /projects/{id}/style``StyleLearnRequest{samples:list[str](min_length=1), mode:Literal["create","update"]="create"}`**202** `StyleLearnResponse{job_id:uuid}`。项目不存在→404 `NOT_FOUND`;无凭据→**503** `LLM_UNAVAILABLE`(在 `get_style_extract_gateway` dep 解析阶段拦下,**调度 job 之前**,不凭空写注定失败的 job。流程`job_repo.create(project_id,"style_learn")``session.commit()`job 行在 202 前持久化供轮询)→ `background_tasks.add_task(run_job, session_factory, job.id, work)``work``run_job` **自建独立 session** 上自造 analyst 网关(`build_gateway_for_tier(session, SqlCredentialStore(session), "analyst")`+ 写侧 repo → `run_style_extraction(style_extract_spec, samples_text="\n\n".join(samples), ...)` → 拆 `dimensions_json={name:value}`/`evidence_json={name:[evidence]}``SqlStyleFingerprintWriteRepo.append`version+1→ 返回 `{version, dims_count}`(落 `jobs.result`)。`run_job` 拥有 session 生命周期/commit业务写 + job done 同一事务)。`mode` 不改落库逻辑(写侧始终 append 新版本),仅供前端区分首学/更新语义。前端经 `GET /jobs/{job_id}` 轮询。
- `GET /projects/{id}/style` → 200 `StyleFingerprintResponse{dimensions:dict, evidence:dict, version:int}`最新版本完整指纹UX §6.9。项目不存在→404无指纹→404 `NOT_FOUND`。**新增端点**ARCH §7.2 原缺读指纹端点,见 decisions 2026-06-19。读侧用写侧 repo 的 `latest`(含 evidence/version**不动 C5 读侧** `SqlStyleRepo.latest`/`StyleView`(只 dimensionsassemble 用),故 assemble/C5 行为不变。
- `POST /projects/{id}/chapters/{no}/refine``RefineRequest{segment:str(min_length=1), instruction:str|None=None}` → 200 `RefineResponse{original:str, refined:str}`。同步回炉writer 网关(`get_refine_gateway``run(refiner_spec)``output_schema=None` → 取 `resp.text` 为 refined输入 = `【待重写段落】{segment}` + 可选 `【改写指令】{instruction}`。**不写库**(不变量 #3,作者采纳经既有 draft 自动保存合入);**末尾 `session.commit()`**(网关 ledger add-only否则 usage_ledger 静默丢失,同 draft/review 纪律。项目不存在→404无凭据→503。
- 注入缝(`services/project_deps.py``get_style_write_repo``SqlStyleFingerprintWriteRepo`,服务 `GET /style` 读侧)、`get_style_extract_gateway`(analyst学文风的凭据探测缝)、`get_refine_gateway`(writer)。测试经 `app.dependency_overrides` override学文风后台路径 monkeypatch `style.build_gateway_for_tier`/`style.SqlStyleFingerprintWriteRepo` + `job_runner.SqlJobRepo``run_job` 自建 repo不走 dep
- 写侧 repo`packages/core/ww_core/domain/style_repo.py`,经 `ww_core.domain` 导出):`StyleFingerprintWriteRepo`/`SqlStyleFingerprintWriteRepo`(加 `Write` 避撞 C5 读侧 `SqlStyleRepo`,仿 `DigestAppendRepo` 先例):`append(project_id, *, dimensions_json, evidence_json)->int`version=当前 max+1首次 1**只 flush 不 commit**+ `latest(project_id)->StyleFingerprintView|None`(含 dimensions/evidence/version`GET /style`)。
- **@frontend 行动**T4.4 前 `pnpm gen:api` 纳入 style/refine/GET style + jobs 轮询类型。
### C3 扩展T5.5, 2026-06-19· 规则端点 `POST /projects/:id/rules`(独立 `routers/rules.py`,已注册)
- snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**T5.6 前置)。
- `POST /projects/{project_id}/rules``RuleCreateRequest{level:Literal["global","genre","style","project"], content:str(min_length=1)}`**201** `RuleView{level:str, content:str}`。非法 `level` / 空 `content`**FastAPI 422**Pydantic Literal/min_length 校验,非 AppError 信封)。
- 加规则是**作者显式动作**(不变量 #3:不经 AI 静默写库)。写一行 `rules`(绑 `project_id`),喂给 assemble 的四级合并 `merge_rules`global→genre→style→project
- 提交边界:`RuleWriteRepo.create``flush()`,端点写后 `await session.commit()`(仿 foreshadow/outline 写侧)。
- 写侧 repo`packages/core/ww_core/domain/rule_repo.py`,经 `ww_core.domain` 导出):`RuleWriteRepo`(Protocol)/`SqlRuleWriteRepo`/`RuleWriteView{project_id?,level,content}`(加 `Write` 避撞 C5 读侧 `domain.repositories.RuleView{level,content}`,仿 `OutlineWriteRepo` 先例)。
- 注入缝(`services/project_deps.py``get_rule_write_repo``SqlRuleWriteRepo`)。测试经 `app.dependency_overrides` override 注 fake repo + fake session断 commit 计数)。
- **@frontend 行动**T5.6 前 `pnpm gen:api` 纳入 `POST /rules`(规则页用)。
### C3 扩展T5.2, 2026-06-19· 生成/入库 + 读端点(独立 `routers/generation.py`已注册 owner @backend 状态: 稳定
- snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**T5.6 前置)。生成走**即时返回**预览→作者确认→入库M5-d非 jobs
- `POST /projects/{project_id}/world/generate``WorldGenerateRequest{brief:str(min1)}`**200** `WorldGenPreviewResponse{entities:[WorldEntityCardView{type:str,name:str,rules:list[str]}]}`。**预览不入库**worldbuilder writer 网关 `run_worldbuilder`);末尾 `commit()` 仅落网关 ledger。项目不存在→404无凭据→**503** `LLM_UNAVAILABLE``get_worldbuilder_gateway` dep 解析阶段拦下)。
- `POST /projects/{project_id}/characters/generate``CharacterGenerateRequest{brief:str(min1), count:int=1(ge1,le12), role:str|None}`**200** `CharacterGenPreviewResponse{cards:[CharacterCardView{name,role,traits:list[str],backstory,arc:str,speech_tics:list[str],tags:list[str],relations:[CharacterRelationView{name,kind,note?}]}]}`。**预览不入库**character-gen writer 网关注入已有角色防雷同generated_so_far=[]);末尾 commit 落 ledger。404/503 同上。
- `POST /projects/{project_id}/characters`(入库)← `CharacterIngestRequest{cards:[CharacterCardView](min1), acknowledge_conflicts:bool=false}`**201** `CharacterIngestResponse{created:list[str], rejected_tables:list[str]}`。**入库 gate**:① `precheck_generated_cards`(continuity 预检, analyst 网关) 比对卡 vs 世界观/已有角色真相源——有冲突且 `acknowledge_conflicts=false`**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`(仿 accept gate不静默入库不变量#3`acknowledge_conflicts=true` 即作者裁决放行)。② `partition_writes(character_gen_spec, {"characters":cards})` 白名单过滤(越权表→`rejected_tables` 审计 log+丢弃character-gen 只声明 writes=characters 故正常为空)。③ 写 `characters`schema list/str → DB JSONB **dict** 形变,见写侧 repo。404/503 同上。提交边界precheck 网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit 落 precheck usage
- `GET /projects/{project_id}/rules`**200** `RuleListResponse{rules:[RuleView{level,content}]}`(规则页;复用 C5 读侧 `SqlRulesRepo.all_for_project`,不动 assemble
- `GET /skills`(独立 `skills_router`prefix `/skills`)→ **200** `SkillListResponse{skills:[SkillView{name,scope,tier,reads:list[str],writes:list[str],genre?}]}`(技能库 UI`get_skill_registry` 读 registry按 name 升序)。
- 写侧 repo`packages/core/ww_core/domain/`,经 `ww_core.domain` 导出):`CharacterWriteRepo`/`SqlCharacterWriteRepo`/`CharacterWriteView{id,name,role}``character_repo.py`+ `WorldEntityWriteRepo`/`SqlWorldEntityWriteRepo`/`WorldEntityWriteView{id,type,name}``world_entity_repo.py`,预留对称入库)。**形变**`traits`/`speech_tics`(list)→`{"items":[...]}``arc`(str)→`{"text":...}``rules`(list)→`{"rules":[...]}``tags`/`relations`→直落 JSONB list仅 character 入库端点已落地world ingest 端点本期未建,仅 worldbuilder 走预览)。
- 注入缝(`services/project_deps.py``get_worldbuilder_gateway`(writer)/`get_character_gen_gateway`(writer)/`get_precheck_gateway`(analyst)/`get_character_write_repo`/`get_world_entity_write_repo`/`get_rules_read_repo`。测试经 `app.dependency_overrides` 注 fake repo + schema-routing fake 网关(按 `req.output_schema` 返 WorldGenResult/CharacterGenResult/ContinuityReview+ fake session断 commit 计数)。
- **`build_gateway_for_tier` 多 provider 接线T5.4 follow-up已完成**:据 DB `tier_routing` 取该 tier 的 `provider:model` + `fallback`,为每个可建适配器的 provider 预备适配器,注入 `chain_resolver=chain_from_routing(...)` 启用回退链。无 DB 路由行 → 退回全局 `resolve_route`(单 providerM1 兼容);无任何可用凭据 → 503。单 provider 配置 = 单元素链(行为不变)。
- **`build_adapter` per-provider 接线M5 R1 follow-up #22026-06-19已完成**`_build_provider_adapter`(原 `_build_openai_compat_adapter`)现调 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`C1 扩 follow-up #2 工厂)替代「一律 `OpenAICompatAdapter`」——OpenAI 兼容 providerdeepseek/kimi/qwen/glm/openai`_PROVIDER_BASE_URLS` 传 base_urlAnthropic/Gemini 传 `base_url=None` 走原生适配器。这样 `tier_routing` 链里配置的 Anthropic/Gemini provider 也能拿到**真实适配器**参与回退(不再被 base_url 缺失跳过)。未配凭据的 provider 返 None回退链跳过。**无 OpenAPI 形变**served_by 不出 API。真实 Anthropic/Gemini 跑通仍需 @devops`anthropic`/`google-genai` SDK 依赖(适配器懒导入/注入客户端)。
- **@frontend 行动**T5.6 前 `pnpm gen:api` 纳入 world/characters generate + characters ingest + GET rules + GET skills。
### C3 扩展M5 R1 follow-up, 2026-06-19· 设定库 Codex 读端点GET characters / world_entities owner @backend 状态: 稳定
- 补 PROGRESS「Codex 读端点缺口」余项:设定库 Codex 现可展示跨会话**全量**已入库角色/世界观(前端先前只能「生成+本次入库会话内回显」。snake_case新增端点 → **@frontend 必须 `cd apps/web && pnpm gen:api`** 重生成 TS 客户端 + CodexPage 初始列表去掉「会话内」局限(见 gotcha 2026-06-19 @frontend Codex 缺口条)。
- `GET /projects/{project_id}/characters`**200** `CharacterListResponse{characters:[CharacterCardView]}`(已入库角色全量;复用 C5 读侧 `SqlCharacterRepo`,经 `get_memory_repos``memory.character.list_for_project`**不动 assemble**)。无行 → 空列表(非 404
- `GET /projects/{project_id}/world_entities`**200** `WorldEntityListResponse{world_entities:[WorldEntityCardView]}`(已入库世界观实体全量;复用 C5 读侧 `SqlWorldEntityRepo`)。
- **形变DB JSONB→APIT5.2 ingest 形变的逆向)**`characters.traits`/`speech_tics` dict `{"items":[...]}`→裸 list、`arc` dict `{"text":...}`→str、`tags`/`relations`→直落 list复用既有 `_existing_characters` helper`world_entities.rules` dict `{"rules":[...]}`→裸 list。
- **路由**:挂既有 `generation.router`prefix `/projects`GET `/{project_id}/characters` 与 POST 同路径FastAPI 按 method 区分,无冲突。注入缝复用 `get_memory_repos`(无新 dep。测试经 `app.dependency_overrides[get_memory_repos]` 注 fake `MemoryRepos`
- **@frontend 行动**`pnpm gen:api` 纳入 `GET /projects/:id/characters` + `GET /projects/:id/world_entities``lib/api/server``fetchCharacters`/`fetchWorldEntities` + CodexPage 初始列表拉全量。
### C3 扩展(大纲读端点 follow-up, 2026-06-20· `GET /projects/{id}/outline`(挂既有 `routers/outline.py`已注册 owner @backend 状态: 稳定
- 补缺口:大纲页先前**只有** `POST .../outline`(生成+持久化),无读端点 → 重访页面已落库的大纲不回显,看似「未保存」。仿 Codex 读端点GET characters/world_entities补对称读侧。
- `GET /projects/{project_id}/outline`tag `outline`)→ **200** `OutlineResponse{chapters:list[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows:list[ForeshadowWindowView]}]}`(与 `POST .../outline` 响应**同形**,前端类型对齐),按 `chapter_no` 升序。项目存在但无大纲 → **200 空列表**(非 404项目不存在 → **404 `NOT_FOUND`**(仿 POST 的项目存在性检查)。**只读不写库**。
- **复用 C5 读侧**:扩 `OutlineRepo`(domain protocol) + `SqlOutlineRepo`(C5 assemble 读侧 repo) 加 `list_for_project(project_id)->list[OutlineView]`(仿 `CharacterRepo`/`WorldEntityRepo` 命名order_by chapter_no既有 `get(project_id, chapter_no)` 不动assemble 行为不变。新注入缝 `get_outline_read_repo`(`services/project_deps.py`,仿 `get_rules_read_repo`)。
- **beats 形变**DB `outline.beats` JSONB dict `{"beats":[...]}` → 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`(同 POST 路径 / `OutlineWriteView._to_view`)。
- **@frontend 行动**`pnpm gen:api` 纳入 `GET /projects/:id/outline`;大纲页初次加载拉已持久化大纲(去掉「生成后才显示」局限)。
### C3 扩展(草稿读端点 follow-up, 2026-06-20· `GET /projects/{id}/chapters/{no}/draft`(挂既有 `routers/projects.py`已注册 owner @backend 状态: 稳定
- 补缺口:写作工作台先前**只有** `POST .../draft`(SSE 流写) + `PUT .../draft`(自动保存) 无读端点 → 重访工作台时编辑器空白、看似「未保存」(其实 `chapters` 行在库)。仿 `GET .../outline` 补对称读侧。
- `GET /projects/{project_id}/chapters/{chapter_no}/draft`**200** `DraftView{project_id, chapter_no, volume, status, version, content, length}`**比 PUT 的 `DraftResponse``content`**——读侧需正文重建编辑器;`length`=content 字符数派生)。无草稿行**或正文空白** → **404 `NOT_FOUND`**(工作台据此呈现空编辑器,与其它「缺资源」端点一致)。**只读不写库**。
- **复用读 seam**:直接调既有 `chapter_repo.get_draft(project_id, chapter_no)->ChapterDraftView|None`(同续审 `_resolve_review_draft` 的读缝);`ChapterDraftView` 已含全部字段,无需新 repo 方法/新 view。多版本时固定返草稿版次`DRAFT_VERSION=1`)那条可编辑工作副本,与 PUT 保存的同一行。
- **@frontend 行动**`pnpm gen:api` 纳入 `GET /projects/:id/chapters/:no/draft`(新 schema `DraftView`);工作台初次加载拉已保存草稿回灌编辑器(去掉「重访看似未保存」局限)。
### C3 扩展K1.3, 2026-06-19· Kimi Code OAuth device-flow 端点(独立 `routers/kimi_oauth.py`已注册 owner @backend 状态: 稳定
- snake_case新增 3 端点 + 3 schema → **@frontend 必须 `cd apps/web && pnpm gen:api`**K1.4 前置)。**响应/job 结果/日志绝不含 access/refresh token**(只 user_code/connected/expires_at 等非密信息token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`
- `POST /settings/providers/kimi-code/oauth/start`**202** `OAuthStartResponse{job_id:uuid, user_code:str, verification_uri:str, verification_uri_complete:str|None, expires_in:int, interval:int}`。流程:`start_device_authorization(http)``job_repo.create(None,"kimi_oauth")` + `session.commit()`202 前持久化供轮询)→ `background_tasks.add_task(run_job, session_factory, job.id, _make_poll_work(device))`。后台 `work``run_job` 自建独立 session 上**循环 `poll_token`**`authorization_pending`→续轮询、`slow_down`→增大 interval、`expired`/`denied`→抛 AppError 置 job failed成功 → `upsert_oauth_credential`(加密包)+ job done结果只 `{connected:true, provider}`)。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`
- `POST /settings/providers/kimi-code/oauth/disconnect`**200** `OAuthDisconnectResponse{disconnected:bool}``delete_credential` 清 OAuth 凭据行)。
- `GET /settings/providers/kimi-code/oauth/status`**200** `OAuthStatusResponse{connected:bool, expires_at:str|None}`(解密 `oauth_enc` 取过期时刻;**无 token 本体**;解密失败/无凭据→`connected:false`)。
- **store OAuth 读写**`services/credentials.py`C2 扩 K1.1 收口):`StoredCredential``auth_type:str`/`oauth_enc:bytes|None``api_key_enc``bytes|None`K1.1 的 2 处 mypy red 已消)。新方法:`upsert_oauth_credential(owner,provider,oauth_enc)`auth_type="oauth"+清 api_key_encread-modify-write 守可空 project_id 约束)、`delete_credential(owner,provider)->bool``upsert_credential`api_key 路径)现显式 set auth_type="api_key"+清 oauth_enc。
- **`_build_provider_adapter` Kimi Code OAuth 接线**`services/project_deps.py`provider `kimi-code``auth_type="oauth"``_resolve_kimi_code_token`:解密 `oauth_enc``needs_refresh`(剩余寿命 < 300s则经临时 `httpx.AsyncClient` `kimi_oauth.refresh` + `upsert_oauth_credential` 持久化新包 刷新后的access token `build_adapter("kimi-code", api_key=<token>, base_url=…)`K1.2 工厂构带伪造头 + coding base `KimiCodeAdapter`)。 `oauth_enc``LLM_UNAVAILABLE``_PROVIDER_BASE_URLS` `"kimi-code": "https://api.kimi.com/coding/v1"`
- **服务缝**`services/kimi_oauth.py`注入 `AsyncHttpClient` Protocol = `httpx.AsyncClient.post` 子集,测试零联网):`start_device_authorization(http)->DeviceAuth``poll_token(http, device_code)->TokenSet`(一次尝试,`AuthorizationPending`/`SlowDown` 异常供调用方循环)、`refresh(http, refresh_token)->TokenSet``encrypt_oauth_bundle`/`decrypt_oauth_bundle`Fernet 复用 `security/credentials` `encrypt_api_key`/`decrypt_api_key`工作在 JSON 串上`needs_refresh(token)`client_id env `KIMI_CLIENT_ID` 可覆盖默认 `17e5f671-...`device authorization **带 `scope=kimi-code`**coding entitlement实测省略 scope`api.kimi.com/coding/v1` 401故重新带上token 交换/刷新不带 scope)。
- **@frontend 行动K1.4**`pnpm gen:api` 纳入 OAuth start/disconnect/status + 3 schema连接流 = start user_code + verification_uri 轮询 `GET /jobs/{job_id}` done已连接/failed过期/拒绝status 端点查连接态档位路由到 `kimi-code:kimi-for-coding`
## C8 · Skill registry + 表权限沙箱 owner @backend 状态: 稳定T5.5, 2026-06-19M5 R3 迁入 ww_skills 包, 2026-06-19
- 来源`ARCHITECTURE.md §5.6` / `PRODUCT_SPEC §5.5`声明式 Skill = AgentSpec「声明式权限 + apply 层白名单」,**非进程沙箱**)。消费方M5 编排/生成入库层技能库 UIT5.6 C3)。
- **实现位置M5 R3 已迁移**实现现落 `packages/skills/ww_skills/{skill_registry,skill_permissions}.py``packages/skills` 已是 uv workspace member)。`ww_skills/__init__.py` **直接导出**不再是再导出 ww_core 的门面)。消费方 `from ww_skills import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES`。**`ww_core.domain` 已删除这两个模块 + `__init__` 导出**不再经 ww_core.domain 暴露 skill 符号)。历史T5.5 曾因 skills workspace member 暂落 ww_core.domain decisions现已迁回
- **registry**`SkillRecord{name,scope,tier:Tier,system_prompt,reads:list[str],writes:list[str],genre?}`frozen`skills` 行的声明式快照input/output JSON Schema 暂不携带 Python 类型——纯声明执行)。`SkillRepo`(Protocol)`.list_all()->list[SkillRecord]``SqlSkillRepo(session)` `skills` `tier` 非法 VALIDATION)。`SkillRegistry.load(repo)`classmethodasync)→ 每条跑 `validate_declaration`越权声明 **AppError VALIDATION**加载中断)→ frozen `dict[name,AgentSpec]``get(name)->AgentSpec` NOT_FOUND)、`names()->list[str]``list_scope(scope)->list[AgentSpec]`不变量 #2只带 `tier`不解析 modelmodel 解析在网关)。
- **表权限沙箱**`skill_permissions.py`纯函数不可变`KNOWN_TABLES`10 创作表白名单projects/characters/world_entities/outline/chapter_digests/foreshadow/style_fingerprint/rules/chapters/chapter_reviews系统/运营表不在列)。`filter_reads(spec, available)->dict`注入只喂声明 `reads` 的表`partition_writes(spec, produced)->(allowed:dict, rejected:list[str])`写库只应用声明 `writes`越权表名进 rejected 供审计 log + 丢弃`validate_declaration(spec)`reads/writes `KNOWN_TABLES` VALIDATION)。不变量 #3`allowed` 仍须过验收 gate 才真入库本层只裁剪白名单不开写后门
- 注入缝`get_skill_registry``SkillRegistry.load(SqlSkillRepo(session))`async dep)。T5.2 生成入库层应用 `partition_writes` 落自定义 skill 产出仅声明 writes 越权审计仍经验收
- **@llm 注意**自定义 skill 经网关执行时复用 §5.1 机制registry spec网关按 tier 路由apply 编排/入库须调 `filter_reads`/`partition_writes` 守白名单
## C3.5 · jobs 长任务基建(写/进度/回收 + run_job owner @backend 状态: 稳定T4.1, 2026-06-18
- 来源ARCH §7.4`jobs` + `GET /jobs/:id` 轮询BackgroundTasks 无专用队列)。`jobs` + `Job` 模型 + `GET /jobs/{id}` 读端点已 T0.3 建齐T4.1 **/进度/回收** **T4.3「学文风走 jobs」** 复用
- 位置`packages/core/ww_core/domain/job_repo.py` `ww_core.domain` 导出 `JobRepo`/`JobView`/`SqlJobRepo`+ `apps/api/ww_api/services/job_runner.py``run_job`+ `services/project_deps.get_job_repo` + lifespan reaper
- **`JobView`**(frozen Pydantic, snake_case)`id:uuid``kind:str``status:str``progress:int=0``result:dict|None=None``error:str|None=None`镜像 `GET /jobs/{id}` 出参)。
- **`JobRepo`** Protocol写方法** flush** commit提交归 `run_job`/端点 `reap_zombies` commit
- `async create(project_id: uuid|None, kind: str) -> JobView`status=`queued`, progress=0
- `async set_running(job_id) -> JobView`status=`running`
- `async set_progress(job_id, pct: int) -> JobView`pct 夹取到 0..100
- `async complete(job_id, result: dict) -> JobView`status=`done`, progress=100, result=<dict>
- `async fail(job_id, error: str) -> JobView`status=`failed`, error=<str>
- `async get(job_id) -> JobView | None`
- `async reap_zombies() -> int`bulk UPDATE `running``failed`(+error 文案),返回被改条数,**自 commit**
- 状态写方法对不存在的 job 抛 `LookupError`。状态常量导出:`STATUS_QUEUED/RUNNING/DONE/FAILED``PROGRESS_COMPLETE=100``job_repo` 模块级)。
- **`run_job` 缝**`services/job_runner.py``async def run_job(session_factory: SessionFactory, job_id: uuid, work: JobWork, *, request_id: str|None=None, repo_factory=_default_repo_factory) -> None`,其中 `JobWork = Callable[[AsyncSession], Awaitable[dict]]`。语义:**自建独立 session**`session_factory`,仿 `run_overdue_scan`,请求 session 已关闭)→ `set_running``await work(session)`(业务写与 job 状态写同一 session`complete(job_id, work 返回值)``commit`;异常路在**全新** session 里 `fail(job_id, str(exc))` + `commit`**异常被吞不冒泡**(后台任务边界)。`SessionFactory` 复用 `services/foreshadow_scan.SessionFactory`= `get_session_factory`/`get_sessionmaker()`)。`repo_factory: Callable[[AsyncSession], JobLifecycleRepo]`(最小 Protocol`set_running`/`complete`/`fail`)是可注入缝,单测注 fake。
- **lifespan reaper**M4-d`main.py` `_lifespan``seed_stub_user` 之后 `SqlJobRepo(session).reap_zombies()`(幂等、自 commit——进程重启丢 BackgroundTask 的残留 `running` 行标 `failed`,用户可见可重试。
- **T4.3 行动**`POST /style``job_repo.create(project_id,"style_learn")` + commit 返 202 `{job_id}``background_tasks.add_task(run_job, session_factory, job.id, work=<部分应用 run_style_extraction→StyleFingerprintWriteRepo.append 并返回 {version,dims_count} 摘要>)``work` 内自建 gatewayanalyst+ repo`run_job` 传入的独立 session。注入缝 `get_job_repo`/`get_session_factory` 测试经 `app.dependency_overrides` override。
## C4 · 编排器接口LangGraph 写章图 owner @llm 状态: 稳定M1/T1.3, 2026-06-18M2 扩四审/验收) ## C4 · 编排器接口LangGraph 写章图 owner @llm 状态: 稳定M1/T1.3, 2026-06-18M2 扩四审/验收)
- 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3SSE。**M1 仅单 `write` 节点**;并行四审/collect/interrupt(accept) 属 M2。 - 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3SSE。**M1 仅单 `write` 节点**;并行四审/collect/interrupt(accept) 属 M2。
- 位置:`packages/core/ww_core/orchestrator/` - 位置:`packages/core/ww_core/orchestrator/`
@@ -82,16 +225,48 @@
- `normalize_review` SSE向后兼容`section`/`conflict` 不变)新增:`foreshadow{kind,code?,title,where?,note?}`(每条建议一条)、`pace{water:[{where,reason}],hook:bool,beat_map:[int]}`一条。键按字典序确定性遍历continuity<foreshadow<pace)。incomplete 审仅发 `section` 不发结果 - `normalize_review` SSE向后兼容`section`/`conflict` 不变)新增:`foreshadow{kind,code?,title,where?,note?}`(每条建议一条)、`pace{water:[{where,reason}],hook:bool,beat_map:[int]}`一条。键按字典序确定性遍历continuity<foreshadow<pace)。incomplete 审仅发 `section` 不发结果
- 消费方前端 T3.6冲突就地标注 / 伏笔看板联动 / 节拍图 ▁▃▅)、E2E T3.7 - 消费方前端 T3.6冲突就地标注 / 伏笔看板联动 / 节拍图 ▁▃▅)、E2E T3.7
### C4 扩展T4.2, 2026-06-19· 四审齐style 漂移并入)+ style SSE
- `REVIEW_SPECS = (continuity_spec, foreshadow_spec, pace_spec, style_drift_spec)``build_review_graph(...)` 默认四审齐图工厂/`run_review`/`make_review_node` spec 泛型**无改动** `req.output_schema` parsedstyle 天然套循环)。
- collect 列映射新增`extract_style(reviews)->dict|None` style ok 结果整 dict`collect_reviews` 末尾 `style=extract_style(reviews)` 传入 `review_repo.record(...)``chapter_reviews.style` JSONB 列已存在)。无指纹降级score=100/空段)仍是 `ok` 结果照常落库区别于 incompleteNone)。
- `normalize_review` SSE向后兼容新增`style{score:int, segments:[{idx:int,score:int,label:str|None}]}`一条)。键按字典序遍历 continuity<foreshadow<pace<style确定性顺序incomplete 审仅发 `section{status:incomplete}` 不发结果
- 常量/工厂导出 orchestrator `__init__``STYLE="style"``extract_style`collect`EVENT_STYLE="style"``style_event(*, score, segments)`sse)。
- 消费方前端 T4.4StylePanel 相似度 + 漂移段一键回炉`section{name:"style"}`/`style{...}` reduce)、E2E T4.5断言事件真发 + `chapter_reviews.style` 真填)。
### C6 扩展T4.2, 2026-06-19· style-auditor 双轨 + refiner spec/schema
- 位置`packages/agents/ww_agents/{schemas.py,specs.py}` `ww_agents` 导出提取节点在 `packages/core/ww_core/orchestrator/style_extract_node.py` orchestrator 导出)。
- **提取轨 schema**`StyleDimension{name:str, value:str, evidence:list[str]=[]}``StyleFingerprintResult{dimensions:list[StyleDimension]=[]}`16 = 9 通用 + 7 中文网文每维带原文证据)。
- **漂移轨 schema**`StyleDriftSegment{idx:int, score:int, label:str|None=None}``StyleDriftReview{score:int=100, segments:list[StyleDriftSegment]=[]}`**默认值即无指纹降级态**score=100/空段)。
- **`style_extract_spec`**(frozen `AgentSpec`)`name="style_extract"`, `tier="analyst"`, `output_schema=StyleFingerprintResult`, `reads=["style_fingerprint"]`, `writes=["style_fingerprint"]`声明式真写库经 T4.3)。独立生成仿 outliner**不进 review **。
- **`style_drift_spec`**`name="style"`(=section/列名), `tier="light"`, `output_schema=StyleDriftReview`, `reads=["style_fingerprint"]`, `writes=[]`只读第四审)。system_prompt 材料无指纹返回 score=100/空段」降级指令。
- **`refiner_spec`**`name="refiner"`, `tier="writer"`, `output_schema=None`纯文本重写段, `reads=[]`, `writes=[]`非持久端点同步返回 `{original,refined}` 不写库作者采纳经既有 draft 自动保存合入不变量 #3)。
- 提取节点缝**模块内自有 `GatewayRun` Protocol**不跨模块复用不在 `__init__` 重复导出 gotcha`build_style_extract_request(spec, *, samples_text, user_id, project_id)->LlmRequest`(`output_schema=StyleFingerprintResult`,`stream=False`,system 缓存断点前块) + `async run_style_extraction(spec, *, samples_text, gateway, user_id, project_id)->StyleFingerprintResult`(`gateway.run().parsed`**只读不写库**网关失败**直接上抛**不隔离parsed 违约抛 `ValueError`仿 run_outline)。
- 消费方T4.3 端点 `POST /style` BackgroundTask `run_style_extraction` `StyleFingerprintResult` `dimensions_json`/`evidence_json` `style_fingerprint` `POST .../refine` `refiner_spec`(writer 网关) `run()` 重写段返回 `{original,refined}`。**@frontend 行动**T4.4 `pnpm gen:api` 纳入 style/refine/jobs 端点
### C6 扩展T5.1, 2026-06-19· worldbuilder + character-gen spec/schema + 入库前 continuity 校验缝
- 状态**稳定**。位置`packages/agents/ww_agents/{schemas.py,specs.py}` `ww_agents` 导出生成节点在 `packages/core/ww_core/orchestrator/generation_node.py` orchestrator 导出)。**T5.2 入库端点据此构建**field 名已对齐 `world_entities`/`characters` DB T5.2 ingest 直接映射)。
- **worldbuilder schema**`WorldEntityCard{type:str, name:str, rules:list[str]=[]}`rules = **显式硬规则清单**,供 continuity 引用)、`WorldGenResult{entities:list[WorldEntityCard]=[]}`映射 `world_entities` type/name + rules JSONBDB `rules` JSONB入库可包成 `{"rules":[...]}` 或裸 list T5.2 定夺)。
- **character-gen schema**`CharacterRelation{name:str, kind:str, note:str|None=None}``CharacterCard{name:str, role:str, traits:list[str]=[], backstory:str, arc:str, speech_tics:list[str]=[], tags:list[str]=[], relations:list[CharacterRelation]=[]}`**必填**name/role/backstory/arc其余集合默认空)、`CharacterGenResult{cards:list[CharacterCard]=[]}`映射 `characters` name/role/backstory + traits/arc/speech_tics/tags/relations 入各自 JSONBDB `traits`/`arc`/`speech_tics` JSONB dict `tags`/`relations` JSONB list——T5.2 ingest 须把 `list[str]` traits/speech_tics 包进 dict 或调整 DB 列形落库`role` = 角色定位 主角/CP/对手/导师/工具人)。
- **`worldbuilder_spec`**(frozen `AgentSpec`)`name="worldbuilder"`, `tier="writer"`, `output_schema=WorldGenResult`, `reads=["projects"]`, `writes=["world_entities"]`声明式真写库经 T5.2)。独立生成仿 outliner**不进 review **。system_prompt 强调硬规则显式可校验
- **`character_gen_spec`**`name="character-gen"`, `tier="writer"`, `output_schema=CharacterGenResult`, `reads=["world_entities","characters"]`, `writes=["characters"]`system_prompt **注入「已生成卡 + 已有角色」差异化要求M5-a 防雷同)**——已有角色」「已生成卡」「防雷同字样
- 生成节点缝**模块内自有 `GatewayRun` Protocol**不跨模块复用不在 `__init__` 重复导出 gotcha导出公共函数 + 三个 context builder
- `async run_worldbuilder(spec, *, brief, project_context, gateway, user_id, project_id)->WorldGenResult`+ `build_worldbuilder_context(*, brief, project_context)->str`)。
- `async run_character_gen(spec, *, brief, count:int, role:str|None, world_context:str, existing_chars:Sequence[CharacterCard], generated_so_far:Sequence[CharacterCard], gateway, user_id, project_id)->CharacterGenResult`+ `build_character_gen_context(*, brief, count, role, world_context, existing_chars, generated_so_far)->str`——**防雷同上下文**注入已有+已生成卡的 `name(role)traits` 简表要求差异化空时给「(暂无…)」占位不崩)。
- **入库前 continuity 校验缝ARCH §6.5T5.2 **`async precheck_generated_cards(spec, *, cards:Sequence[CharacterCard], world_context:str, characters_context:str, gateway, user_id, project_id)->list[Conflict]`+ `build_precheck_context(*, cards, world_context, characters_context)->str`)。**复用 `continuity_spec`**analyst 只读跑生成卡 vs 世界观/已有角色真相源比对返回 `list[Conflict]`C6 `Conflict` 五类)。**编排器追加的检查 character-gen 直接互调** §5.4 数据流/不变量 #1)。T5.2 入库端点用返回的 conflicts gate有冲突提示作者裁决/调整不静默入库)。
- 三函数均生成用 `run()` `stream()``output_schema=spec.output_schema`system 缓存断点前块cache=True#9);网关失败**直接上抛**端点处理独立生成不做失败隔离parsed 违约抛 `ValueError`仿 run_outline/run_style_extraction**只读不写库**#3)。
- 测试替身复用 `packages/core/tests/fakes_orchestrator.py` `FakeRunGateway`/`SchemaRoutingRunGateway`/`FailingRunGateway` `req.output_schema` 路由 parsed)。
- 消费方**T5.2 端点** `POST /world/generate` `run_worldbuilder``POST /characters/generate` `run_character_gen`批量循环时把已产卡传 `generated_so_far` 防雷同)、`POST /characters`(入库) 前调 `precheck_generated_cards` gateanalyst 网关复用 `build_gateway_for_tier(session,store,"analyst")`writer `"writer"`记账闭环同既有端点末尾 commit)。
## C5 · 记忆服务 `assemble` / `select_relevant_entities` owner @backend 状态: 稳定T1.2, 2026-06-18 ## C5 · 记忆服务 `assemble` / `select_relevant_entities` owner @backend 状态: 稳定T1.2, 2026-06-18
- 来源`ARCHITECTURE.md §3.4 / §5.3`确定性选择显式+主角+近况渲染卡片缓存断点)。 - 来源`ARCHITECTURE.md §3.4 / §5.3`确定性选择显式+主角+近况渲染卡片缓存断点)。
- 位置`packages/core/ww_core/memory/`+ `domain/repositories.py`)。 - 位置`packages/core/ww_core/memory/`+ `domain/repositories.py`)。
- 输出决策中性文本 `LlmRequest` decisions 2026-06-18 - 输出决策中性文本 `LlmRequest` decisions 2026-06-18
- `AssembledContext{ stable_core:str, volatile:str, selection:SelectionTrace }` - `AssembledContext{ stable_core:str, volatile:str, selection:SelectionTrace }`
- `stable_core`断点前世界硬规则+定型主角( latest_state)+文风指纹+合并规则已排序/无时间戳/ UUID - `stable_core`断点前**作品蓝本(title/logline/premise/theme)**+世界硬规则+定型主角( latest_state)+文风指纹+合并规则已排序/无时间戳/ UUID作品蓝本是书级 spec定型 入缓存前缀首段
- `volatile`断点后注入卡片( latest_state)+伏笔窗口+近况摘要+本章 beats)。 - `volatile`断点后**写作指令请创作第 {chapter_no} 章的正文。」始终在场**+注入卡片( latest_state)+伏笔窗口+近况摘要+本章 beats)。
- **空-prompt 保证2026-06-19 bugfix**哪怕世界观/角色/大纲全空只要项目有 premise/title`stable_core`(含作品蓝本) `volatile`(含写作指令) **均非空** writer user message 永不为空 LLM `400 "message at position 0 ... must not be empty"`)。
- `SelectionTrace{ selected:list[SelectedEntity] }``SelectedEntity{ kind:"character"|"world_entity", name:str, reasons:list[SelectionReason] }``SelectionReason="explicit_beat"|"main_character"|"recent_digest"|"foreshadow_window"` - `SelectionTrace{ selected:list[SelectedEntity] }``SelectedEntity{ kind:"character"|"world_entity", name:str, reasons:list[SelectionReason] }``SelectionReason="explicit_beat"|"main_character"|"recent_digest"|"foreshadow_window"`
- 入口`async assemble(repos:MemoryRepos, project_id, chapter_no, recent_k=5)->AssembledContext`纯函数 `select_relevant_entities(*, outline, characters, world_entities, recent_digests)->SelectionTrace``render_cards(selection, characters, world_entities)->str``merge_rules(rules)->list[RuleView]`globalgenrestyleproject)。 - 入口`async assemble(repos:MemoryRepos, project_id, chapter_no, recent_k=5)->AssembledContext`纯函数 `select_relevant_entities(*, outline, characters, world_entities, recent_digests)->SelectionTrace``render_cards(selection, characters, world_entities)->str``merge_rules(rules)->list[RuleView]`globalgenrestyleproject)。
- 依赖注入`MemoryRepos` 捆绑 7 ProtocolOutline/Character/WorldEntity/Digest/Foreshadow/Style/Rules单测注入内存 fake运行时 `sql_memory_repos(AsyncSession)`**T1.4 注入点**)。 - 依赖注入`MemoryRepos` 捆绑 **8** ProtocolOutline/Character/WorldEntity/Digest/Foreshadow/Style/Rules/**Project**单测注入内存 fake运行时 `sql_memory_repos(AsyncSession)`**T1.4 注入点**)。`ProjectSpecRepo.spec(project_id)->ProjectSpecView|None``ProjectSpecView{title, logline?, premise?, theme?}` project_id `projects` 2026-06-19 新增**消费方须在 `MemoryRepos(...)` `project=` 字段**)。
- 不变量确定性选择(无向量 #6)统一 project_id 过滤3.5)latest_state 严格归 volatile(#9)。 - 不变量确定性选择(无向量 #6)统一 project_id 过滤3.5)latest_state 严格归 volatile(#9)。
- 消费方**T1.3 write 节点** 构造 `LlmRequest(system=[Block(stable_core, cache=True)], input=volatile)` - 消费方**T1.3 write 节点** 构造 `LlmRequest(system=[Block(stable_core, cache=True)], input=volatile)`

View File

@@ -14,6 +14,64 @@
--- ---
## [2026-06-19] Kimi Code OAuthtoken 存储包 + 刷新启发式 + 省略 scope + 后台轮询走 jobs — @backend (K1.3)
- 背景Kimi 订阅 plan 走 device-flow OAuth需定 token 持久化形、何时刷新、device authorization 是否带 scope、后台轮询如何挂。
- 选择:
1. **存储包**`TokenSet{access_token, refresh_token, expires_at}` 序列化为 JSON 串经 Fernet复用 `encrypt_api_key`,工作在 str 上)加密入 `provider_credentials.oauth_enc`C2 扩 K1.1 列)。`expires_at` 存**服务端驱动的绝对 UTC 时刻**(从 `expires_in` 计算入库时刻)。明文 token 绝不进日志/响应/job 结果。
2. **刷新启发式**:建网关时若 access token 剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS=300`300s 缓冲,对 ~15min access 足够),调 `refresh` 换新包并 `upsert_oauth_credential` 持久化(下次复用)。任务文档原提 `max(300, 0.5*expires_in)`——本实现采固定 300s 缓冲(不存原始 expires_inKISS对 15min access 等价于 0.5*expires_in=450s 略激进但安全)。
3. **省略 scope**device authorization 不带 `scope`(匹配研究确认的 kimi-cli v1.41.0+server 拒绝时再加 `scope=kimi-code` 兜底——本期先不带。client_id env `KIMI_CLIENT_ID` 可覆盖默认。
4. **后台轮询走 jobs**`POST .../start` 创建 `jobs(kind="kimi_oauth")` 返 202 + user_code复用 K1.1/M4 `run_job` 在独立 session 上**循环** `poll_token`(每 interval 秒,`authorization_pending` 续轮询、`slow_down` 增大 interval、过期/拒绝抛 AppError 置 job failed成功落加密 token + job done结果只 `{connected, provider}`)。前端轮询 `GET /jobs/{id}`
- 理由 / 取舍:复用既有 jobs/run_job 基建(无新长任务机制);固定 300s 缓冲省去存 expires_inYAGNI。代价refresh-on-build 每次建网关多一次解密 + 可能一次刷新网络调用(仅临近过期时)。
- 影响C3 扩OAuth 3 端点C2 扩 K1.1 的 `StoredCredential` 收口api_key_enc→nullable + auth_type/oauth_enc`_build_provider_adapter` 加 OAuth 分支。K1.4 前端连接流、K1.5 E2E真账号联网验证含封号风险用户自担
## [2026-06-19] Kimi Code OAuthdevice authorization **重新带 `scope=kimi-code`**(实测 401 后修正,超越上条第 3 点)— @backend (K1.3 fix)
- 背景K1.5 真账号联网实测——device-flow 登录成功拿到真 JWT但调 `api.kimi.com/coding/v1` 被拒 `401 Invalid Authentication`。根因device_authorization 省略了 `scope`,拿到的 token 缺 coding entitlement。
- 选择:`start_device_authorization` 的请求体在 `client_id` 之外加 `scope=kimi-code`(模块常量 `KIMI_CODE_SCOPE`)。**修正上条决策第 3 点的「省略 scope」**——`scope=kimi-code` 是 coding-agent 文档化 OAuth scope`ooojustin/opencode-kimi` `constants.ts` 发送之kimi-cli v1.41.0 已不发但服务端仍接受)。
- 范围scope **只**放 device_authorization 请求token 交换(`poll_token`/刷新(`refresh`**不带** scopeOAuth device flow 惯例,参考实现亦然)。
- 影响device-auth 请求体现为 `{"client_id": <id>, "scope": "kimi-code"}`K1.3 单测 `test_start_device_authorization_parses_response_and_sends_scope``data["scope"]=="kimi-code"`。无 OpenAPI 形变,前端无需 re-gen。
## [2026-06-19] Skill registry + 表权限沙箱迁回 `ww_skills` 包(@devops 已纳入 workspace— @backend (M5 R3)
- 背景T5.5 曾因 `packages/skills` 非 uv workspace member 把 `skill_registry`/`skill_permissions` 暂落 `ww_core.domain``ww_skills/__init__` 做再导出门面(见上 T5.5 决策)。@devops 现已把 `packages/skills` 做成真 workspace member`ww_skills` 可 import、有自己的 pyproject、`uv sync` 完成)——前提消除。
- 选择:把两个模块**物理迁入** `packages/skills/ww_skills/{skill_registry,skill_permissions}.py`(内部相对 import 从 `ww_core.domain.skill_permissions` 改为 `ww_skills.skill_permissions``ww_skills/__init__` 改为**直接导出**(不再 re-export ww_core删除 `ww_core.domain` 的两个模块文件 + `__init__` 里的 skill 导出。所有 importer`apps/api` `generation.py`/`project_deps.py` + `test_generation.py`)改 `from ww_skills import …`skill 单测从 `packages/core/tests/` 移到 `packages/skills/tests/`import 也改 `ww_skills`)。
- 理由 / 取舍恢复任务文档原定位skill 逻辑归 skills 包),物理位置与 §5.6 措辞一致ww_core 不再承载 skill 符号(关注点分离)。代价:`packages/skills/pyproject.toml` deps 需校准——`ww_skills` 直接 import `ww_db`/`ww_agents`/`ww_shared`/`ww_llm_gateway`(`Tier`),故去掉不再用的 `ww-core`、补 `ww-llm-gateway` + `sqlalchemy``SqlSkillRepo``select`/`AsyncSession`);改后 `uv sync` 重建 ww-skills。
- 影响C8 实现位置更新contracts.md C8 状态加「M5 R3 迁入 ww_skills 包」);消费方 import 路径从 `ww_core.domain`/`ww_core.domain.skill_registry``ww_skills``ww_core.domain.skill_*` 导入路径**已失效**(凡 grep 到旧路径须改)。`packages/skills/pyproject.toml` 改了 deps@backend 可编辑的 skills 目录内,但属打包元数据)——已 PROGRESS 通知 @devops 复核根锁。
## [2026-06-19] `build_gateway_for_tier` 改用 `build_adapter` 工厂per-provider 适配器)— @backend (M5 R1 #2)
- 背景T5.4 接线只为回退链里的每个 provider 一律建 `OpenAICompatAdapter`——Anthropic/Gemini 即便配进 `tier_routing` 链也因 `_PROVIDER_BASE_URLS` 无 base_url 被跳过、拿不到真实适配器。@llm 加了 `build_adapter(provider, *, api_key, base_url=None)` 工厂C1 扩 follow-up #2)按 provider 选适配器类。
- 选择:`_build_openai_compat_adapter` 改名 `_build_provider_adapter`去掉「base_url 缺失即返 None」分支改调 `build_adapter(provider, api_key=…, base_url=_PROVIDER_BASE_URLS.get(provider))`——OpenAI 兼容 provider 传 base_urlAnthropic/Gemini 传 None 走原生适配器。仅在凭据缺失时返 None回退链跳过。保留既有 503/凭据/单 provider 兼容行为。
- 理由 / 取舍:最小改动让回退链支持非 OpenAI-兼容 provideradapter 选择逻辑收敛到网关包的工厂单一真源apps/api 不再硬编码适配器类。代价:真实 Anthropic/Gemini 仍需 @devops 加 SDK 依赖(适配器懒导入)。
- 影响C3 扩build_gateway_for_tier 接线段更新);`apps/api` 去掉 `AsyncOpenAI`/`OpenAICompatAdapter` 直接 import。T5.7 切 provider E2E 可扩 Anthropic/Gemini 路径。
## [2026-06-19] 设定库 Codex 读端点复用 C5 读侧 + 反向 JSONB 解包 — @backend (M5 R1)
- 背景PROGRESS 余项①——Codex 设定库本应展示 characters/world_entities **读/管理**视图,但后端只有生成/入库写端点,前端只能「生成+本次入库会话内回显」。
- 选择:加 `GET /projects/:id/characters` + `GET /projects/:id/world_entities`**复用 C5 读侧** `SqlCharacterRepo`/`SqlWorldEntityRepo`(经既有 `get_memory_repos` dep不新增 repo、不动 assemble响应做 T5.2 ingest 形变的**逆向**解包(`{"items":[...]}`/`{"text":...}`/`{"rules":[...]}`→裸 list/str复用既有 `_existing_characters` helper。无行返空列表非 404列表语义
- 理由 / 取舍KISS——读端点是写入形变的镜像复用已有读侧 + helper 零新依赖GET 与 POST 同路径靠 method 区分无冲突。代价:读侧 CharacterView 仍是裸 dict端点层做解包与写侧 repo 的包裹对称)。
- 影响C3 扩(两读端点);@frontend `pnpm gen:api` + CodexPage 初始列表拉全量(去掉「会话内」局限,解 gotcha 2026-06-19 @frontend Codex 缺口条)。
## [2026-06-19] 角色入库 continuity gate有冲突→409 CONFLICT_UNRESOLVED作者 acknowledge 后放行 — @backend (T5.2)
- 背景ARCH §6.5 要求生成角色入库前过 continuity 校验(`precheck_generated_cards`但任务文档留了两条路线block / 返回冲突供作者裁决)。要选最简单正确、且守不变量 #3「无 AI 静默写库」。
- 选择:入库端点跑 precheck检出冲突且请求未带 `acknowledge_conflicts=true`**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[...], conflict_count}`(不写库)。作者在前端查看冲突、裁决后重发带 `acknowledge_conflicts=true` → 放行写库。零冲突 → 直接写库 201。
- 理由 / 取舍:复用既有 `CONFLICT_UNRESOLVED`(409) 码(语义=未决冲突禁写入,与 accept gate 一致),无需新增错误码;显式 `acknowledge` 标志=作者裁决的 HITL gate不静默入库守不变量 #3),比「自动返回冲突 + 另开裁决端点」更省(无新端点、无新状态表,原型 KISS。代价作者确认 = 重发一次请求(带 flag可接受。
- 影响C3 扩POST /characters`partition_writes` 在 gate 后再过写白名单character-gen 只声明 writes=characters。T5.6 前端入库流:预览→点入库→若 409 展示冲突→裁决勾选→带 acknowledge 重发。T5.7 E2E 覆盖两条路径。
## [2026-06-19] 角色卡 schema↔DB 形变落写侧 repolist→{"items"}、arc str→{"text"}、rules→{"rules"})— @backend (T5.2)
- 背景T5.1 gotcha 指出 `CharacterCard.traits`/`speech_tics`(list)/`arc`(str) 与 DB `characters` JSONB **dict** 列不同形;`WorldEntityCard.rules`(list) 与 `world_entities.rules` JSONB dict 列不同形。需定一个固定包裹形。
- 选择:写侧 repo 做转换层——`traits`/`speech_tics` 包成 `{"items":[...]}``arc` 包成 `{"text":...}``rules` 包成 `{"rules":[...]}``tags`/`relations` 是 JSONB list 直落。读回(喂防雷同/precheck反向解包同形`_existing_characters`/`_world_context`)。
- 理由 / 取舍:固定 wrapper key 让 round-trip 确定(写=读互逆),不改 @db 列类型(@db owned。代价assemble/C5 读侧 `CharacterView.traits` 仍是裸 dict本期 assemble 不消费 items 内层,无影响);若后续 assemble 要渲染 traits 文本,按同 wrapper 解包即可。
- 影响C3 扩(写侧 repo 形变契约ww_core.domain 新增 `character_repo`/`world_entity_repo`@db 若日后规整列类型为 list可去 wrapper迁移由届时 owner 执行)。
## [2026-06-19] 顺带补 `GET /projects/:id/style` 读指纹 + 写侧 repo 独立读视图 — @backend (T4.3)
- 背景UX §6.9 要展示完整 16 维指纹 + 证据,但 ARCH §7.2 端点清单**无**读指纹端点(只 `POST /style`。C5 读侧 `SqlStyleRepo.latest` 已存在,但其 `StyleView` **只含 `dimensions`**assemble 只需维度文本,不需证据/版本),扩它会牵动 C5 assemble 契约。
- 选择:(1) **加 `GET /projects/:id/style`** 返最新指纹 `{dimensions, evidence, version}`无指纹→404 `NOT_FOUND`)。(2) 不动 C5 读侧——在写侧 `SqlStyleFingerprintWriteRepo` 上加一个 `latest(project_id)->StyleFingerprintView`(含 evidence_json + version 的更全视图),`GET /style` 用它C5 `SqlStyleRepo.latest`/`StyleView`(只 dimensions供 assemble stable_core保持不变。
- 理由 / 取舍:读写分离 + 各自最小视图(同 `DigestAppendRepo` vs 读侧 `DigestRepo` 先例),不破坏 C5 assemble前端无须靠 job `result` 拼凑摘要、可直接拉完整指纹。
- 影响C3 扩(新增 `GET /style`**待回写规格**ARCH §7.2 端点清单 + PRODUCT_SPEC §6 应补 `GET /projects/:id/style`spec 一致性纪律,@docs 回写。T4.4 前端 FingerprintView 消费此端点。
## [2026-06-19] 学文风 `work` 自建网关+repo不走 FastAPI dep凭据探测前移到请求阶段 — @backend (T4.3)
- 背景:学文风走 jobsM4-c`POST /style` 立即返 202提取经 BackgroundTask `run_job` 在**独立 session** 上跑(请求 session 已关闭。但「无凭据→503」需在请求阶段就让用户知道不能 202 后再静默 fail job
- 选择:(1) `work(session)` 闭包内**自建** analyst 网关(`build_gateway_for_tier(session, SqlCredentialStore(session), "analyst")`+ 写侧 repo`run_job` 传入的独立 session不依赖 FastAPI depdep 绑的是请求 session。(2) 端点仍声明 `get_style_extract_gateway`(analyst) 依赖**仅作凭据探测**——无凭据时 dep 解析阶段抛 503`job_repo.create` 之前拦下;其返回的网关本身不被复用(请求 session 即将关闭)。
- 理由 / 取舍兼顾「BackgroundTask 必须自建 session」gotcha与「无凭据请求阶段即 503」友好、不写注定失败的 job。代价凭据被探测两次请求阶段 + 后台 work原型可接受。
- 影响C3 扩POST /style测试后台路径 monkeypatch `style.build_gateway_for_tier`/`style.SqlStyleFingerprintWriteRepo` + `job_runner.SqlJobRepo`(见 gotcha
## [2026-06-18] 网关结构化输出经可注入 `StructuredClient` 缝instructor— @llm ## [2026-06-18] 网关结构化输出经可注入 `StructuredClient` 缝instructor— @llm
- 背景:`OpenAICompatAdapter` 需接 instructor 产结构化输出C1 类型预留 `output_schema`/`parsed` 但 M1 未接线),但测试绝不可联网/碰真实 LLM。 - 背景:`OpenAICompatAdapter` 需接 instructor 产结构化输出C1 类型预留 `output_schema`/`parsed` 但 M1 未接线),但测试绝不可联网/碰真实 LLM。
- 选择:定义 `StructuredClient` Protocol`create_with_completion(*, messages, response_model, **kw) -> (parsed, raw)`,即 `instructor.AsyncInstructor` 的形adapter 构造可选注入 `structured_client`,未注入时懒构建 `instructor.from_openai(self._client)``run()` 透传 `result.parsed → LlmResponse.parsed` - 选择:定义 `StructuredClient` Protocol`create_with_completion(*, messages, response_model, **kw) -> (parsed, raw)`,即 `instructor.AsyncInstructor` 的形adapter 构造可选注入 `structured_client`,未注入时懒构建 `instructor.from_openai(self._client)``run()` 透传 `result.parsed → LlmResponse.parsed`
@@ -78,3 +136,9 @@
- 选择CLAUDE.md 只保留跨文档、易错的**规则**(含 9 条架构不变量);所有枚举型/具体值改为指向 ARCHITECTURE/PRODUCT_SPEC 对应 §(唯一真源)。新增"冲突裁决"段ARCHITECTURE > UX_SPEC/DEV_PLAN > PRODUCT_SPEC矛盾时回写上游。 - 选择CLAUDE.md 只保留跨文档、易错的**规则**(含 9 条架构不变量);所有枚举型/具体值改为指向 ARCHITECTURE/PRODUCT_SPEC 对应 §(唯一真源)。新增"冲突裁决"段ARCHITECTURE > UX_SPEC/DEV_PLAN > PRODUCT_SPEC矛盾时回写上游。
- 理由 / 取舍:消除文档间漂移面、降低每 session 上下文成本;代价是查具体值需多跳一层到 ARCHITECTURE可接受 - 理由 / 取舍:消除文档间漂移面、降低每 session 上下文成本;代价是查具体值需多跳一层到 ARCHITECTURE可接受
- 影响:编辑 CLAUDE.md 的任何 agent 遵循此纪律——契约/枚举值落在 spec 与 `memory/contracts.md`,不在 CLAUDE.md。 - 影响:编辑 CLAUDE.md 的任何 agent 遵循此纪律——契约/枚举值落在 spec 与 `memory/contracts.md`,不在 CLAUDE.md。
## [2026-06-19] T5.5 Skill registry + 表权限沙箱落 `ww_core.domain`,非 `packages/skills/ww_skills` — @backend
- 背景T5.5 任务文档把 registry/沙箱定位在 `packages/skills/ww_skills/`,但该包当前**不是 uv workspace member**(根 `pyproject.toml``[tool.uv.workspace].members` 不含它、mypy_path/ruff src 也不含),无 pyproject → 仅靠 sys.path hack 才能 importapp/测试/mypy 都无法干净消费它。根 pyproject 由 @devops 持有,自己接线会越权 + 阻塞。
- 选择:把实现落在已是 workspace member、且承载「编排/写库 apply 层」的 `ww_core.domain``skill_registry.py` + `skill_permissions.py`,经 `ww_core.domain` 导出)。`ARCHITECTURE §5.6` 明确「强制点在编排/写库层」=ww_core语义上也吻合。`packages/skills/ww_skills/__init__.py` 留**再导出门面**import 自 ww_core保留稳定「skills」导入名。
- 理由 / 取舍非阻塞、门禁可干净覆盖ruff/mypy/pytest 全跑)、不越权改根配置;代价是物理位置与任务文档措辞略偏,但功能/契约C8完整。待 @devops`packages/skills` 纳入 workspace + mypy_path 后,可平滑迁移实现到 `ww_skills` 而不动调用方(只改门面方向)。
- 影响:消费方 import `ww_core.domain``SkillRegistry`/`filter_reads`/`partition_writes`/`validate_declaration`(或经 `ww_skills` 门面)。若 @devops 后续纳入 workspace迁移由届时的 @backend/@llm 执行。

View File

@@ -7,6 +7,33 @@
--- ---
- [2026-06-20] @frontend **页面重访回显已存内容用 RSC 读 helper`lib/api/server.ts`)作初值种入 client 组件404/错误降级、绝不阻塞进页**(大纲/写作重载):先前大纲页 `initialChapters=[]`、工作台 `useState("")` → 重访空白虽然库里有数据。范式:① **大纲**`fetchOutline(projectId)``getJson<OutlineResponse>``chapters??[]`**try/catch 整体降级为 `[]`**(项目存在无大纲后端返 200 空列表,但任何错误也不该让大纲页 500`useOutline(initial)` 已据 `initial.length>0``status="ready"`,传非空即回显,生成流照常覆盖。② **草稿**`fetchDraft` 直接复用既有 `getJsonOrNull`**404→null**,后端无草稿行正是 404页面 `draft?.content ?? ""``Workbench``initialText`。③ **不要把初值塞进 stream 的 reducer**——`Workbench``useState(initialText)` 起步即可:流式那条 `useEffect` 起始 `stream.state.text` 为空、仅当 `phase∈{streaming,done,aborted}` 且文本变化才 `setText`所以初值不会被空流反扑SSE+AbortController+PUT 自动保存全不用动。④ 测纯加载/降级逻辑:`lib/api/server.test.ts``vi.stubGlobal("fetch", …new Response(JSON, {status}))` + `afterEach(vi.unstubAllGlobals)`node 环境够用,`server.ts` 只依赖 `process.env`/`fetch`),断 200 解包、空/错误→`[]`、404→null。
- [2026-06-19] @qa **Kimi OAuth E2E 要 token 真落 pgoverride `get_session_factory`→`e2e_sm`、但**不能** monkeypatch `kimi_oauth.SqlCredentialStore`K1.3 单测那样会让落库走内存 fake验不到真 pg**K1.5):与 K1.3 单测FakeSession/FakeStore/FakeJobRepo验逻辑不同E2E 要验真持久化——让后台 `work` 自建的 `SqlCredentialStore(session)` + `run_job` 默认 `SqlJobRepo` **保持真**,只 override `get_session_factory``e2e_sm`(后台 work 用真 session 写真 pg。仍须monkeypatch **模块级** `routers.kimi_oauth._default_http_client`→同一 scripted fake后台 work 自建 http 非 dep+ `app.dependency_overrides[kimi_oauth._default_http_client]`→同一 fake端点 device-auth 走 depcall[0]=device-auth、call[1..]=token 轮询顺序共享)+ monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op。断「加密非明文」`_ACCESS_TOKEN.encode() not in row.oauth_enc`Fernet 密文里找不到明文字节)+ `decrypt_oauth_bundle` 回环。`Gateway` 无公开 adapters accessor → 用 `gateway._adapters[provider]``KimiCodeAdapter._client``.base_url`/`.default_headers`/`.api_key`(同 K1.2 单测。refresh-on-build 直测 `_build_provider_adapter`(非经 HTTP更清晰字符串 target monkeypatch `"ww_api.services.project_deps.httpx.AsyncClient"` + `setattr(project_deps,"kimi_refresh",fake)`。清理:删 `provider_credentials`(kimi-code) + `jobs`(kind=kimi_oauth) + `tier_routing`(project_id 为 NULL 的全局行)。ruff E501 按**显示宽度**算(中文字符占 2 列)——含中文注释/docstring 的行别贴边 100。
- [2026-06-19] @frontend **Kimi Code OAuth 三个端点无 params/body → openapi-fetch 用 `api.POST(PATH, {})` / `api.GET(PATH)`;路径用 `as const` 字面量常量复用**K1.4`POST .../oauth/start``.../oauth/disconnect``GET .../oauth/status` 的生成 schema 全是 `path?: never; ...; requestBody?: never`——`api.POST(START, {})`(传空 init objtypecheck 绿)、`api.GET("/settings/providers/kimi-code/oauth/status")`(无第二参)。把 `start`/`disconnect` 路径抽成模块级 `const START = "/settings/...start"``api.POST` 时,**字符串字面量类型仍被 openapi-fetch 推断为合法 path key**(无需 `as const`,但抽常量去重 DRY。② 连接流复用 `useJobPoll`M4零改`connect``data.job_id``poll.poll(job_id)`;轮询终态用 `useEffect([poll.status])` + `startedRef` 守门(同 `useStyleLearn` 先例,`initialPollState.status` 默认 `"polling"` 不能进页即据此显进度)。③ kimi_oauth job 完成态 result 只 `{connected, provider}`**无 token**)——`jobConnected(job)=job.result?.["connected"]===true`**别**期待/解析任何 token 字段。④ user_code a11y`<output tabIndex={0} className="select-all">`(可读、可全选复制、可聚焦),别用纯 `<span>`。⑤ 档位路由原是只读展示K1.4 改成可编辑per-tier `<select>` provider + model input + 保存 PUT `{tier_routing}`);选 OAuth providerkimi-code`applyProviderChange` 自动套 `defaultModel="kimi-for-coding"`API-key provider 清空 model 待填。
- [2026-06-19] @backend **Kimi OAuth 后台轮询 work 自建 http 走模块级 `_default_http_client()`(非 dep——测试须 monkeypatch 它 + `job_runner.SqlJobRepo`,不能只 override dep**K1.3`POST .../start``http` 是 FastAPI dep端点用但后台 `work``run_job` 独立 session 里调**模块级** `routers.kimi_oauth._default_http_client()` 自建 http请求 dep 已失效——E2E/单测必 `monkeypatch.setattr("ww_api.routers.kimi_oauth._default_http_client", lambda: fake_http)`(同一 scripted fake 实例供端点 device-auth call[0] + work poll call[1...] 顺序共享)+ `monkeypatch.setattr(job_runner,"SqlJobRepo", lambda s: fake_job_repo)`run_job 默认 repo_factory 引模块级 SqlJobRepoFakeSession 无 .execute同 T4.3 gotcha+ `monkeypatch.setattr(routers.kimi_oauth,"SqlCredentialStore", lambda s: shared_store)`work 自建 store 落库)+ override `get_session`→FakeSession/`get_session_factory`→FakeSessionFactory + monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op不真等 interval 秒。TestClient 同步跑完 background task断言稳定。
- [2026-06-19] @backend **`AsyncHttpClient` 最小 Protocol 故意不含 `aclose`(只 `post`router work 关客户端用 `getattr(http,"aclose",None)`**K1.3service 函数start/poll/refresh只需 `.post`,给 Protocol 加 `aclose` 会逼所有测试 fake 实现它mypy red`httpx.AsyncClient``aclose`,故 router `work``finally``aclose = getattr(http,"aclose",None); if aclose: await aclose()`——既关真客户端、又不污染最小 Protocol、fake 无需实现 aclose。**测试模块级属性 monkeypatch`project_deps.httpx`/`kimi_oauth.asyncio`mypy strict 报 `attr-defined`(模块未 re-export——用字符串 target `monkeypatch.setattr("pkg.mod.attr.sub", val)`mypy 不静态校验字符串)规避,别 `setattr(mod.attr, "sub", val)`。**
- [2026-06-19] @llm **Kimi Code 适配器:伪造头走 `AsyncOpenAI(default_headers=…)`、device_id 走 env-或-`uuid5` 确定性缺省绝不随机UA 验证为 `KimiCLI/1.5` 且参考实现未设 X-Msh-***K1.2):① coding 端点 OpenAI 兼容 → `KimiCodeAdapter` **直接子类化 `OpenAICompatAdapter`**(零重复 complete/stream/结构化逻辑),区别只在工厂构造的客户端带 `default_headers` + coding base_url + access_token 当 api_keySDK 自动发 `Authorization: Bearer`)。② `X-Msh-Device-Id` 必须**跨调用稳定**`kimi_device_id()` = env `KIMI_DEVICE_ID` 优先,否则 `uuid5(NAMESPACE_DNS, "ww.kimi-code.device")` 模块级常量——**别用 `uuid4()` 每次新建**(每次随机 device id 会让 Kimi 侧把每次调用当新设备)。③ **校验对照 `picassio/pi-kimi-coder` `extensions/index.ts`**:它对 coding API 只显式设 `User-Agent: "KimiCLI/1.5"`(精确值,本实现采用)、**没有** X-Msh-* 头——与 PROGRESS K1 契约「缺 X-Msh→403」**不符**。本实现按「UA 必需(已验证)+ X-Msh-* 附带(契约要求、真客户端会发、额外标识无害)」处理;**到底缺 X-Msh 会不会 403 须 K1.3/K1.5 对真 api.kimi.com 联网验证**(含封号风险)。④ 单测断「构造出的 `AsyncOpenAI` 携带正确 `base_url`/`default_headers`/`api_key`」即可,零联网;工厂分派测试访问 `adapter._client` 须先 `isinstance(adapter, KimiCodeAdapter)` 收窄(`build_adapter` 返回 `ProviderAdapter` Protocol 无 `_client`,否则 mypy `attr-defined`)。
- [2026-06-19] @db **K1.1 把 `provider_credentials.api_key_enc` 改 nullable 后,跨边界打穿到 @backend `credentials.py` mypy 报错——这是 K1.3 要吸收的K1.1 不越权修**`models.py``api_key_enc: Mapped[bytes | None]`,但 `apps/api/ww_api/services/credentials.py``StoredCredential.api_key_enc: bytes`dataclass+ `SqlCredentialStore.list_credentials/get_credential``r.api_key_enc`(现 `bytes|None`)→ 2 处 `arg-type` 错。@db 只拥有 `packages/db/`,不改 `apps/api/`(目录所有权)。**K1.3 @backend 行动**`StoredCredential.api_key_enc``bytes | None`(顺带加 `auth_type`/`oauth_enc` 字段 + OAuth 读写mypy 即绿。首个新迁移M1M5 全零建表)= K1 新功能预期内。
- [2026-06-19] @db **新迁移文件 autogenerate 后须手修两处再过 ruff**:① autogenerate 模板把 `from sqlalchemy.dialects import postgresql` **写两遍**(重复 importruff F811/格式炸)→删一行;② 模板用单引号 + import 顺序(`from alembic import op``import sqlalchemy as sa` 前)不合本仓 ruff双引号 + isort→照初始迁移 `220ca2e3d53f` 的样式手排(`import sqlalchemy as sa``from alembic import op``from sqlalchemy.dialects import postgresql`双引号docstring 后空行)。改完跑 `uv run ruff format` + `ruff check` 验。
- [2026-06-19] @llm **真 `anthropic`/`google-genai` SDK 装上后,注入客户端 Protocol 与 SDK 具体类型在 mypy strict 下不互通——在边界 `cast` 解决,别硬改 Protocol**R2 follow-up #2):① `instructor.from_anthropic(self._client)` 的重载只收 SDK 具体 `AsyncAnthropic|...` 联合,`AnthropicClient` Protocol 不匹配 → adapter 内 `cast("AsyncAnthropic", self._client)` 选中 AsyncInstructor 重载(`TYPE_CHECKING` 下 import避免运行时硬依赖 SDK。② `factory.build_adapter` 把**真实** `AsyncAnthropic`/`genai.Client` 传给 adapter 时SDK 客户端的 `messages`/`aio`**read-only 属性**,而 Protocol 成员默认 **settable** → mypy 报「expected settable variable, got read-only attribute」adapter 仅**读取**这些属性,故在工厂里 `cast("AnthropicClient"/"GeminiClient", client)` 跨过约束。**测试替身缝完好**adapter 仍声明 Protocol 形参fake 仍可注入。gemini 无 instructor 那条错(它走 `response_schema` 非 instructor只有 factory 的 read-only 那条。
- [2026-06-19] @backend **Codex 读端点缺口已补(解紧邻下面这条 @frontend gap+ skill impl 迁回 `ww_skills` 包**M5 R1/R3`GET /projects/:id/characters` + `GET /projects/:id/world_entities` 已落(复用 C5 读侧 `SqlCharacterRepo`/`SqlWorldEntityRepo`,反向 JSONB 解包 `{"items":[...]}`→list/`{"text":...}`→str/`{"rules":[...]}`→list无行返空列表@frontend `pnpm gen:api` 后 CodexPage 初始列表可拉**跨会话全量**,去掉「会话内回显」局限。② skill registry/沙箱从 `ww_core.domain` **迁到** `packages/skills/ww_skills/`——`from ww_core.domain import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES` **已失效**,改 `from ww_skills import …`。③ 改 `packages/skills/pyproject.toml` deps去 ww-core、补 ww-llm-gateway+sqlalchemy后**必须 `uv sync`** 重建 ww-skills否则按旧元数据解析。④ `build_gateway_for_tier` 现用 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`(去掉 apps/api 直接 `AsyncOpenAI`/`OpenAICompatAdapter` importanthropic/gemini 传 base_url=None 走原生适配器。
- [2026-06-19] @frontend **【RESOLVED 2026-06-19】Codex 读端点缺口已闭合M5 R1 follow-up #1**@backend`GET /projects/:id/characters` / `GET .../world_entities`C3 扩,反向 JSONB 解包成 API 友好字段)后,前端 `gen:api` 纳入 `CharacterListResponse{characters?:[CharacterCardView]}` / `WorldEntityListResponse{world_entities?:[WorldEntityCardView]}`+`lib/api/types` 别名);`lib/api/server``fetchCharacters`/`fetchWorldEntities`(无行→空列表,非 404仿 `fetchForeshadow`codex routeServer Component`Promise.all` 拉项目+人物+世界观全量传初始数据;`CodexPage` 渲染**跨会话持久化真源**(刷新不再空),本会话新入库卡经 `mergeCharacterCards`(纯逻辑,按 name 去重、持久化优先、node 单测)合并补显(不与真源重复)。世界观本期无入库端点,故只渲染真源 + 生成器预览(无 session 合并)。**「会话内回显」局限已去除**。原 T5.3「生成+本次入库会话内回显」管理入口仍保留generate→ingest 后即时见新行)。
- [2026-06-19] @frontend **入库 409 冲突详情形 = accept gate 的 `details.conflicts`ReviewConflict 形),不是 missing_conflict_indices**T5.3`POST /characters` 的 409 CONFLICT_UNRESOLVED 信封 `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`continuity 预检产出C3 扩 T5.2),区别于 accept 的 `details{missing_conflict_indices, conflict_count}`(裁决覆盖缺口)。前端 `extractIngestConflicts``error.code==="CONFLICT_UNRESOLVED"` + 收窄 `details.conflicts`(复用 `lib/review/sse``ReviewConflict` 形 + history.ts 同款安全收窄);裁决流 = 展示冲突 → 作者确认 → 带 `acknowledge_conflicts=true` 重发(非逐条 conflict_index整体放行对齐后端 gate 语义)。错误码同款 503 LLM_UNAVAILABLE 引导去设置(仿 useRefine
- [2026-06-19] @frontend **命令面板⌘K走全局 keydown + RootLayout 挂载,纯过滤/高亮逻辑抽 `lib/command/palette` node-env 单测**T5.6`CommandPaletteMount`client`usePathname` 注入项目上下文 → `projectIdFromPath` 正则抽 `/projects/<id>`;命令清单(导航 + 生成动作)+ `filterCommands`(title/keywords 大小写无关子串) + `moveHighlight`/`clampHighlight`(循环) 全是纯函数、node 单测。组件层只管 ⌘K/Ctrl+K toggle`window.keydown`)、焦点(打开 `inputRef.focus`、Esc/↑↓/Enter、a11yrole=dialog/listbox/option + aria-selected + motion-safe。生成动作生成角色/世界观)= 跳 `codex?gen=character|world`CodexPage 读 searchParams 直开对应 tab无需独立 modal 路由)。
- [2026-06-19] @backend **测 `build_gateway_for_tier` 多 provider 接线要用真 Fernet key不是 `"x"*44`**T5.2):该函数解密凭据建适配器,凭据是 `encrypt_api_key(..., key=CREDENTIAL_ENC_KEY)` 加的密key 必须是合法 Fernet32 字节 url-safe base64否则 `decrypt_api_key``CredentialKeyError`。生成一个:`uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`,设 `os.environ["CREDENTIAL_ENC_KEY"]=<key>` + `get_settings.cache_clear()`lru_cache。生成/入库**端点**测试不碰真解密override 网关 dep 注 schema-routing fake故那些仍可用 `"x"*44`;只有直接调 `build_gateway_for_tier` 的单测需真 key。
- [2026-06-19] @backend **生成/入库/precheck 端点测试用 schema-routing fake 网关(按 `req.output_schema` 返不同 parsed**T5.2):一次 ingest 请求触发 precheck`output_schema=ContinuityReview`world/character generate 各触发 `WorldGenResult`/`CharacterGenResult`——单一固定-parsed 的 `FakeReviewGateway` 不够。端点测试自带 `_SchemaRoutingGateway({Schema: instance})``run``req.output_schema` 路由,未命中返 None三个网关 depworldbuilder/character_gen/precheckoverride 成同一个。**生成预览端点也要 `commit()`**(网关 ledger add-only → 不 commit 则 usage 静默丢,同 draft 坑)——断言 `session.commits==1` 即便预览不写业务表;冲突 409 路径同样 commit 落 precheck usage。
- [2026-06-19] @llm **回退/熔断要让适配器把瞬时故障翻译成 `TransientProviderError`,否则网关只重试/回退它认得的错**T5.4`Gateway` 的重试谓词 `_is_retryable` 只认 `TransientProviderError``AppError(RATE_LIMITED)`;普通 `Exception`/厂商原生异常**不重试不回退、直接上抛**内容策略拒绝等本就该让作者知情§4.5)。故每个适配器在 `complete`/`stream``except` 里按异常类名(RateLimitError/APITimeoutError/APIConnectionError/InternalServerError/APIStatusError)+`status_code`(429 或 ≥500) 判定瞬时→包成 `TransientProviderError(provider=...)`。**新增适配器必须照做**,否则它的 429/5xx 会穿透回退链变成硬失败。`packages/llm_gateway/tests/fakes_resilience.py``ScriptedAdapter(failures=[...])``TransientProviderError` 模拟可回退失败、用 `AppError(其它码)` 模拟不可回退。
- [2026-06-19] @llm **`Gateway` 构造签名扩了但保留 `resolver=` 兼容——apps/api 的 `build_gateway_for_tier` 无需改即不破,但也因此默认不启用回退链**T5.4`Gateway(adapters, ledger, *, chain_resolver=None, resolver=None, max_retries=2, breaker=None)``resolver`(单路由) 会被自动包成单元素链,故 `project_deps.build_gateway_for_tier` 仍传 `resolver=resolve_route` 照常工作(**未回归**)。但单元素链=无回退——**真正启用回退须 apps/api 改 `build_gateway_for_tier`**:按 DB `tier_routing.fallback`(`StoredRouting.fallback` 已有) 预备多个 provider 适配器进 `adapters` dict + 传 `chain_resolver=lambda tier: chain_from_routing(tier, primary, fallback)`,并可注入跨请求共享的 `CircuitBreaker`(默认每个 Gateway 实例自带一个、进程内不共享,逐请求新建网关则熔断不跨请求累积)。Anthropic/Gemini 适配器真实接线还需 @devops`anthropic`/`google-genai` SDK 依赖(适配器本身懒导入/注入客户端,仅 apps/api 构造真实 client 时需要)。
- [2026-06-19] @llm **character-gen schema 的 `traits`/`speech_tics` 是 `list[str]`,但 DB `characters.traits`/`speech_tics`/`arc` 列是 JSONB **dict**T5.2 ingest 须转形)**T5.1C6 扩 `CharacterCard``traits`/`speech_tics` 设计成 `list[str]`(生成产物天然是列表)、`arc` 设计成 `str`(弧光一句话),但 `packages/db/ww_db/models.py` 的列类型是 `traits: JSONB dict` / `arc: JSONB dict` / `speech_tics: JSONB dict`,而 `tags`/`relations` 才是 JSONB **list**。T5.2 入库映射时list 字段tags/relations直落`traits`/`speech_tics` 须包成 dict`{"items":[...]}` 或按语义拆)或由 @db 调列形;`arc`(str) 同理包 dict。**别假设 schema 字段形 == DB 列形**——schema 贴生成产物、DB 列贴存储ingest 是转换层。`role`/`backstory` 是 Text 列、直落。`WorldEntityCard.rules:list[str]``world_entities.rules` JSONB`{"rules":[...]}` 或裸 list
- [2026-06-19] @llm **入库前 continuity 校验是「编排器追加的一道检查」,复用 `continuity_spec` 而非新建 spec**T5.1ARCH §6.5`precheck_generated_cards` 传入 `continuity_spec`(不是 character-gen 自调、不是新审种),跑生成角色卡 vs 世界观/已有角色真相源、返 `list[Conflict]`。守不变量#1agent 只经 DB/编排器通信、不互调。T5.2 入库端点拿 conflicts 做 gate有冲突→提示作者裁决/调整,**不静默入库**(同 accept 的冲突 gate 精神,但这是生成预览阶段、非 chapter accept。独立生成语义网关失败直接上抛端点处理不做 review 图的失败隔离)。
- [2026-06-19] @qa **学文风 E2E后台 work 自造网关须 monkeypatch `style.build_gateway_for_tier`(不是 override 网关 dep**T4.5`POST /style` 请求阶段的凭据探测走 `get_style_extract_gateway`(可 `dependency_overrides` 注假网关绕过 503但**真正提取**在 `run_job` 自建的独立 session 上跑 `_make_style_learn_work`→其内 `build_gateway_for_tier(session, store, "analyst")` 从凭据建**真 OpenAI 适配器**(与请求 dep 无关。E2E 无真凭据 → 必 `monkeypatch.setattr(ww_api.routers.style, "build_gateway_for_tier", 返真 Gateway 包假适配器)`ledger 绑入参 session=后台真 session。写侧 `SqlStyleFingerprintWriteRepo` + `run_job` 默认 `repo_factory`→真 `SqlJobRepo` **保持不动** → 指纹/job 真落 pg只换 LLM。同 M3override `get_session_factory``e2e_sm`(同测试 engine/loopASGITransport 下 `await client.post` 等 background task 跑完,轮询 `GET /jobs/{id}` 稳定见 done。第四审 E2E只 override `get_review_gateway`analyst即覆盖整个四审图四档位同 deepseek 单适配器),假适配器据 `req.output_schema is StyleDriftReview` 返漂移 parsed回炉假适配器据 `req.output_schema is None` 返改写串writer 纯文本,保证 refined≠original
- [2026-06-19] @frontend **`GET /jobs/{id}` 在 OpenAPI 是裸 `{[k]:unknown}`T0.3 未建命名 schema**T4.4):前端无法直接用生成类型当 JobView须在 `lib/jobs/job.ts` 安全收窄(`narrowJob`status 非法→`queued`、progress 夹取 0..100、result 仅取 object 非数组)。同理 `chapter_reviews.style` 是松散 `{[k]:unknown}|null``normalizeStyleDrift` 收窄成 `{score(缺省100),segments:[{idx,score(缺省100),label:string|null}]}`score 缺省 100 = 后端无指纹降级态)。轮询 reducer/收窄是纯逻辑、node-env 单测hook 只管 setTimeout+fetch。
- [2026-06-19] @frontend **`useJobPoll``initialPollState.status` 默认 `"polling"`,调用方别在挂载即据此显进度**T4.4`useStyleLearn``startedRef` 守门——只在提交过一次 `POST /style` 后才把 `poll.status` 映射进 UI否则文风页一进就误显「提取中…」。轮询「停止」靠 reducer 到 done/failed 终态后不再 `setTimeout`(无 abort 概念,区别于 SSE 的 AbortController。回炉 `useRefine` 503(`error.code==="LLM_UNAVAILABLE"`)→提示去设置,仿 review 流前 503 检出。
- [2026-06-19] @backend **测 `run_job` 后台路径要 monkeypatch `job_runner.SqlJobRepo` 而非 `_default_repo_factory`**T4.3`run_job(..., repo_factory=_default_repo_factory)``repo_factory` 是**默认参数值**,在函数**定义时**绑定——`monkeypatch.setattr(runner_mod, "_default_repo_factory", ...)` 改的是模块属性、改不到已绑定的默认值,无效。`_default_repo_factory` 体内 `return SqlJobRepo(session)` 引用的是**模块全局** `SqlJobRepo`,故 monkeypatch `runner_mod.SqlJobRepo` 才生效(否则后台 `set_running``SqlJobRepo(FakeSession)``.execute` 炸、被 run_job 吞成 job_failed。端点测 BackgroundTask 落库路径override `get_session_factory``FakeSessionFactory` + monkeypatch `style.build_gateway_for_tier`(产 StyleFingerprintResult)/`style.SqlStyleFingerprintWriteRepo`(指共享 fake repo) + `runner.SqlJobRepo`(指 fake job repo)ASGITransport 下 `await client.post` 会等 background task 跑完,断言稳定(同 run_overdue_scan 时序先例)。
- [2026-06-19] @backend **bulk `UPDATE` 取受影响行数要 `cast` 成 `CursorResult`**T4.1 `reap_zombies``await session.execute(update(...))` 静态类型是 `Result[Any]`**无 `.rowcount`** 属性mypy strict 报 `attr-defined`);实际运行时是 `CursorResult`。做法:`from sqlalchemy import CursorResult` + `cast("CursorResult[Any]", result).rowcount``reap_zombies` 用一条 bulk UPDATE非逐行`running→failed` 高效且原子。
- [2026-06-19] @backend **`run_job` 失败置态要开「全新」session**T4.1`work` 抛异常后,原 session 的事务已作废,不能复用它 `fail(job_id, ...)`——`_mark_failed``session_factory()` 再开一个独立 session 写 failed + commit。故 `run_job` 失败路会开 **2 个** session业务一个 + 失败置态一个),单测断言 `len(factory.sessions)==2``run_job` 对 repo 只依赖最小 `JobLifecycleRepo` Protocolset_running/complete/fail——比全 `JobRepo` 窄,单测 fake 不必实现 create/get/reap`run_overdue_scan` 用最小 `OverdueScanRepo` 先例)。
- [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-onlyT3.8 修,去 `await flush()`**:原 add+flush 在并行审里 flush 重入炸(见下条)。改为只 `session.add(row)`——`add()` 同步不让步→并行协程不交错;持久化靠端点/事务 `commit()`(自动 flush。draft/review/accept/outline 端点末尾均已 commit记账不丢。**凡新增「并行用网关产 usage」的路径sink 必须保持 add-only不得在并行段 `await flush`。** 旧 gotcha「record 只 flush 不 commit」措辞已过时——现为「add-onlycommit 归调用方」commit 仍会 flush故记账语义不变 - [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-onlyT3.8 修,去 `await flush()`**:原 add+flush 在并行审里 flush 重入炸(见下条)。改为只 `session.add(row)`——`add()` 同步不让步→并行协程不交错;持久化靠端点/事务 `commit()`(自动 flush。draft/review/accept/outline 端点末尾均已 commit记账不丢。**凡新增「并行用网关产 usage」的路径sink 必须保持 add-only不得在并行段 `await flush`。** 旧 gotcha「record 只 flush 不 commit」措辞已过时——现为「add-onlycommit 归调用方」commit 仍会 flush故记账语义不变
- [2026-06-18] @qa/@llm **并行审记账撞 sessionM3 真 bugT3.7 暴露→T3.8 修)**:三审同 LangGraph superstep 并行,各自 `gateway.run()`→共用**请求 session** 的 `SqlAlchemyLedgerSink.record`(`session.add`+`await session.flush()`)。`AsyncSession` 非并发安全→第二/三审 flush 撞 `Session is already flushing`→被 `run_review` 失败隔离吞成 `incomplete`→**foreshadow/pace 静默丢失**SSE 无事件、`chapter_reviews.foreshadow_sug`/`pace` 列空、日志 `review_node_incomplete error='Session is already flushing'`。M2 单审未触发。根因=并行路径里有 `await flush()`(唯一 await 的 DB-IO。修向审稿期记账避免并发 flushadd-only 靠端点 commit / 或缓冲后 collect 串行落 / 或并行审记账用独立 session - [2026-06-18] @qa/@llm **并行审记账撞 sessionM3 真 bugT3.7 暴露→T3.8 修)**:三审同 LangGraph superstep 并行,各自 `gateway.run()`→共用**请求 session** 的 `SqlAlchemyLedgerSink.record`(`session.add`+`await session.flush()`)。`AsyncSession` 非并发安全→第二/三审 flush 撞 `Session is already flushing`→被 `run_review` 失败隔离吞成 `incomplete`→**foreshadow/pace 静默丢失**SSE 无事件、`chapter_reviews.foreshadow_sug`/`pace` 列空、日志 `review_node_incomplete error='Session is already flushing'`。M2 单审未触发。根因=并行路径里有 `await flush()`(唯一 await 的 DB-IO。修向审稿期记账避免并发 flushadd-only 靠端点 commit / 或缓冲后 collect 串行落 / 或并行审记账用独立 session
- [2026-06-18] @qa **E2E 验「验收后 BackgroundTask 扫描」时序**httpx `ASGITransport``await client.post(...)` 会等 ASGI app 协程(含 Starlette background tasks跑完才返回故 client 上下文退出后断言稳定不 flaky必 override `get_session_factory``e2e_sm`(真 sessionmaker同测试 engine/loop否则默认 `get_sessionmaker()` 另建 engine 绑别的 loop。 - [2026-06-18] @qa **E2E 验「验收后 BackgroundTask 扫描」时序**httpx `ASGITransport``await client.post(...)` 会等 ASGI app 协程(含 Starlette background tasks跑完才返回故 client 上下文退出后断言稳定不 flaky必 override `get_session_factory``e2e_sm`(真 sessionmaker同测试 engine/loop否则默认 `get_sessionmaker()` 另建 engine 绑别的 loop。
@@ -48,3 +75,10 @@
- [2026-06-18] @db mypy strict + 跨包 editable 安装模型里 `from ww_db.base import Base` 会被判成 Any( "cannot subclass Any")需在根 `pyproject.toml` `[tool.mypy] mypy_path=[...各包源码目录...]` + `namespace_packages=true`否则 editable 包解析不到源码 - [2026-06-18] @db mypy strict + 跨包 editable 安装模型里 `from ww_db.base import Base` 会被判成 Any( "cannot subclass Any")需在根 `pyproject.toml` `[tool.mypy] mypy_path=[...各包源码目录...]` + `namespace_packages=true`否则 editable 包解析不到源码
- [2026-06-17] @docs 命名契约后端 Python/Pydantic 一律 **snake_case**字段/schema/JSON前端经 OpenAPI 生成类型消费——别手写 camelCase 共享类型评审里曾因 TS 旧栈遗留 camelCase Pydantic 契约对不上)。 - [2026-06-17] @docs 命名契约后端 Python/Pydantic 一律 **snake_case**字段/schema/JSON前端经 OpenAPI 生成类型消费——别手写 camelCase 共享类型评审里曾因 TS 旧栈遗留 camelCase Pydantic 契约对不上)。
- [2026-06-17] @docs 数据写入只走**验收事务**四审 agent 只读不写任何 AI 产出入库必经 `accept`HITL gate)。别在 agent 节点里直接写库 - [2026-06-17] @docs 数据写入只走**验收事务**四审 agent 只读不写任何 AI 产出入库必经 `accept`HITL gate)。别在 agent 节点里直接写库
- [2026-06-19] @qa M5 E2E生成端点的网关`get_worldbuilder_gateway`/`get_character_gen_gateway`/`get_precheck_gateway`**请求 scope FastAPI 依赖**内部虽调 `build_gateway_for_tier`但作为 `Depends` 暴露)→ E2E 直接 `app.dependency_overrides[get_*_gateway]=真Gateway包假适配器` 即可**无需** monkeypatch `build_gateway_for_tier`区别于 M4 学文风那是 `run_job` 后台**自建**网关 monkeypatch 模块级 `style.build_gateway_for_tier`)。
- [2026-06-19] @qa `served_by`(fell_back/degraded) **不出 API**仅观测/记账标注)→ 端到端 HTTP 路径要证回退真发生」,断言 **DB 真源 `usage_ledger.provider` = fallback 名而非 primary**网关记账记实际服务方ARCH §4.5)。HTTP fallback 用真 `Gateway` + `chain_resolver=chain_from_routing(...)` + primary 假适配器每次 `complete` `TransientProviderError` max_retries+1 次失败耗尽重试)→ fallback能力降级路径无支持者经直测 `Gateway.run` `served_by.degraded` 更清晰
- [2026-06-19] @qa `ww_agents.Conflict.type` `ConflictType` **Literal**性格漂移/能力不符/设定违例/地理矛盾/时间线倒错)——构造 precheck 假冲突须用枚举值之一设定违例」),随手写设定冲突 pydantic literal_error
- [2026-06-19] @llm **已被下条推翻**保留作纠错轨迹曾写 Kimi Code coding API 的伪造头只发 `User-Agent: KimiCLI/1.5`不带 X-Msh-*」——该结论源自分歧/错误参考 `picassio/pi-kimi-coder`UA-only导致误把头集裁成 UA-only。**勿采纳本条**见下条
- [2026-06-19] @backend Kimi Code device authorization **必须带 `scope=kimi-code`**`start_device_authorization` 请求体 = `{"client_id": <id>, "scope": "kimi-code"}`。K1.5 联网实测省略 scope 登录能拿到真 JWT但调 `api.kimi.com/coding/v1` `401 Invalid Authentication`——token coding entitlement`scope=kimi-code` coding-agent 文档化 scope`ooojustin/opencode-kimi` `constants.ts` 发送kimi-cli v1.41.0 已不发但服务端仍接受)。scope **只**放 device_authorizationtoken 交换(`poll_token`/刷新(`refresh`**不带** scopedevice flow 惯例)。⚠ 此前 K1.3 单测/decision/contract 省略 scope」(误信 kimi-cli v1.41.0已全部修正
- [2026-06-19] @llm **Kimi Code coding API 伪造头真源 = `ooojustin/opencode-kimi``src/headers.ts`+`src/constants.ts`1:1 镜像 kimi-cli v1.37.0),发完整 7 头、UA `KimiCLI/1.37.0`**K1.2 校正推翻上方 pi-kimi-coder UA-only 那条`picassio/pi-kimi-coder` 是分歧/错误参考只发 UA照它裁成 UA-only 是误修已回退`kimi_code_headers()` `User-Agent=KimiCLI/1.37.0` + `X-Msh-Platform=kimi_cli`字面常量 OS + `X-Msh-Version=1.37.0`==UA 版本+ `X-Msh-Device-Name``socket.gethostname()` ASCII + `X-Msh-Device-Model`macOS=`f"macOS {platform.mac_ver()[0]} {platform.machine()}"``machine()` 原样 `arm64`/`x86_64` 不归一化+ `X-Msh-Os-Version``platform.version()`+ `X-Msh-Device-Id`稳定 32 位无连字符 `uuid4().hex`env `KIMI_DEVICE_ID` 优先否则读/ `~/.kimi/device_id` 复用**绝不每次随机**否则 Kimi 侧每次当新设备)。源码若与此有出入以源码为准本次核对 master 一致)。⚠ HTTP 头值含非 ASCII 会被底层 fetch/httpx host 派生值Device-Name/Model/Os-Version ASCII `asciiHeaderValue` `\x20-\x7e` 之外 + trim空回退 `unknown`)。单测用 env `KIMI_DEVICE_ID` 注入固定 id CI 确定不触真 ~/.kimi)。ruff E501 按显示宽度算中文占 2 含中文 docstring/注释的行别贴边 100
- [2026-06-19] @backend `assemble`C5此前只从 world_entities/characters/style/rules + cards/foreshadow/digests/outline.beats 组装**从不含项目 premise/logline/theme/title也无写章指令** 全新项目只有 premise无世界观/角色/大纲产出 `stable_core=""`+`volatile=""`writer user message 为空LLM `400 "the message at position 0 with role 'user' must not be empty"`修复`MemoryRepos` 加第 8 repo `project:ProjectSpecRepo``spec(project_id)->ProjectSpecView{title,logline?,premise?,theme?}` project_id `projects` owner 过滤——owner 隔离归路由层`assemble` 作品蓝本放进 `stable_core` 首段书级 spec=定型→缓存前缀,不违 #9并让 `volatile` 始终含 `请创作第 {chapter_no} 章的正文。`易变指令)。⚠ 任何手搓 `MemoryRepos(...)` 的地方含测试 fake现在**必须** `project=` 字段否则 dataclass 缺参报错 DDL 变更只读既有 projects 无迁移

View File

@@ -0,0 +1,183 @@
"""T5.1 worldbuilder + character-gen 契约测试spec + schemaC6 扩 / ARCH §5.4 §6.5)。
契约测试——构造符合 schema 的 mock 响应校验字段、tier、读写权限。
worldbuilder`{entities:[{type,name,rules}]}` 硬规则显式写手档reads=projects
character-gen`{cards:[{name,role,traits,backstory,arc,speech_tics,tags,relations}]}`
写手档reads=world_entities+characterswrites=characters。不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
AgentSpec,
CharacterCard,
CharacterGenResult,
CharacterRelation,
WorldEntityCard,
WorldGenResult,
character_gen_spec,
worldbuilder_spec,
)
# ---- WorldGenResult schemaworldbuilder----
def test_world_gen_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化世界观产出(硬规则显式)
mock = {
"entities": [
{
"type": "力量体系",
"name": "九转炼气",
"rules": ["只能逐境突破,不可越级", "突破必有反噬代价"],
},
{"type": "地理", "name": "无雨城", "rules": ["终年无雨"]},
]
}
# Act
result = WorldGenResult.model_validate(mock)
# Assert
assert len(result.entities) == 2
assert result.entities[0].type == "力量体系"
assert result.entities[0].name == "九转炼气"
assert result.entities[0].rules == ["只能逐境突破,不可越级", "突破必有反噬代价"]
assert result.entities[1].rules == ["终年无雨"]
def test_world_gen_defaults_to_empty_entities() -> None:
assert WorldGenResult().entities == []
def test_world_entity_rules_default_empty() -> None:
card = WorldEntityCard.model_validate({"type": "势力", "name": "天剑宗"})
assert card.rules == []
def test_world_entity_requires_type_and_name() -> None:
with pytest.raises(ValidationError):
WorldEntityCard.model_validate({"type": "势力"})
# ---- CharacterGenResult schemacharacter-gen----
def test_character_gen_parses_mock_response() -> None:
mock = {
"cards": [
{
"name": "苏黎",
"role": "对手",
"traits": ["表面冷漠", "内心挣扎"],
"backstory": "出身敌对势力,幼年遭灭门。",
"arc": "复仇者 → 动摇 → 救赎",
"speech_tics": ["惯用反问", "句末带『罢了』"],
"tags": ["亦正亦邪", "宿命纠葛"],
"relations": [{"name": "主角", "kind": "宿敌", "note": "灭门之仇"}],
}
]
}
result = CharacterGenResult.model_validate(mock)
assert len(result.cards) == 1
card = result.cards[0]
assert card.name == "苏黎"
assert card.role == "对手"
assert card.traits == ["表面冷漠", "内心挣扎"]
assert card.speech_tics == ["惯用反问", "句末带『罢了』"]
assert card.relations[0].name == "主角"
assert card.relations[0].kind == "宿敌"
assert card.relations[0].note == "灭门之仇"
def test_character_gen_defaults_to_empty_cards() -> None:
assert CharacterGenResult().cards == []
def test_character_card_optional_collections_default_empty() -> None:
card = CharacterCard.model_validate(
{"name": "", "role": "工具人", "backstory": "无名小卒", "arc": "始终如一"}
)
assert card.traits == []
assert card.speech_tics == []
assert card.tags == []
assert card.relations == []
def test_character_card_requires_core_fields() -> None:
# name/role/backstory/arc 为必填
with pytest.raises(ValidationError):
CharacterCard.model_validate({"name": "", "role": "对手"})
def test_character_relation_note_optional() -> None:
rel = CharacterRelation.model_validate({"name": "师父", "kind": "师徒"})
assert rel.note is None
# ---- worldbuilder_spec 声明 ----
def test_worldbuilder_spec_is_writer_tier() -> None:
# 不变量 #2世界观创意用写手档只声明 tier
assert worldbuilder_spec.tier == "writer"
assert worldbuilder_spec.name == "worldbuilder"
def test_worldbuilder_spec_reads_projects_writes_world_entities() -> None:
assert worldbuilder_spec.reads == ["projects"]
assert worldbuilder_spec.writes == ["world_entities"]
def test_worldbuilder_spec_output_schema() -> None:
assert worldbuilder_spec.output_schema is WorldGenResult
def test_worldbuilder_spec_prompt_documents_hard_rules() -> None:
# 硬规则须显式(供 continuity 校验引用——system_prompt 必须强调
assert "硬规则" in worldbuilder_spec.system_prompt
# ---- character_gen_spec 声明 ----
def test_character_gen_spec_is_writer_tier() -> None:
assert character_gen_spec.tier == "writer"
assert character_gen_spec.name == "character-gen"
def test_character_gen_spec_reads_world_and_characters() -> None:
# reads=world_entities+characters约束 + 防雷同对照writes=characters
assert character_gen_spec.reads == ["world_entities", "characters"]
assert character_gen_spec.writes == ["characters"]
def test_character_gen_spec_output_schema() -> None:
assert character_gen_spec.output_schema is CharacterGenResult
def test_character_gen_spec_prompt_documents_anti_duplication() -> None:
# M5-a 群像防雷同prompt 必须注入「已生成卡 + 已有角色」差异化要求
prompt = character_gen_spec.system_prompt
assert "已有角色" in prompt
assert "已生成卡" in prompt
assert "防雷同" in prompt
# ---- 共性immutable + 是 AgentSpec ----
def test_generation_specs_are_agent_specs() -> None:
assert isinstance(worldbuilder_spec, AgentSpec)
assert isinstance(character_gen_spec, AgentSpec)
def test_generation_specs_are_immutable() -> None:
with pytest.raises(ValidationError):
worldbuilder_spec.tier = "analyst"
with pytest.raises(ValidationError):
character_gen_spec.tier = "analyst"

Some files were not shown because too many files have changed in this diff Show More