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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ apps/web/lib/api/openapi.json
|
||||
.claude/settings.local.json
|
||||
# os
|
||||
.DS_Store
|
||||
.gstack/
|
||||
|
||||
@@ -774,6 +774,7 @@ def assemble(project_id, chapter_no):
|
||||
| POST | `/projects/:id/characters/generate` | `{需求, count?, role?}` | 角色卡(预览,待入库) | 200 |
|
||||
| POST | `/projects/:id/characters` | 角色卡 | 入库结果 | 201 |
|
||||
| POST | `/projects/:id/style` | 样本(`mode=update?`) | `{job_id}`(异步见 7.4) | 202 |
|
||||
| GET | `/projects/:id/style` | — | 最新文风指纹 `{dimensions, evidence, version}` | 200 (无指纹 404) |
|
||||
| GET | `/jobs/:id` | — | 任务状态/进度/结果 | 200 |
|
||||
| POST | `/projects/:id/outline` | `{范围}` | 大纲(含伏笔窗口) | 200 |
|
||||
| POST | `/projects/:id/chapters/:no/draft` | `{}` | **SSE 流**草稿 | 200(stream) |
|
||||
|
||||
@@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 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 0–5 (M1–M5) 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 M1–M5 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.
|
||||
|
||||
|
||||
@@ -506,6 +506,7 @@ skills(id, scope, name, description, model, system_prompt,
|
||||
| `POST /projects/:id/world/generate` | 设计世界观 | 立项/写作 | worldbuilder 生成/扩展世界观、力量体系、势力、地理 |
|
||||
| `POST /projects/:id/characters/generate` | AI 生成角色 | 立项/写作 | 一句话生成完整角色卡,入库;批量传 `count` + 定位 |
|
||||
| `POST /projects/:id/style` | 学文风 | 立项 | 学习作者文风,生成文风指纹(`mode=update` 增量补样本) |
|
||||
| `GET /projects/:id/style` | 查看文风 | 立项/写作 | 读取最新文风指纹(完整 16 维 + 原文证据 + 版本号;无指纹 404) |
|
||||
| `POST /projects/:id/outline` | 排大纲 | 规划 | 生成/更新分卷分章大纲,提示伏笔回收窗口 |
|
||||
| `POST /projects/:id/chapters/:no/draft` | 写本章 | 写作 | 注入记忆+文风,产出本章草稿(streaming) |
|
||||
| `POST /projects/:id/chapters/:no/review` | 审稿 | 审稿 | 四审并行,输出冲突/漂移/节奏报告 |
|
||||
|
||||
102
PROGRESS.md
102
PROGRESS.md
@@ -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 端点)。
|
||||
> 执行波次(依赖驱动):A(T3.1‖T3.4) → B(T3.2‖T3.3) → C(T3.5) → D(T3.6) → E(T3.7)。
|
||||
> 计划已定的衔接决策:
|
||||
> **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)。
|
||||
> **M3-b** 到期扫描(T3.2)挂在**验收后**触发——接 M2 验收事务 §5.5 步骤 3 当前留的 `TODO(M3)`(人物 latest_state/伏笔更新)占位。
|
||||
> **M3-c** 第三审(foreshadow/pace)复用 M2 `build_review_graph` 的 `review_specs` 扩展点并入并行分支(C4 已稳定);**文风第四审属 M4/T4.2**,M3 先到三审齐。
|
||||
> **M3-d** 到期判据为纯函数确定性代码(非 Agent):`current_ch > expected_close_to AND status≠CLOSED → OVERDUE`。
|
||||
> **背景**:用户要求接入 Kimi Code 订阅 plan(device-flow OAuth)。**已知情接受 ToS/封号风险**——研究确认:用订阅 plan 调用需伪造官方客户端 `User-Agent` + `X-Msh-*` 头,Kimi ToS 视为违规、可能封停会员(见变更日志研究结论)。用户明确选择此路径(非合规的 API Key 路径)。与既有 API-key провайдер `kimi` **分离**,新增独立 provider `kimi-code` 以便切换。
|
||||
> **集成契约(研究确认,实现时对照 `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。
|
||||
> - 推理: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。
|
||||
> - token:access ~15min / refresh ~30d → **建网关时按需刷新**(临近过期刷新并持久化)。存储:`provider_credentials` 加 `auth_type`(api_key|oauth) + `oauth_enc`(Fernet 加密 JSON `{access_token,refresh_token,expires_at}`)。
|
||||
> **执行波次**:A(@db 加列+迁移 ‖ @llm Kimi coding 适配器) → B(@backend OAuth device 服务 + 端点 + store + `_build_provider_adapter` 接线) → C(@frontend 设置页连接/轮询/断开 + 路由档位) → D(@qa E2E device-flow mock + 头断言)。
|
||||
> **测试纪律**: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 读侧 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 扩稳定 |
|
||||
| T3.5 API:outline + foreshadow | ✅ @backend 2026-06-18 | @backend | T3.1✅, T3.4✅ | C3✅扩 | `GET .../foreshadow?status=`(看板,续 foreshadow.router) + `POST .../outline`(独立 outline.py,run_outline analyst→upsert outline 表+commit);outline 写侧 repo;+15 测全仓 220 passed |
|
||||
| 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 收尾** |
|
||||
| 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)|
|
||||
| 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 仅设 UA,X-Msh-* 为契约附带,见 C1扩 校正);11 测全绿 |
|
||||
| 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_enc,K1.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`** |
|
||||
| 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 |
|
||||
| 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 完成,可封板** |
|
||||
|
||||
**M3 出口(DoD)✅ 全部达成**:伏笔账本/到期提醒/看板;大纲含回收窗口;节奏报告(三审齐真持久化)。后端门禁绿(ruff / mypy **111 文件** / pytest **228 passed** / alembic **无漂移**);前端门禁绿(gen:api / lint / tsc / vitest **69** / build);E2E 真实 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**/build);E2E(`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/单测仅验逻辑、不联网。**
|
||||
|
||||
---
|
||||
|
||||
## 状态:M1–M5 全部完成(含 M5 三项收尾余项已清理)
|
||||
|
||||
> **当前无活跃阶段。** Phase 0–5(M1–M5)已全部交付,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-skills);Anthropic/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 生成/入库 API(C3✅扩;world/characters generate 预览 + POST characters 入库双 gate:continuity 预检 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 / build);E2E 真 DB + mock 多 provider 零 token 走通。**M5 完成 → M1–M5 全部交付。** 三非阻塞余项(①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 + refine(C3✅扩;`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 / build);E2E 真 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 + foreshadow(C3✅扩;`GET .../foreshadow?status=` 看板 + `POST .../outline` upsert outline 表含窗口)@backend · T3.6 伏笔看板 + 大纲编辑器 + 节奏报告(四泳道 + beats+窗口徽标 + 审稿页节拍图▁▃▅)@frontend · T3.7 M3 E2E(伏笔埋设→进展→验收后扫描 OVERDUE + 大纲含窗口 + 三审 SSE,strict-xfail 钉住并发记账 bug)@qa · T3.8 [bugfix] 并行审记账并发安全(`SqlAlchemyLedgerSink.record` 改 add-only 去 `await flush`,解 AsyncSession 并发 flush 冲突,xfail 转 XPASS 后解钉)@llm。
|
||||
出口达成:后端门禁绿(ruff / mypy 111 / pytest 228 passed(0 xfailed/0 failed)/ alembic 无漂移,`foreshadow` 表已 T0.2 建齐无新建表);前端门禁绿(gen:api / lint / tsc / vitest 69 / build);E2E 真 DB + mock 网关零 token 走通,DB 真源逐项断言。T3.7 暴露并发记账 bug → T3.8 修复 → 解钉 xfail。
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Phase 2 · 一致性 + 验收 (M2) — ✅ 全部完成(2026-06-18)</summary>
|
||||
|
||||
T2.1 续审 AgentSpec+结构化输出契约+网关 instructor 接线(C6✅稳定,改 C1:`run()` 填 `parsed`)@llm · T2.2 LangGraph 并行审+collect 留痕+review SSE(C4✅扩)@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 — 一句话结果 + 影响的契约/文件`
|
||||
|
||||
- [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 vitest,mock 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/WorldEntityRepo,order_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 fake(test_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()` 助手 + 重加 imports(os/uuid/socket/platform/pathlib)+ `__init__` 重导出。文件:`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py` + `__init__.py` + 两测(断全 7 头键 + 固定字面值 + device-id 32 位小写 hex 稳定 + host 派生值非空 ASCII,env 注入保 CI 确定)。更 contracts C1扩(7 头 + opencode 真源)+ gotcha(pi-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-auth,token 交换/刷新不带。文件:`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 E2E(Kimi 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}` done,result `{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。**K1(Kimi 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 拉 status;disconnect=POST→复位)+ `KimiCodeOauth.tsx`(连接区,user_code 用 `<output select-all tabIndex=0>` 可读可选可聚焦 + motion-safe)。`ProvidersSettings.tsx` 改:档位路由从只读→**可编辑**(per-tier select provider + model input + 保存 PUT),API-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/结构化/瞬时错误包装/capabilities),provider 固定 `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)` 不变。**首个新迁移**(M1–M5 全零建表)`migrations/versions/1f011c42bd4d_provider_credentials_oauth_columns.py`(down_revision=`220ca2e3d53f`):up=add_column auth_type/oauth_enc + alter api_key_enc→nullable,down 逆向;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。
|
||||
- **[请求 → @backend,K1.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 — **🎉 M1–M5 全部交付 + M5 余项收尾完成**。五个里程碑(Phase 0–5)全部 ✅;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 impl(registry/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 route(Server 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":[...]}`→list),register main;C3 扩稳定。**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 名断分派,不联网)。**门禁绿**(隔离 cache):ruff/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 gotcha(Protocol vs 真 SDK cast 边界)。
|
||||
- [2026-06-19] @orchestrator — **🎉 M5 完成 + 独立复核 ✅**(Wave C:T5.3+T5.6 ✅ + Wave D:T5.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 服务 + 记账记实际 provider);Skill registry + 表权限沙箱 + 规则页/技能库/命令面板(⌘K)。**M1–M5 全部交付。** 非阻塞余项:① 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 D:T5.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`**(非 stub)deepseek(每次 `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 C:T5.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 + 数量(1–12) + 定位 → `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/Enter,a11y(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→+29:cards 12 + rules 5 + skills 3 + palette 9)/build(codex/rules/skills 路由齐)。memory 记 2 gotcha + 1 gap(Codex 读端点缺口)。**M5 前端交付完成** → 下一步 **Wave D:T5.7(@qa) M5 E2E + 切 provider**。
|
||||
- [2026-06-19] @orchestrator — **M5 Wave B 收口 ✅**(T5.2,C3 扩稳定)。5 端点入 OpenAPI:world/characters generate 预览 + characters 入库(双 gate:continuity 预检 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 C:T5.3+T5.6(@frontend 单 agent 顺序做)**——先 `pnpm gen:api`,做 角色生成器 + 世界观设计器 + 设定库 Codex + 规则页 + 技能库 + 命令面板(⌘K),入库流处理 409 裁决重发。
|
||||
- [2026-06-19] @backend — **M5 Wave B:T5.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`(单 provider,M1 兼容),无可用凭据→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→+17:13 生成/入库端点 + 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 C:T5.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 B:T5.2(@backend 生成/入库 API)**。
|
||||
- [2026-06-19] @llm — **M5 Wave A:T5.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 A:T5.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)。守不变量#3(allowed 仍过验收 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 直映。② spec(specs.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/不变量#1),T5.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 gate;analyst/writer 网关复用 build_gateway_for_tier。下一步 **Wave B:T5.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 完成;M1–M4 全部交付。下一阶段 M5(生成+多 provider+Skill) 见 DEV_PLAN,进入时归档 M3/M4 并拉入新表。
|
||||
- [2026-06-19] @qa — **🎉 M4 完成**(Wave D:T4.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+1;DB 真源 `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 C:T4.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 路径合入,不另写库守不变量#3;503 优雅提示)。LeftNav 开「文风」入口(`ActiveNav` 加 "style")。前端门禁全绿:gen:api/lint/tsc/**vitest 96**(+27:job 12 + style 10 + style-sse 5)/build(含 `/projects/[id]/style` 路由)。memory 记 1 gotcha。下一步 **Wave D:T4.5(@qa) M4 E2E**。
|
||||
- [2026-06-19] @orchestrator — **M4 Wave B 完成 ✅**(T4.3,C3 扩稳定)。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 C:T4.4(@frontend)**——先 `pnpm gen:api` 纳入 style/refine/GET-style + jobs 类型,再做文风页(上传/指纹/证据) + `lib/jobs/useJobPoll` + 审稿页第四审 StylePanel + 回炉 diff。
|
||||
- [2026-06-19] @backend — **M4 Wave B:T4.3✅ API style + refine**(契约 **C3 扩稳定**)。三端点入 OpenAPI(独立 `routers/style.py`,仿 outline.py,已注册):① `POST /projects/:id/style` → **202** `{job_id}`(学文风走 jobs,M4-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.9;ARCH §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 C:T4.4(@frontend)**。
|
||||
- [2026-06-19] @orchestrator — **M4 Wave A 收口 ✅**(T4.2 + T4.1 全绿)。全仓门禁收口:ruff/format 干净、mypy **111 文件**、**alembic 无漂移**(M4 无建表迁移)、pytest **280 passed**(M3 基线 228 → +52:T4.2 37 + T4.1 15)。契约稳定:C6扩/C4扩(StyleFingerprintResult/StyleDriftReview/四审齐/SSE style 事件)、C3.5(JobRepo/run_job)。下一步 **Wave B:T4.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 A:T4.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 B:T4.3(@backend style/refine 端点) 可起,调本任务 `run_style_extraction`/`refiner_spec`。
|
||||
- [2026-06-19] @orchestrator — **进入 M4(文风)**:归档心智上 M3,Phase 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 A:T4.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 Wave E:T3.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)**/build。memory +1 decision +2 gotchas。下一步 **Wave E:T3.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 + OpenAPI→TS 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-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/pytest(56 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=`。
|
||||
|
||||
@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
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.foreshadow_state import (
|
||||
ForeshadowStatus,
|
||||
@@ -21,8 +21,16 @@ from ww_core.domain.foreshadow_state import (
|
||||
from ww_core.domain.foreshadow_state import (
|
||||
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.repositories import DigestView
|
||||
from ww_core.domain.repositories import DigestView, OutlineView
|
||||
from ww_core.domain.review_repo import ReviewView
|
||||
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:
|
||||
"""到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。
|
||||
|
||||
@@ -332,20 +374,106 @@ class FakeSessionFactory:
|
||||
return _cm()
|
||||
|
||||
|
||||
class FakeStyleWriteRepo:
|
||||
"""实现 `StyleFingerprintWriteRepo` Protocol(内存版本化 append + 最新读取)。
|
||||
|
||||
`append` 模拟 version=当前 max+1(首次 1);`latest` 取最大 version 行。
|
||||
供 `GET /style` 读侧 + 学文风 work(mode=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:
|
||||
"""实现审稿/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._error = error
|
||||
# `text` 覆盖纯文本产出(refiner output_schema=None → 端点消费 resp.text)。
|
||||
self._text = text
|
||||
self.requests: list[LlmRequest] = []
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
self.requests.append(req)
|
||||
if self._error is not None:
|
||||
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(
|
||||
text=self._parsed.model_dump_json() if self._parsed else "{}",
|
||||
text=out_text,
|
||||
parsed=self._parsed,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
|
||||
@@ -15,21 +15,34 @@ class FakeCredentialStore:
|
||||
"""实现 `CredentialStore` Protocol 的内存版。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# key: (owner_id, provider) → 密文
|
||||
# key: (owner_id, provider) → api_key 密文
|
||||
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] = {}
|
||||
|
||||
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]:
|
||||
return [
|
||||
rows = [
|
||||
StoredCredential(provider=p, api_key_enc=blob)
|
||||
for (o, p), blob in self.creds.items()
|
||||
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]:
|
||||
return list(self.routing.values())
|
||||
|
||||
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))
|
||||
if blob is None:
|
||||
return None
|
||||
@@ -39,6 +52,19 @@ class FakeCredentialStore:
|
||||
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
|
||||
) -> None:
|
||||
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:
|
||||
self.routing[routing.tier] = routing
|
||||
|
||||
100
apps/api/tests/test_gateway_fallback_wiring.py
Normal file
100
apps/api/tests/test_gateway_fallback_wiring.py
Normal 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
|
||||
506
apps/api/tests/test_generation.py
Normal file
506
apps/api/tests/test_generation.py
Normal file
@@ -0,0 +1,506 @@
|
||||
"""T5.2 生成/入库 + 读端点测试(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- POST /world/generate:mock 网关产 WorldGenResult → 预览 + commit(落 ledger);项目不存在 404。
|
||||
- POST /characters/generate:mock 网关产 CharacterGenResult → 预览(含已有角色注入防雷同)。
|
||||
- POST /characters(入库):precheck 无冲突 → 写库 + 201;有冲突且未确认 → 409;确认后放行写库。
|
||||
- 无凭据 → 503(override 网关 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` 返对应 parsed(world/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"] == []
|
||||
153
apps/api/tests/test_job_runner.py
Normal file
153
apps/api/tests/test_job_runner.py
Normal 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
|
||||
77
apps/api/tests/test_kimi_code_key_wiring.py
Normal file
77
apps/api/tests/test_kimi_code_key_wiring.py
Normal 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"
|
||||
125
apps/api/tests/test_kimi_code_wiring.py
Normal file
125
apps/api/tests/test_kimi_code_wiring.py
Normal 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
|
||||
213
apps/api/tests/test_kimi_oauth_endpoints.py
Normal file
213
apps/api/tests/test_kimi_oauth_endpoints.py
Normal 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(共享实例,断后台落库)+ FakeSessionFactory;monkeypatch
|
||||
`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 success(TestClient 同步跑完 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
|
||||
184
apps/api/tests/test_kimi_oauth_service.py
Normal file
184
apps/api/tests/test_kimi_oauth_service.py
Normal 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
|
||||
@@ -16,6 +16,7 @@ import httpx
|
||||
import pytest
|
||||
from fakes_projects import (
|
||||
FakeForeshadowRepo,
|
||||
FakeOutlineReadRepo,
|
||||
FakeOutlineWriteRepo,
|
||||
FakeProjectRepo,
|
||||
FakeReviewGateway,
|
||||
@@ -186,6 +187,84 @@ async def test_generate_outline_upper_error_propagates() -> None:
|
||||
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
|
||||
|
||||
|
||||
# ---- 伏笔看板 ----
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from ww_core.domain.repositories import (
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
@@ -27,6 +28,9 @@ class _EmptyOutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return None
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
return []
|
||||
|
||||
|
||||
class _EmptyCharacterRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||||
@@ -58,6 +62,11 @@ class _EmptyRulesRepo:
|
||||
return []
|
||||
|
||||
|
||||
class _StubProjectSpecRepo:
|
||||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
|
||||
return ProjectSpecView(title="测试作品", premise="测试前提")
|
||||
|
||||
|
||||
def _empty_memory_repos() -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=_EmptyOutlineRepo(),
|
||||
@@ -67,6 +76,7 @@ def _empty_memory_repos() -> MemoryRepos:
|
||||
foreshadow=_EmptyForeshadowRepo(),
|
||||
style=_EmptyStyleRepo(),
|
||||
rules=_EmptyRulesRepo(),
|
||||
project=_StubProjectSpecRepo(),
|
||||
)
|
||||
|
||||
|
||||
@@ -208,3 +218,30 @@ async def test_put_draft_is_idempotent() -> None:
|
||||
assert saved.content == "改稿更长"
|
||||
assert saved.version == 1
|
||||
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
|
||||
|
||||
87
apps/api/tests/test_rules.py
Normal file
87
apps/api/tests/test_rules.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""T5.5 POST /projects/:id/rules 端点(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- 201 + 回显 level/content + 端点 commit;
|
||||
- 非法 level → 422(Pydantic 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
|
||||
394
apps/api/tests/test_style.py
Normal file
394
apps/api/tests/test_style.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""T4.3 端点测试:学文风(jobs 异步)+ 回炉 + 最新指纹(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- POST /style:写一行 job 返 202 `{job_id}`;BackgroundTask 跑完写 style_fingerprint
|
||||
+ 置 job done(ASGITransport 下 background task 在请求返回前跑完,见 gotcha);
|
||||
- mode="update" → 第二次 append version+1;
|
||||
- 项目不存在 → 404;无凭据 → 503(dep 解析阶段拦下,未写 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 2(version+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
|
||||
@@ -6,7 +6,10 @@ from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
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_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.routers import (
|
||||
foreshadow,
|
||||
generation,
|
||||
health,
|
||||
jobs,
|
||||
kimi_oauth,
|
||||
outline,
|
||||
projects,
|
||||
rules,
|
||||
settings_providers,
|
||||
style,
|
||||
)
|
||||
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)。
|
||||
async with get_sessionmaker()() as session:
|
||||
await seed_stub_user(session)
|
||||
# zombie reaper(M4-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
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
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.exception_handler(AppError)
|
||||
@@ -57,7 +81,12 @@ def create_app() -> FastAPI:
|
||||
app.include_router(projects.router)
|
||||
app.include_router(foreshadow.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(kimi_oauth.router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
427
apps/api/ww_api/routers/generation.py
Normal file
427
apps/api/ww_api/routers/generation.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""生成/入库端点(C3 扩 / ARCH §6.5 角色生成器 / §5.4 / §7.2;不变量 #1/#3/#9)。
|
||||
|
||||
三类端点 + 两个读端点(M5-d 生成走即时返回:预览 → 作者确认 → 入库,非 jobs):
|
||||
- `POST /projects/:id/world/generate`:worldbuilder(writer 网关)→ **预览**世界观实体(不入库)。
|
||||
- `POST /projects/:id/characters/generate`:character-gen(writer 网关,群像防雷同)→ 预览角色卡。
|
||||
- `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`:技能库列表(技能库 UI,T5.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)
|
||||
|
||||
# 提交边界:网关 ledger(precheck)+ 角色写侧均只 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":[...]}` → 裸 list(worldbuilder 形变的逆向)。
|
||||
"""
|
||||
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
|
||||
],
|
||||
)
|
||||
190
apps/api/ww_api/routers/kimi_oauth.py
Normal file
190
apps/api/ww_api/routers/kimi_oauth.py
Normal 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())
|
||||
@@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import outliner_spec
|
||||
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_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
@@ -41,6 +41,7 @@ from ww_api.services.project_deps import (
|
||||
get_foreshadow_repo,
|
||||
get_memory_repos,
|
||||
get_outline_gateway,
|
||||
get_outline_read_repo,
|
||||
get_outline_write_repo,
|
||||
get_project_repo,
|
||||
)
|
||||
@@ -53,6 +54,7 @@ ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
|
||||
OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)]
|
||||
OutlineGatewayDep = Annotated[Gateway, Depends(get_outline_gateway)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
@@ -133,3 +135,42 @@ async def generate_outline(
|
||||
chapter_count=len(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)
|
||||
|
||||
@@ -45,6 +45,7 @@ from ww_api.schemas.projects import (
|
||||
AcceptResponse,
|
||||
DraftResponse,
|
||||
DraftSaveRequest,
|
||||
DraftView,
|
||||
ProjectCreateRequest,
|
||||
ProjectListResponse,
|
||||
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(
|
||||
body: ReviewRequest,
|
||||
chapter_repo: ChapterRepo,
|
||||
|
||||
53
apps/api/ww_api/routers/rules.py
Normal file
53
apps/api/ww_api/routers/rules.py
Normal 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)
|
||||
225
apps/api/ww_api/routers/style.py
Normal file
225
apps/api/ww_api/routers/style.py
Normal 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}`:学文风走 jobs(M4-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, # 不变量 #2:writer 档,不传 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)
|
||||
163
apps/api/ww_api/schemas/generation.py
Normal file
163
apps/api/ww_api/schemas/generation.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""生成/入库端点的请求/响应 schema(C3 扩 / 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":...}`→str;tags/relations 直落 list。
|
||||
"""
|
||||
|
||||
characters: list[CharacterCardView] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorldEntityListResponse(BaseModel):
|
||||
"""GET /projects/:id/world_entities:已入库世界观实体全量列表(设定库 Codex 真源)。
|
||||
|
||||
DB JSONB dict 列 `{"rules":[...]}`→裸 list(worldbuilder 形变的逆向)。
|
||||
"""
|
||||
|
||||
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)
|
||||
39
apps/api/ww_api/schemas/kimi_oauth.py
Normal file
39
apps/api/ww_api/schemas/kimi_oauth.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Kimi Code OAuth device-flow 端点的响应 schema(C3 扩 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
|
||||
@@ -59,6 +59,22 @@ class DraftResponse(BaseModel):
|
||||
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)----
|
||||
|
||||
|
||||
|
||||
28
apps/api/ww_api/schemas/rules.py
Normal file
28
apps/api/ww_api/schemas/rules.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""规则端点的请求/响应 schema(C3 扩 / 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
|
||||
47
apps/api/ww_api/schemas/style.py
Normal file
47
apps/api/ww_api/schemas/style.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""文风学习 + 回炉端点的请求/响应 schema(C3 扩 / 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
|
||||
@@ -20,12 +20,24 @@ from ww_llm_gateway.adapters.base import Capabilities
|
||||
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)
|
||||
class StoredCredential:
|
||||
"""存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。"""
|
||||
"""存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。
|
||||
|
||||
一行二选一:`auth_type="api_key"` → `api_key_enc` 有值、`oauth_enc=None`;
|
||||
`auth_type="oauth"`(Kimi Code device-flow,K1.3)→ `oauth_enc` 有值、`api_key_enc=None`
|
||||
(持 Fernet 加密的 `{access_token,refresh_token,expires_at}` JSON 包)。
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -51,6 +63,12 @@ class CredentialStore(Protocol):
|
||||
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
|
||||
) -> 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: ...
|
||||
|
||||
|
||||
@@ -72,7 +90,15 @@ class SqlCredentialStore:
|
||||
select(ProviderCredential).where(ProviderCredential.owner_id == owner_id)
|
||||
)
|
||||
).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]:
|
||||
rows = (await self._session.execute(select(TierRouting))).scalars()
|
||||
@@ -94,7 +120,12 @@ class SqlCredentialStore:
|
||||
).scalar_one_or_none()
|
||||
if row is 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(
|
||||
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
|
||||
@@ -117,12 +148,68 @@ class SqlCredentialStore:
|
||||
project_id=None,
|
||||
provider=provider,
|
||||
api_key_enc=api_key_enc,
|
||||
auth_type=AUTH_TYPE_API_KEY,
|
||||
oauth_enc=None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.api_key_enc = api_key_enc
|
||||
existing.auth_type = AUTH_TYPE_API_KEY
|
||||
existing.oauth_enc = None
|
||||
await self._session.commit()
|
||||
|
||||
async def upsert_oauth_credential(
|
||||
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
|
||||
) -> None:
|
||||
"""写/更新 OAuth 凭据行(Kimi Code device-flow,K1.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:
|
||||
existing = (
|
||||
await self._session.execute(
|
||||
|
||||
97
apps/api/ww_api/services/job_runner.py
Normal file
97
apps/api/ww_api/services/job_runner.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""通用长任务 runner(BackgroundTask 跑 `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))` → commit(job 失败可见)。
|
||||
任何异常都被吞(后台任务边界,不冒泡崩进程);失败置态本身再炸只记日志。
|
||||
`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))
|
||||
240
apps/api/ww_api/services/kimi_oauth.py
Normal file
240
apps/api/ww_api/services/kimi_oauth.py
Normal 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 scope):opencode-kimi
|
||||
`constants.ts` 发送之;kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token
|
||||
缺 coding entitlement,调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`,故重新带上。
|
||||
- scope 只放在 device_authorization 请求上;token 交换/刷新不带 scope(OAuth 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_id(kimi-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 scope(device 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_id(env `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"])),
|
||||
)
|
||||
@@ -11,40 +11,58 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from openai import AsyncOpenAI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
||||
from ww_core.domain.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.job_repo import JobRepo, SqlJobRepo
|
||||
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
|
||||
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.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.models import User
|
||||
from ww_llm_gateway import (
|
||||
Gateway,
|
||||
OpenAICompatAdapter,
|
||||
ProviderAdapter,
|
||||
Route,
|
||||
SqlAlchemyLedgerSink,
|
||||
build_adapter,
|
||||
chain_from_routing,
|
||||
resolve_route,
|
||||
)
|
||||
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER
|
||||
from ww_llm_gateway.types import Tier
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_skills import SkillRegistry, SqlSkillRepo
|
||||
|
||||
from ww_api.security.credentials import (
|
||||
CredentialKeyError,
|
||||
decrypt_api_key,
|
||||
)
|
||||
from ww_api.services.credentials import (
|
||||
AUTH_TYPE_OAUTH,
|
||||
STUB_OWNER_ID,
|
||||
CredentialStore,
|
||||
SqlCredentialStore,
|
||||
StoredCredential,
|
||||
)
|
||||
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
|
||||
|
||||
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
|
||||
@@ -105,6 +123,16 @@ def get_foreshadow_repo(
|
||||
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(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> OutlineWriteRepo:
|
||||
@@ -112,6 +140,90 @@ def get_outline_write_repo(
|
||||
return SqlOutlineWriteRepo(session)
|
||||
|
||||
|
||||
def get_rule_write_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> RuleWriteRepo:
|
||||
"""规则写侧 repo(POST /rules:作者显式加规则;只 flush,端点提交)。测试经 override 注。"""
|
||||
return SqlRuleWriteRepo(session)
|
||||
|
||||
|
||||
async def get_skill_registry(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> SkillRegistry:
|
||||
"""从 `skills` 表加载声明式 skill registry(ARCH §5.6;越权声明 → VALIDATION)。
|
||||
|
||||
每请求按 session 加载(registry 不可变快照)。测试经 `app.dependency_overrides` 注 fake repo
|
||||
或直接注 `SkillRegistry`。技能库 UI(T5.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:
|
||||
"""角色写侧 repo(POST /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:
|
||||
"""规则读侧 repo(GET /rules 列表,复用 C5 assemble 读侧;测试经 override 注 fake)。"""
|
||||
return SqlRulesRepo(session)
|
||||
|
||||
|
||||
def get_outline_read_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> OutlineRepo:
|
||||
"""大纲读侧 repo(GET /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:
|
||||
"""验收后到期扫描的**独立 session 工厂**缝。
|
||||
|
||||
@@ -122,42 +234,125 @@ def get_session_factory() -> SessionFactory:
|
||||
return get_sessionmaker()
|
||||
|
||||
|
||||
async def build_gateway_for_tier(
|
||||
session: AsyncSession, store: CredentialStore, tier: Tier
|
||||
) -> Gateway:
|
||||
"""据指定档位路由解密对应 provider 凭据 → 建网关(解析器仍为全局 `resolve_route`)。
|
||||
async def _build_provider_adapter(store: CredentialStore, provider: str) -> ProviderAdapter | None:
|
||||
"""据 provider 解密凭据 → 经 `build_adapter` 工厂建对应 provider 适配器(T5.4 follow-up)。
|
||||
|
||||
无凭据/未知 provider → `LLM_UNAVAILABLE`(友好提示,前端引导去配置)。
|
||||
解析器用 `resolve_route`(按 tier 路由);这里只决定**要预备哪个 provider 的适配器**。
|
||||
`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(不直接抛)。
|
||||
"""
|
||||
settings = get_settings()
|
||||
route = resolve_route(tier)
|
||||
base_url = _PROVIDER_BASE_URLS.get(route.provider)
|
||||
if base_url 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)
|
||||
cred = await store.get_credential(STUB_OWNER_ID, provider)
|
||||
if cred is None:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
|
||||
{"provider": route.provider, "tier": tier},
|
||||
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_url;Anthropic/Gemini 走原生 SDK(base_url=None)。
|
||||
base_url = _PROVIDER_BASE_URLS.get(provider)
|
||||
return build_adapter(provider, api_key=api_key, base_url=base_url)
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
adapter = OpenAICompatAdapter(provider=route.provider, client=client)
|
||||
return Gateway(
|
||||
adapters={route.provider: adapter},
|
||||
ledger=SqlAlchemyLedgerSink(session),
|
||||
resolver=resolve_route,
|
||||
)
|
||||
|
||||
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(
|
||||
session: AsyncSession, store: CredentialStore, tier: Tier
|
||||
) -> Gateway:
|
||||
"""据指定档位路由 + DB `tier_routing.fallback` 装配**多 provider 回退链**网关(T5.4 接线)。
|
||||
|
||||
流程(§4.3 三级解析 / §4.5 回退链):
|
||||
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 配置仍走单元素链(网关把它当无回退处理),**不破既有行为**。
|
||||
"""
|
||||
ledger = SqlAlchemyLedgerSink(session)
|
||||
|
||||
stored = next((r for r in await store.list_routing() if r.tier == tier), None)
|
||||
if stored is None:
|
||||
# 无 DB 路由行:退回全局默认(单 provider,M1 兼容)。
|
||||
route = resolve_route(tier)
|
||||
adapter = await _build_provider_adapter(store, route.provider)
|
||||
if adapter is None:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
|
||||
{"provider": route.provider, "tier": tier},
|
||||
)
|
||||
return Gateway(adapters={route.provider: adapter}, ledger=ledger, resolver=resolve_route)
|
||||
|
||||
# DB 路由:primary + fallback 构链;为每个可建的 provider 预备适配器。
|
||||
primary_spec = f"{stored.provider}:{stored.model}"
|
||||
chain: list[Route] = chain_from_routing(tier, primary_spec, list(stored.fallback))
|
||||
adapters: dict[str, ProviderAdapter] = {}
|
||||
for route in chain:
|
||||
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:
|
||||
@@ -195,3 +390,25 @@ async def get_outline_gateway(
|
||||
"""大纲生成(analyst 档位)的可注入网关缝。测试经 override 注 mock(产 OutlineResult)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
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")
|
||||
|
||||
@@ -34,6 +34,10 @@ _PROVIDER_BASE_URLS: dict[str, str] = {
|
||||
"qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"glm": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"openai": "https://api.openai.com/v1",
|
||||
# Kimi 订阅 plan(OAuth device-flow,K1.3):coding 端点(OpenAI 兼容 + 伪造头)。
|
||||
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||
# Kimi 订阅 plan(静态 Console Key,ToS 合规):同一 coding 端点,纯 bearer 无伪造头。
|
||||
"kimi-code-key": "https://api.kimi.com/coding/v1",
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +72,13 @@ class GatewayProviderProbe:
|
||||
f"未知提供商 {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:
|
||||
api_key = decrypt_api_key(cred.api_key_enc, key=self._enc_key)
|
||||
except CredentialKeyError as exc:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import { CommandPaletteMount } from "@/components/command/CommandPaletteMount";
|
||||
import { ToastProvider } from "@/components/Toast";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -16,7 +17,10 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="font-sans antialiased">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<CommandPaletteMount />
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
42
apps/web/app/projects/[id]/codex/page.tsx
Normal file
42
apps/web/app/projects/[id]/codex/page.tsx
Normal 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 取项目 + 跨会话全量人物/世界观真源;
|
||||
// CodexPage(Client)承载生成/管理 + 本会话新入库回显。
|
||||
// ?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();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { OutlineEditor } from "@/components/outline/OutlineEditor";
|
||||
import { fetchProject } from "@/lib/api/server";
|
||||
import { fetchOutline, fetchProject } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 大纲编辑器页(UX §6.7)。Server Component 取项目;OutlineEditor(Client)按需
|
||||
// 触发 POST .../outline 生成大纲(M3 无 GET 大纲端点,进页空、生成后填充)。
|
||||
// 大纲编辑器页(UX §6.7)。Server Component 取项目 + 已存大纲(GET .../outline,
|
||||
// 空/错误降级为空列表)作为初始章节,重访时显已生成的大纲;OutlineEditor(Client)
|
||||
// 仍按需触发 POST .../outline 生成/覆盖所显章节。
|
||||
export default async function OutlinePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
return <OutlineEditor project={project} initialChapters={[]} />;
|
||||
const initialChapters = await fetchOutline(id);
|
||||
return (
|
||||
<OutlineEditor project={project} initialChapters={initialChapters} />
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
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";
|
||||
|
||||
interface PageProps {
|
||||
@@ -20,12 +20,14 @@ export default async function ReviewPage({ params, searchParams }: PageProps) {
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
const history = await fetchReviews(id, chapterNo);
|
||||
// 终稿初值取已存草稿(GET .../draft,404→null→"");否则 final_text 为空 → accept 422。
|
||||
const draft = await fetchDraft(id, chapterNo);
|
||||
return (
|
||||
<ReviewReport
|
||||
project={project}
|
||||
chapterNo={chapterNo}
|
||||
initialReview={latestReview(history.reviews)}
|
||||
initialDraft=""
|
||||
initialDraft={draft?.content ?? ""}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
|
||||
20
apps/web/app/projects/[id]/rules/page.tsx
Normal file
20
apps/web/app/projects/[id]/rules/page.tsx
Normal 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 取项目 + 规则列表;RulesPage(Client)承载新增。
|
||||
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();
|
||||
}
|
||||
}
|
||||
20
apps/web/app/projects/[id]/skills/page.tsx
Normal file
20
apps/web/app/projects/[id]/skills/page.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
27
apps/web/app/projects/[id]/style/page.tsx
Normal file
27
apps/web/app/projects/[id]/style/page.tsx
Normal 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);
|
||||
// StylePage(Client)承载上传/学文风/指纹展示交互。
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
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 {
|
||||
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) {
|
||||
const { id } = await params;
|
||||
|
||||
// 仅当项目确实取不到时才判 404;草稿/瞬时错误不应把整页变成「找不到页面」。
|
||||
let project: ProjectResponse;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
return <Workbench project={project} />;
|
||||
project = await fetchProject(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 草稿读取失败(后端瞬时错误 / 尚无草稿)→ 空编辑器初值,绝不 404。
|
||||
let initialText = "";
|
||||
try {
|
||||
const draft = await fetchDraft(id, WORKBENCH_CHAPTER_NO);
|
||||
initialText = draft?.content ?? "";
|
||||
} catch {
|
||||
initialText = "";
|
||||
}
|
||||
|
||||
return <Workbench project={project} initialText={initialText} />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
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";
|
||||
|
||||
// 模型与提供商设置(UX §6.10)。Server Component 取初始数据。
|
||||
// 模型与提供商设置(UX §6.10)。Server Component 取初始数据(含 Kimi Code OAuth 状态)。
|
||||
export default async function ProvidersSettingsPage() {
|
||||
let initial: ProvidersResponse = { providers: [], tier_routing: [] };
|
||||
let kimiOauth = { connected: false, expiresAt: null as string | null };
|
||||
let loadError = false;
|
||||
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 {
|
||||
loadError = true;
|
||||
}
|
||||
@@ -21,7 +33,7 @@ export default async function ProvidersSettingsPage() {
|
||||
无法连接后端服务,请确认 API 已启动后刷新。
|
||||
</p>
|
||||
) : (
|
||||
<ProvidersSettings initial={initial} />
|
||||
<ProvidersSettings initial={initial} kimiOauth={kimiOauth} />
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { LeftNav } from "./LeftNav";
|
||||
import { LeftNav, type ActiveNav } from "./LeftNav";
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
@@ -11,7 +11,7 @@ interface AppShellProps {
|
||||
// 项目内页给出 projectId → 左导航展开项目级入口(大纲/伏笔/审稿,UX §3.1)。
|
||||
projectId?: string;
|
||||
// 当前激活的项目级入口键(用于高亮)。
|
||||
activeNav?: "write" | "outline" | "foreshadow" | "review";
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
// 全局外壳:顶栏(64px) + 左导航 + 主区(UX §5.1)。
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
import Link from "next/link";
|
||||
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 {
|
||||
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿,UX §3.1)。
|
||||
@@ -50,8 +58,30 @@ function projectItems(projectId: string): NavItem[] {
|
||||
enabled: true,
|
||||
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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
167
apps/web/components/codex/CodexPage.tsx
Normal file
167
apps/web/components/codex/CodexPage.tsx
Normal 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: "时间线" },
|
||||
];
|
||||
|
||||
// 设定库 Codex(UX §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>
|
||||
);
|
||||
}
|
||||
152
apps/web/components/command/CommandPalette.tsx
Normal file
152
apps/web/components/command/CommandPalette.tsx
Normal 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;
|
||||
}
|
||||
|
||||
// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。
|
||||
// 焦点管理(打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11y(role=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>
|
||||
);
|
||||
}
|
||||
12
apps/web/components/command/CommandPaletteMount.tsx
Normal file
12
apps/web/components/command/CommandPaletteMount.tsx
Normal 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 ?? "/"} />;
|
||||
}
|
||||
97
apps/web/components/generation/CharacterCardItem.tsx
Normal file
97
apps/web/components/generation/CharacterCardItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
apps/web/components/generation/CharacterGenerator.tsx
Normal file
171
apps/web/components/generation/CharacterGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/web/components/generation/ConflictAdjudication.tsx
Normal file
75
apps/web/components/generation/ConflictAdjudication.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/web/components/generation/WorldGenerator.tsx
Normal file
78
apps/web/components/generation/WorldGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -20,12 +20,16 @@ import {
|
||||
} from "@/lib/review/history";
|
||||
import { useAccept } from "@/lib/review/useAccept";
|
||||
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 { AnnotatedText } from "./AnnotatedText";
|
||||
import { ConflictCard } from "./ConflictCard";
|
||||
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
|
||||
import { PacePanel } from "./PacePanel";
|
||||
import { StylePanel } from "@/components/style/StylePanel";
|
||||
import { RefineView } from "@/components/style/RefineView";
|
||||
|
||||
interface ReviewReportProps {
|
||||
project: ProjectResponse;
|
||||
@@ -45,6 +49,7 @@ export function ReviewReport({
|
||||
}: ReviewReportProps) {
|
||||
const review = useReviewStream();
|
||||
const accept = useAccept();
|
||||
const toast = useToast();
|
||||
|
||||
const [finalText, setFinalText] = useState(initialDraft);
|
||||
const [drafts, setDrafts] = useState<DecisionDraft[]>(() =>
|
||||
@@ -52,6 +57,10 @@ export function ReviewReport({
|
||||
);
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||
const [missing, setMissing] = useState<Set<number>>(new Set());
|
||||
// 回炉中的漂移段(null=未打开 RefineView)。
|
||||
const [refineSegment, setRefineSegment] = useState<StyleDriftSegment | null>(
|
||||
null,
|
||||
);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
|
||||
@@ -67,26 +76,33 @@ export function ReviewReport({
|
||||
() => normalizePace(initialReview),
|
||||
[initialReview],
|
||||
);
|
||||
const seededStyle = useMemo(
|
||||
() => normalizeStyleDrift(initialReview),
|
||||
[initialReview],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
const hasSeed =
|
||||
seededConflicts.length > 0 ||
|
||||
seededForeshadow.length > 0 ||
|
||||
seededPace !== null;
|
||||
seededPace !== null ||
|
||||
seededStyle !== null;
|
||||
if (hasSeed) {
|
||||
review.seed({
|
||||
conflicts: seededConflicts,
|
||||
foreshadow: seededForeshadow,
|
||||
pace: seededPace,
|
||||
style: seededStyle,
|
||||
});
|
||||
}
|
||||
}, [review, seededConflicts, seededForeshadow, seededPace]);
|
||||
}, [review, seededConflicts, seededForeshadow, seededPace, seededStyle]);
|
||||
|
||||
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
|
||||
const conflicts: ReviewConflict[] = review.state.conflicts;
|
||||
const foreshadow = review.state.foreshadow;
|
||||
const pace = review.state.pace;
|
||||
const style = review.state.style;
|
||||
const sectionStatus = (name: string): string | undefined =>
|
||||
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 unresolved = unresolvedIndices(drafts).length;
|
||||
const reviewing = review.isReviewing;
|
||||
@@ -279,6 +315,20 @@ export function ReviewReport({
|
||||
pace={pace}
|
||||
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>
|
||||
|
||||
<section className="mt-4">
|
||||
|
||||
107
apps/web/components/rules/RulesPage.tsx
Normal file
107
apps/web/components/rules/RulesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
apps/web/components/settings/KimiCodeOauth.tsx
Normal file
127
apps/web/components/settings/KimiCodeOauth.tsx
Normal 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 Code(OAuth 订阅 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 Code(OAuth)</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 Code(OAuth)"
|
||||
: "连接 Kimi Code(OAuth)"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,25 @@ import { useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
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 {
|
||||
CapabilitiesView,
|
||||
ProvidersResponse,
|
||||
TierRoutingView,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
interface ProvidersSettingsProps {
|
||||
initial: ProvidersResponse;
|
||||
// Kimi Code OAuth 连接态(GET .../oauth/status,进页 Server Component 取)。
|
||||
kimiOauth: { connected: boolean; expiresAt: string | null };
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
@@ -20,11 +30,17 @@ interface TestResult {
|
||||
capabilities: CapabilitiesView;
|
||||
}
|
||||
|
||||
// 设置页主体(UX §6.10):档位路由(只读展示)+ 凭据行(脱敏 / 添加更新 / 测试连接)。
|
||||
export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
|
||||
// 设置页主体(UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
|
||||
export function ProvidersSettings({
|
||||
initial,
|
||||
kimiOauth,
|
||||
}: ProvidersSettingsProps) {
|
||||
const toast = useToast();
|
||||
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 [savingId, setSavingId] = 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 =>
|
||||
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 apiKey = (drafts[providerId] ?? "").trim();
|
||||
if (!apiKey) {
|
||||
@@ -74,34 +119,61 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
|
||||
<div>
|
||||
<section className="mb-10">
|
||||
<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">
|
||||
{tierRouting.map((t) => (
|
||||
<li
|
||||
key={t.tier}
|
||||
className="flex items-center gap-4 px-4 py-3 text-sm"
|
||||
<ul className="divide-y divide-line rounded border border-line bg-panel">
|
||||
{routing.map((row) => (
|
||||
<li
|
||||
key={row.tier}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm"
|
||||
>
|
||||
<span className="w-20 text-ink">
|
||||
{TIER_LABELS[row.tier] ?? row.tier}
|
||||
</span>
|
||||
<label className="sr-only" htmlFor={`route-provider-${row.tier}`}>
|
||||
{TIER_LABELS[row.tier] ?? row.tier} 提供商
|
||||
</label>
|
||||
<select
|
||||
id={`route-provider-${row.tier}`}
|
||||
value={row.provider}
|
||||
onChange={(e) => updateRouting(row.tier, e.target.value)}
|
||||
className="rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
>
|
||||
<span className="w-20 text-ink">
|
||||
{TIER_LABELS[t.tier] ?? t.tier}
|
||||
</span>
|
||||
<span className="font-mono text-ink-soft">
|
||||
{t.provider} · {t.model}
|
||||
</span>
|
||||
{t.fallback && t.fallback.length > 0 ? (
|
||||
<span className="ml-auto font-mono text-xs text-ink-soft">
|
||||
回退 {t.fallback.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<KimiCodeOauth
|
||||
initialConnected={kimiOauth.connected}
|
||||
initialExpiresAt={kimiOauth.expiresAt}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 font-serif text-xl text-ink">提供商凭据</h2>
|
||||
{providers.length === 0 ? (
|
||||
@@ -111,7 +183,7 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
|
||||
</p>
|
||||
) : null}
|
||||
<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 result = results[prov.id];
|
||||
return (
|
||||
|
||||
77
apps/web/components/skills/SkillsPage.tsx
Normal file
77
apps/web/components/skills/SkillsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/web/components/style/FingerprintView.tsx
Normal file
65
apps/web/components/style/FingerprintView.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { Fingerprint } from "@/lib/style/style";
|
||||
|
||||
interface FingerprintViewProps {
|
||||
fingerprint: Fingerprint | null;
|
||||
}
|
||||
|
||||
// 文风指纹展示(UX §6.9):16 维行(维度名 + 值 + 可展开原文证据)。
|
||||
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>
|
||||
);
|
||||
}
|
||||
100
apps/web/components/style/RefineView.tsx
Normal file
100
apps/web/components/style/RefineView.tsx
Normal 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;
|
||||
}
|
||||
|
||||
// 回炉 diff(UX §8.3):POST .../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>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/style/StylePage.tsx
Normal file
48
apps/web/components/style/StylePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/web/components/style/StylePanel.tsx
Normal file
87
apps/web/components/style/StylePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/web/components/style/StyleUpload.tsx
Normal file
110
apps/web/components/style/StyleUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,20 +7,25 @@ import { AppShell } from "@/components/AppShell";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||
import { useDraftStream } from "@/lib/stream/useDraftStream";
|
||||
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
|
||||
import { ChapterList } from "./ChapterList";
|
||||
import { ChapterAssistant } from "./ChapterAssistant";
|
||||
import { Editor } from "./Editor";
|
||||
|
||||
interface WorkbenchProps {
|
||||
project: ProjectResponse;
|
||||
// RSC 种入的已存草稿正文(GET .../draft);无草稿(404)→ ""(空编辑器)。
|
||||
initialText?: string;
|
||||
}
|
||||
|
||||
// M1:单章工作台。章节列表为占位(无章节列表端点);默认第 1 章。
|
||||
const M1_CHAPTER_NO = 1;
|
||||
// WORKBENCH_CHAPTER_NO 移到 lib/workbench/chapter.ts(非客户端模块),供 Server Component 安全导入。
|
||||
|
||||
export function Workbench({ project }: WorkbenchProps) {
|
||||
const chapterNo = M1_CHAPTER_NO;
|
||||
const [text, setText] = useState("");
|
||||
export function Workbench({ project, initialText = "" }: WorkbenchProps) {
|
||||
const chapterNo = WORKBENCH_CHAPTER_NO;
|
||||
// 用已存草稿作初值;流式开始后由下方 effect 覆盖(流不会被初值反扑——
|
||||
// stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。
|
||||
const [text, setText] = useState(initialText);
|
||||
const autosave = useAutosave(project.id, chapterNo);
|
||||
const stream = useDraftStream();
|
||||
const lastStreamText = useRef("");
|
||||
@@ -137,7 +142,7 @@ function Toolbar({
|
||||
</button>
|
||||
)}
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
字数 {wordCount.toLocaleString()}
|
||||
字数 {wordCount.toLocaleString("en-US")}
|
||||
</span>
|
||||
<Link
|
||||
href={`/projects/${projectId}/outline`}
|
||||
|
||||
1124
apps/web/lib/api/schema.d.ts
vendored
1124
apps/web/lib/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
68
apps/web/lib/api/server.test.ts
Normal file
68
apps/web/lib/api/server.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
import { serverApiBase } from "./config";
|
||||
import type {
|
||||
CharacterListResponse,
|
||||
DraftView,
|
||||
ForeshadowBoardResponse,
|
||||
OutlineChapterView,
|
||||
OutlineResponse,
|
||||
ProjectListResponse,
|
||||
ProjectResponse,
|
||||
OAuthStatusResponse,
|
||||
ProvidersResponse,
|
||||
ReviewHistoryResponse,
|
||||
RuleListResponse,
|
||||
SkillListResponse,
|
||||
StyleFingerprintResponse,
|
||||
WorldEntityListResponse,
|
||||
} from "./types";
|
||||
|
||||
// 服务端只读取数据(Server Components)。失败时抛出,由页面边界处理。
|
||||
@@ -16,6 +25,16 @@ async function getJson<T>(path: string): Promise<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> {
|
||||
return getJson<ProjectListResponse>("/projects");
|
||||
}
|
||||
@@ -28,6 +47,13 @@ export async function fetchProviders(): Promise<ProvidersResponse> {
|
||||
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(
|
||||
projectId: string,
|
||||
chapterNo: number,
|
||||
@@ -43,3 +69,66 @@ export async function fetchForeshadow(
|
||||
): Promise<ForeshadowBoardResponse> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"]
|
||||
export type ProjectListResponse = components["schemas"]["ProjectListResponse"];
|
||||
export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"];
|
||||
export type DraftResponse = components["schemas"]["DraftResponse"];
|
||||
// GET .../draft 读端点:含正文,供工作台重访时重载编辑器(404→空编辑器)。
|
||||
export type DraftView = components["schemas"]["DraftView"];
|
||||
export type ProvidersResponse = components["schemas"]["ProvidersResponse"];
|
||||
export type ProviderView = components["schemas"]["ProviderView"];
|
||||
export type TierRoutingView = components["schemas"]["TierRoutingView"];
|
||||
@@ -40,3 +42,48 @@ export type OutlineChapterView = components["schemas"]["OutlineChapterView"];
|
||||
export type OutlineGenerateRequest =
|
||||
components["schemas"]["OutlineGenerateRequest"];
|
||||
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-flow(C3 扩 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"];
|
||||
|
||||
75
apps/web/lib/command/palette.test.ts
Normal file
75
apps/web/lib/command/palette.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
154
apps/web/lib/command/palette.ts
Normal file
154
apps/web/lib/command/palette.ts
Normal 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);
|
||||
}
|
||||
199
apps/web/lib/generation/cards.test.ts
Normal file
199
apps/web/lib/generation/cards.test.ts
Normal 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(["势力", "地点"]);
|
||||
});
|
||||
});
|
||||
BIN
apps/web/lib/generation/cards.ts
Normal file
BIN
apps/web/lib/generation/cards.ts
Normal file
Binary file not shown.
150
apps/web/lib/generation/useCharacterGen.ts
Normal file
150
apps/web/lib/generation/useCharacterGen.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
58
apps/web/lib/generation/useWorldGen.ts
Normal file
58
apps/web/lib/generation/useWorldGen.ts
Normal 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 };
|
||||
}
|
||||
150
apps/web/lib/jobs/job.test.ts
Normal file
150
apps/web/lib/jobs/job.test.ts
Normal 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
111
apps/web/lib/jobs/job.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// 长任务(jobs)轮询纯逻辑:把松散 `GET /jobs/{id}` 响应安全收窄成 JobView,
|
||||
// 并提供一个 setTimeout 轮询状态机的纯 reducer(C3.5 / ARCH §7.4)。
|
||||
// schema 把 GET /jobs/{id} 标成 {[k]:unknown}(T0.3 裸端点),这里安全narrow。
|
||||
|
||||
// 后端 job 状态机(C3.5):queued → 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 };
|
||||
}
|
||||
|
||||
// 轮询状态机:polling(queued/running)→ done | error(done/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 → 终态 done;failed → 终态 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";
|
||||
}
|
||||
88
apps/web/lib/jobs/useJobPoll.ts
Normal file
88
apps/web/lib/jobs/useJobPoll.ts
Normal 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 };
|
||||
}
|
||||
@@ -42,6 +42,18 @@ export interface PaceReport {
|
||||
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 {
|
||||
event: "section";
|
||||
data: { name: string; status: SectionStatus };
|
||||
@@ -68,6 +80,17 @@ export interface PaceEvent {
|
||||
beat_map: number[];
|
||||
};
|
||||
}
|
||||
export interface StyleEvent {
|
||||
event: "style";
|
||||
data: {
|
||||
score: number;
|
||||
segments: {
|
||||
idx: number;
|
||||
score: number;
|
||||
label?: string | null;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
export interface DoneEvent {
|
||||
event: "done";
|
||||
data: { length: number };
|
||||
@@ -81,6 +104,7 @@ export type ReviewSseEvent =
|
||||
| ConflictEvent
|
||||
| ForeshadowEvent
|
||||
| PaceEvent
|
||||
| StyleEvent
|
||||
| DoneEvent
|
||||
| ErrorEvent;
|
||||
|
||||
@@ -89,6 +113,7 @@ const KNOWN_EVENTS = new Set([
|
||||
"conflict",
|
||||
"foreshadow",
|
||||
"pace",
|
||||
"style",
|
||||
"done",
|
||||
"error",
|
||||
]);
|
||||
@@ -160,6 +185,7 @@ export interface ReviewStreamState {
|
||||
conflicts: ReviewConflict[];
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
style: StyleDriftReport | null;
|
||||
error: { code: string; message: string; request_id?: string | null } | null;
|
||||
}
|
||||
|
||||
@@ -169,6 +195,7 @@ export const initialReviewState: ReviewStreamState = {
|
||||
conflicts: [],
|
||||
foreshadow: [],
|
||||
pace: null,
|
||||
style: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -202,6 +229,19 @@ export function reduceReview(
|
||||
phase: "reviewing",
|
||||
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":
|
||||
return { ...state, phase: "done" };
|
||||
case "error":
|
||||
|
||||
70
apps/web/lib/review/style-sse.test.ts
Normal file
70
apps/web/lib/review/style-sse.test.ts
Normal 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" }]);
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
type PaceReport,
|
||||
type ReviewConflict,
|
||||
type ReviewStreamState,
|
||||
type StyleDriftReport,
|
||||
} from "./sse";
|
||||
|
||||
export interface ReviewSeed {
|
||||
conflicts: ReviewConflict[];
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
style: StyleDriftReport | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
@@ -46,6 +48,7 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
|
||||
conflicts: action.seed.conflicts,
|
||||
foreshadow: action.seed.foreshadow,
|
||||
pace: action.seed.pace,
|
||||
style: action.seed.style,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
||||
54
apps/web/lib/rules/rules.test.ts
Normal file
54
apps/web/lib/rules/rules.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
58
apps/web/lib/rules/rules.ts
Normal file
58
apps/web/lib/rules/rules.ts
Normal 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;
|
||||
}
|
||||
62
apps/web/lib/rules/useRules.ts
Normal file
62
apps/web/lib/rules/useRules.ts
Normal 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 };
|
||||
}
|
||||
191
apps/web/lib/settings/kimiOauth.test.ts
Normal file
191
apps/web/lib/settings/kimiOauth.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
101
apps/web/lib/settings/kimiOauth.ts
Normal file
101
apps/web/lib/settings/kimiOauth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Kimi Code OAuth device-flow 连接态纯逻辑(C3 扩 K1.3,K1.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。
|
||||
// - error:device 启动失败 / 轮询失败 / 过期 / 拒绝(展示错误 + 允许重试)。
|
||||
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=false):connected ? connected : idle。
|
||||
// - 已发起:done 且 job.connected → connected;error → 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",
|
||||
});
|
||||
}
|
||||
107
apps/web/lib/settings/providers.test.ts
Normal file
107
apps/web/lib/settings/providers.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,103 @@
|
||||
// 提供商鉴权方式:api_key(凭据行)或 oauth(device-flow 连接按钮,如 Kimi Code)。
|
||||
export type ProviderAuthKind = "api_key" | "oauth";
|
||||
|
||||
export interface KnownProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
auth: ProviderAuthKind;
|
||||
// OAuth 提供商在档位路由里用的默认 model(API-key 提供商由用户填写/后端默认)。
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
// 已知提供商(UX §6.10)。后端按 provider 字符串识别;这里只是 UI 候选列表。
|
||||
export const KNOWN_PROVIDERS: { id: string; label: string }[] = [
|
||||
{ id: "anthropic", label: "Anthropic" },
|
||||
{ id: "deepseek", label: "DeepSeek" },
|
||||
{ id: "kimi", label: "Kimi" },
|
||||
{ id: "openai", label: "OpenAI" },
|
||||
{ id: "qwen", label: "通义千问" },
|
||||
{ id: "glm", label: "智谱 GLM" },
|
||||
{ id: "gemini", label: "Gemini" },
|
||||
// `kimi-code` 是 OAuth 订阅 plan 提供商(device flow,伪造客户端头,有封号风险),与 API-key
|
||||
// 的 `kimi` 分离(K1.4)。`kimi-code-key` 是同一订阅 plan 的**静态 Console Key** 路径(ToS 合规、
|
||||
// 无伪造头)——普通 api_key 提供商,固定 model `kimi-for-coding`。
|
||||
export const KNOWN_PROVIDERS: KnownProvider[] = [
|
||||
{ id: "anthropic", label: "Anthropic", auth: "api_key" },
|
||||
{ id: "deepseek", label: "DeepSeek", auth: "api_key" },
|
||||
{ id: "kimi", label: "Kimi", auth: "api_key" },
|
||||
{ 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 Code(OAuth)",
|
||||
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> = {
|
||||
writer: "写手档",
|
||||
analyst: "分析档",
|
||||
light: "轻量档",
|
||||
};
|
||||
|
||||
// 档位顺序(路由编辑器列出全部档位,缺省的也能配)。
|
||||
export const TIERS = ["writer", "analyst", "light"] as const;
|
||||
export type Tier = (typeof TIERS)[number];
|
||||
|
||||
// 某 provider 在路由里使用的默认 model:OAuth 提供商有固定 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 的默认 model(OAuth 固定 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 }));
|
||||
}
|
||||
|
||||
139
apps/web/lib/settings/useKimiOauth.ts
Normal file
139
apps/web/lib/settings/useKimiOauth.ts
Normal 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)。
|
||||
// connect:POST .../oauth/start(202)→ 展示 user_code + 开浏览器 → 轮询 job 到 done/failed。
|
||||
// status 进页传入 initialConnected/initialExpiresAt(Server 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,
|
||||
};
|
||||
}
|
||||
38
apps/web/lib/skills/skills.test.ts
Normal file
38
apps/web/lib/skills/skills.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
44
apps/web/lib/skills/skills.ts
Normal file
44
apps/web/lib/skills/skills.ts
Normal 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) ?? [] }));
|
||||
}
|
||||
127
apps/web/lib/style/style.test.ts
Normal file
127
apps/web/lib/style/style.test.ts
Normal 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
129
apps/web/lib/style/style.ts
Normal 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.ts(reduce 复用),上面已 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;
|
||||
}
|
||||
91
apps/web/lib/style/useRefine.ts
Normal file
91
apps/web/lib/style/useRefine.ts
Normal 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 };
|
||||
}
|
||||
123
apps/web/lib/style/useStyleLearn.ts
Normal file
123
apps/web/lib/style/useStyleLearn.ts
Normal 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);
|
||||
}
|
||||
5
apps/web/lib/workbench/chapter.ts
Normal file
5
apps/web/lib/workbench/chapter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// 工作台当前章号常量(纯模块,**非** "use client")。
|
||||
// 必须放在非客户端模块:Server Component(write/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
@@ -16,10 +16,66 @@
|
||||
- 档位路由 `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)。
|
||||
|
||||
### 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` 不直接出 API(review/draft SSE 不回 served_by,settings/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 #2(2026-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 schema(SQLAlchemy 模型) owner @db 状态: 待定义
|
||||
- 来源:`ARCHITECTURE.md §3.1` DDL(11 创作表 + chapter_reviews + 运营表;无向量列;users stub)。
|
||||
- 消费方:`@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→nullable;down 逆向。`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/97)mypy 报 `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)
|
||||
- 来源:`ARCHITECTURE.md §7.2` 端点清单(章节端点统一 `/projects/:id/chapters/:no/...`;含 `PUT .../draft`(自动保存)、`/refine`、`/jobs/:id`、`/reviews`)。
|
||||
- 消费方:`@frontend`(经 OpenAPI→TS 客户端)。**改端点/字段 → 前端必须 `pnpm gen:api` 重生成客户端。**
|
||||
@@ -54,6 +110,93 @@
|
||||
- **beats 存储形**:DB `outline.beats` 是 JSONB dict→写侧包成 `{"beats":[...]}` 落库,API 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`。
|
||||
- **@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`(只 dimensions,assemble 用),故 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`(单 provider,M1 兼容);无任何可用凭据 → 503。单 provider 配置 = 单元素链(行为不变)。
|
||||
- **`build_adapter` per-provider 接线(M5 R1 follow-up #2,2026-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 兼容 provider(deepseek/kimi/qwen/glm/openai)经 `_PROVIDER_BASE_URLS` 传 base_url;Anthropic/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→API,T5.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_enc,read-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-19;M5 R3 迁入 ww_skills 包, 2026-06-19)
|
||||
- 来源:`ARCHITECTURE.md §5.6` / `PRODUCT_SPEC §5.5`(声明式 Skill = AgentSpec;「声明式权限 + apply 层白名单」,**非进程沙箱**)。消费方:M5 编排/生成入库层、技能库 UI(T5.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)`(classmethod,async)→ 每条跑 `validate_declaration`(越权声明 → **AppError VALIDATION**,加载中断)→ frozen `dict[name,AgentSpec]`;`get(name)->AgentSpec`(缺 → NOT_FOUND)、`names()->list[str]`、`list_scope(scope)->list[AgentSpec]`。不变量 #2:只带 `tier`,不解析 model(model 解析在网关)。
|
||||
- **表权限沙箱**(`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` 内自建 gateway(analyst)+ repo(用 `run_job` 传入的独立 session)。注入缝 `get_job_repo`/`get_session_factory` 测试经 `app.dependency_overrides` override。
|
||||
|
||||
## C4 · 编排器接口(LangGraph 写章图) owner @llm 状态: 稳定(M1/T1.3, 2026-06-18;M2 扩四审/验收)
|
||||
- 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3(SSE)。**M1 仅单 `write` 节点**;并行四审/collect/interrupt(accept) 属 M2。
|
||||
- 位置:`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` 不发结果。
|
||||
- 消费方:前端 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` 产 parsed,style 天然套循环)。
|
||||
- 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` 结果、照常落库(区别于 incomplete→None)。
|
||||
- `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.4(StylePanel ◔ 相似度 + 漂移段一键回炉,`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 入 JSONB(DB 列 `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 入各自 JSONB(DB `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.5,T5.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` gate;analyst 网关复用 `build_gateway_for_tier(session,store,"analyst")`,writer 用 `"writer"`。记账闭环同既有(端点末尾 commit)。
|
||||
|
||||
## C5 · 记忆服务 `assemble` / `select_relevant_entities` owner @backend 状态: 稳定(T1.2, 2026-06-18)
|
||||
- 来源:`ARCHITECTURE.md §3.4 / §5.3`(确定性选择:显式+主角+近况;渲染卡片;缓存断点)。
|
||||
- 位置:`packages/core/ww_core/memory/`(+ `domain/repositories.py`)。
|
||||
- 输出(决策:中性文本,非 `LlmRequest`,见 decisions 2026-06-18):
|
||||
- `AssembledContext{ stable_core:str, volatile:str, selection:SelectionTrace }`
|
||||
- `stable_core`:断点前(世界硬规则+定型主角(无 latest_state)+文风指纹+合并规则),已排序/无时间戳/无 UUID。
|
||||
- `volatile`:断点后(注入卡片(含 latest_state)+伏笔窗口+近况摘要+本章 beats)。
|
||||
- `stable_core`:断点前(**作品蓝本(title/logline/premise/theme)**+世界硬规则+定型主角(无 latest_state)+文风指纹+合并规则),已排序/无时间戳/无 UUID。「作品蓝本」是书级 spec、定型 → 入缓存前缀首段。
|
||||
- `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"`。
|
||||
- 入口:`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]`(global→genre→style→project)。
|
||||
- 依赖注入:`MemoryRepos` 捆绑 7 个 Protocol(Outline/Character/WorldEntity/Digest/Foreshadow/Style/Rules);单测注入内存 fake,运行时 `sql_memory_repos(AsyncSession)`(**T1.4 注入点**)。
|
||||
- 依赖注入:`MemoryRepos` 捆绑 **8** 个 Protocol(Outline/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)。
|
||||
- 消费方:**T1.3 write 节点** → 构造 `LlmRequest(system=[Block(stable_core, cache=True)], input=volatile)`。
|
||||
|
||||
|
||||
@@ -14,6 +14,64 @@
|
||||
|
||||
---
|
||||
|
||||
## [2026-06-19] Kimi Code OAuth:token 存储包 + 刷新启发式 + 省略 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_in,KISS;对 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_in(YAGNI)。代价: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 OAuth:device 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`)**不带** scope(OAuth 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_url,Anthropic/Gemini 传 None 走原生适配器。仅在凭据缺失时返 None(回退链跳过)。保留既有 503/凭据/单 provider 兼容行为。
|
||||
- 理由 / 取舍:最小改动让回退链支持非 OpenAI-兼容 provider;adapter 选择逻辑收敛到网关包的工厂(单一真源),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 形变落写侧 repo(list→{"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)
|
||||
- 背景:学文风走 jobs(M4-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 dep(dep 绑的是请求 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
|
||||
- 背景:`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`。
|
||||
@@ -78,3 +136,9 @@
|
||||
- 选择:CLAUDE.md 只保留跨文档、易错的**规则**(含 9 条架构不变量);所有枚举型/具体值改为指向 ARCHITECTURE/PRODUCT_SPEC 对应 §(唯一真源)。新增"冲突裁决"段:ARCHITECTURE > UX_SPEC/DEV_PLAN > PRODUCT_SPEC,矛盾时回写上游。
|
||||
- 理由 / 取舍:消除文档间漂移面、降低每 session 上下文成本;代价是查具体值需多跳一层到 ARCHITECTURE(可接受)。
|
||||
- 影响:编辑 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 才能 import,app/测试/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 执行。
|
||||
|
||||
@@ -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 真落 pg:override `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 走 dep;call[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 obj,typecheck 绿)、`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 provider(kimi-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 引模块级 SqlJobRepo,FakeSession 无 .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.3):service 函数(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_key(SDK 自动发 `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 即绿。首个新迁移(M1–M5 全零建表)= K1 新功能预期内。
|
||||
- [2026-06-19] @db **新迁移文件 autogenerate 后须手修两处再过 ruff**:① autogenerate 模板把 `from sqlalchemy.dialects import postgresql` **写两遍**(重复 import,ruff 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` import);anthropic/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 route(Server 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、a11y(role=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 必须是合法 Fernet(32 字节 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),三个网关 dep(worldbuilder/character_gen/precheck)override 成同一个。**生成预览端点也要 `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.1):C6 扩 `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.1,ARCH §6.5):`precheck_generated_cards` 传入 `continuity_spec`(不是 character-gen 自调、不是新审种),跑生成角色卡 vs 世界观/已有角色真相源、返 `list[Conflict]`。守不变量#1(agent 只经 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)。同 M3:override `get_session_factory`→`e2e_sm`(同测试 engine/loop),ASGITransport 下 `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` Protocol(set_running/complete/fail)——比全 `JobRepo` 窄,单测 fake 不必实现 create/get/reap(同 `run_overdue_scan` 用最小 `OverdueScanRepo` 先例)。
|
||||
- [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-only(T3.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-only,commit 归调用方」(commit 仍会 flush,故记账语义不变)。
|
||||
- [2026-06-18] @qa/@llm **并行审记账撞 session(M3 真 bug,T3.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)。修向:审稿期记账避免并发 flush(add-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。
|
||||
@@ -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-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-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_authorization;token 交换(`poll_token`)/刷新(`refresh`)**不带** scope(device 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 列),无迁移。
|
||||
|
||||
183
packages/agents/tests/test_generation_specs.py
Normal file
183
packages/agents/tests/test_generation_specs.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""T5.1 worldbuilder + character-gen 契约测试:spec + schema(C6 扩 / 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+characters,writes=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 schema(worldbuilder)----
|
||||
|
||||
|
||||
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 schema(character-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
Reference in New Issue
Block a user