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

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

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

View File

@@ -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` 读侧 + 学文风 workmode=update → 第二次 append version+1断言。
"""
def __init__(self) -> None:
# version -> (dimensions, evidence)
self.rows: dict[int, tuple[dict[str, Any], dict[str, Any]]] = {}
self.append_calls = 0
async def append(
self,
project_id: uuid.UUID,
*,
dimensions_json: dict[str, Any],
evidence_json: dict[str, Any],
) -> int:
self.append_calls += 1
next_version = (max(self.rows) if self.rows else 0) + 1
self.rows[next_version] = (dict(dimensions_json), dict(evidence_json))
return next_version
async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None:
if not self.rows:
return None
version = max(self.rows)
dimensions, evidence = self.rows[version]
return StyleFingerprintView(dimensions=dimensions, evidence=evidence, version=version)
class FakeJobRepo:
"""实现 `JobRepo` Protocol内存create/get/状态流转,供 `POST /style` 断言。"""
def __init__(self) -> None:
self.rows: dict[uuid.UUID, JobView] = {}
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
view = JobView(id=uuid.uuid4(), kind=kind, status=STATUS_QUEUED, progress=0)
self.rows[view.id] = view
return view
async def set_running(self, job_id: uuid.UUID) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_RUNNING})
self.rows[job_id] = view
return view
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
view = self.rows[job_id].model_copy(update={"progress": max(0, min(100, pct))})
self.rows[job_id] = view
return view
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
view = self.rows[job_id].model_copy(
update={"status": STATUS_DONE, "progress": PROGRESS_COMPLETE, "result": dict(result)}
)
self.rows[job_id] = view
return view
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error})
self.rows[job_id] = view
return view
async def get(self, job_id: uuid.UUID) -> JobView | None:
return self.rows.get(job_id)
async def reap_zombies(self) -> int:
return 0
class FakeReviewGateway:
"""实现审稿/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",

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import httpx
import pytest
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
# ---- 伏笔看板 ----

View File

@@ -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

View File

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

View File

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

View File

@@ -6,7 +6,10 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from 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 reaperM4-d / §7.4 缓解):进程重启会丢未跑完的 BackgroundTask把残留
# status=running 的 job 标 failed让用户看到失败可重试而非进度条永转。自 commit、幂等。
async with get_sessionmaker()() as session:
reaped = await SqlJobRepo(session).reap_zombies()
if reaped:
log.info("job_zombies_reaped", count=reaped)
yield
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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from 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)

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,6 +59,22 @@ class DraftResponse(BaseModel):
length: int
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----

View File

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

View File

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

View File

@@ -20,12 +20,24 @@ from ww_llm_gateway.adapters.base import Capabilities
STUB_OWNER_ID = uuid.UUID(int=1)
# 凭据认证类型(`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-flowK1.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-flowK1.3)。
`auth_type="oauth"`、`oauth_enc=<Fernet 加密 token 包>`、`api_key_enc=None`。
显式 read-modify-write同 `upsert_credential`:含可空 project_id 的唯一约束不能用
PG `ON CONFLICT`,见 memory/gotchas。明文 token 绝不进此层(已加密)。
"""
existing = (
await self._session.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == owner_id,
ProviderCredential.project_id.is_(None),
ProviderCredential.provider == provider,
)
)
).scalar_one_or_none()
if existing is None:
self._session.add(
ProviderCredential(
owner_id=owner_id,
project_id=None,
provider=provider,
api_key_enc=None,
auth_type=AUTH_TYPE_OAUTH,
oauth_enc=oauth_enc,
)
)
else:
existing.api_key_enc = None
existing.auth_type = AUTH_TYPE_OAUTH
existing.oauth_enc = oauth_enc
await self._session.commit()
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
"""删除凭据行OAuth disconnect / 撤销)。返回是否删到行。"""
existing = (
await self._session.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == owner_id,
ProviderCredential.project_id.is_(None),
ProviderCredential.provider == provider,
)
)
).scalar_one_or_none()
if existing is None:
return False
await self._session.delete(existing)
await self._session.commit()
return True
async def upsert_routing(self, routing: StoredRouting) -> None:
existing = (
await self._session.execute(

View File

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

View File

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

View File

@@ -11,40 +11,58 @@ from __future__ import annotations
from typing import Annotated
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:
"""规则写侧 repoPOST /rules作者显式加规则只 flush端点提交。测试经 override 注。"""
return SqlRuleWriteRepo(session)
async def get_skill_registry(
session: Annotated[AsyncSession, Depends(get_session)],
) -> SkillRegistry:
"""从 `skills` 表加载声明式 skill registryARCH §5.6;越权声明 → VALIDATION
每请求按 session 加载registry 不可变快照)。测试经 `app.dependency_overrides` 注 fake repo
或直接注 `SkillRegistry`。技能库 UIT5.6)经此读 builtin/custom/community。
"""
return await SkillRegistry.load(SqlSkillRepo(session))
def get_style_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> StyleFingerprintWriteRepo:
"""文风指纹写侧 repo`GET /style` 读最新 + 学文风后台任务 append 版本化指纹)。
注:学文风的 `work` 在 `run_job` 自建的独立 session 上自造 repo请求 session 已关闭),
故本依赖只服务于 `GET /style` 读侧。测试经 `app.dependency_overrides` 注 fake。
"""
return SqlStyleFingerprintWriteRepo(session)
def get_character_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> CharacterWriteRepo:
"""角色写侧 repoPOST /characters 入库schema→DB 形变;只 flush端点提交
测试经 `app.dependency_overrides` 注 fake。
"""
return SqlCharacterWriteRepo(session)
def get_world_entity_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> WorldEntityWriteRepo:
"""世界观实体写侧 repo预留对称入库当前生成端点只用其形变能力"""
return SqlWorldEntityWriteRepo(session)
def get_rules_read_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> RulesRepo:
"""规则读侧 repoGET /rules 列表,复用 C5 assemble 读侧;测试经 override 注 fake"""
return SqlRulesRepo(session)
def get_outline_read_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> OutlineRepo:
"""大纲读侧 repoGET /outline 列表,复用 C5 assemble 读侧;测试经 override 注 fake"""
return SqlOutlineRepo(session)
async def get_worldbuilder_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""世界观生成writer 档位)的可注入网关缝。测试经 override 注 mock产 WorldGenResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer")
async def get_character_gen_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""角色生成writer 档位)的可注入网关缝。测试经 override 注 mock产 CharacterGenResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "writer")
async def get_precheck_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""入库前 continuity 预检analyst 档位)的可注入网关缝。测试注 mock产 ContinuityReview"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
def get_session_factory() -> SessionFactory:
"""验收后到期扫描的**独立 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_urlAnthropic/Gemini 走原生 SDKbase_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 路由行:退回全局默认(单 providerM1 兼容)。
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")

View File

@@ -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 订阅 planOAuth device-flowK1.3coding 端点OpenAI 兼容 + 伪造头)。
"kimi-code": "https://api.kimi.com/coding/v1",
# Kimi 订阅 plan静态 Console KeyToS 合规):同一 coding 端点,纯 bearer 无伪造头。
"kimi-code-key": "https://api.kimi.com/coding/v1",
}
@@ -68,6 +72,13 @@ class GatewayProviderProbe:
f"未知提供商 {provider}",
{"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:

View File

@@ -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>
);

View File

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

View File

@@ -1,19 +1,23 @@
import { notFound } from "next/navigation";
import { 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 取项目OutlineEditorClient按需
// 触发 POST .../outline 生成大纲M3 无 GET 大纲端点,进页空、生成后填充)。
// 大纲编辑器页UX §6.7。Server Component 取项目 + 已存大纲GET .../outline
// 空/错误降级为空列表作为初始章节重访时显已生成的大纲OutlineEditorClient
// 仍按需触发 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();
}

View File

@@ -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 .../draft404→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 {

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,36 @@
import { notFound } from "next/navigation";
import { 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} />;
}

View File

@@ -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>

View File

@@ -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)。

View File

@@ -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",
},
];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,12 +20,16 @@ import {
} from "@/lib/review/history";
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">

View File

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

View File

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

View File

@@ -4,15 +4,25 @@ import { useState } from "react";
import { api } from "@/lib/api/client";
import { 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 (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,20 +7,25 @@ import { AppShell } from "@/components/AppShell";
import type { ProjectResponse } from "@/lib/api/types";
import { 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`}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,10 +1,19 @@
import { serverApiBase } from "./config";
import 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`,
);
}

View File

@@ -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-flowC3 扩 K1.3)。
export type OAuthStartResponse = components["schemas"]["OAuthStartResponse"];
export type OAuthDisconnectResponse =
components["schemas"]["OAuthDisconnectResponse"];
export type OAuthStatusResponse = components["schemas"]["OAuthStatusResponse"];
// M5 规则 + 技能C3 扩 T5.2 / T5.5)。
export type RuleCreateRequest = components["schemas"]["RuleCreateRequest"];
export type RuleView = components["schemas"]["RuleView"];
export type RuleListResponse = components["schemas"]["RuleListResponse"];
export type SkillView = components["schemas"]["SkillView"];
export type SkillListResponse = components["schemas"]["SkillListResponse"];

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -42,6 +42,18 @@ export interface PaceReport {
beat_map: number[];
}
// 文风漂移第四审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":

View File

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

View File

@@ -11,12 +11,14 @@ import {
type PaceReport,
type 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,103 @@
// 提供商鉴权方式api_key凭据行或 oauthdevice-flow 连接按钮,如 Kimi Code
export type ProviderAuthKind = "api_key" | "oauth";
export interface KnownProvider {
id: string;
label: string;
auth: ProviderAuthKind;
// OAuth 提供商在档位路由里用的默认 modelAPI-key 提供商由用户填写/后端默认)。
defaultModel?: string;
}
// 已知提供商UX §6.10)。后端按 provider 字符串识别;这里只是 UI 候选列表。
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 CodeOAuth",
auth: "oauth",
defaultModel: "kimi-for-coding",
},
];
// API-key 凭据行只展示 api_key 提供商OAuth 提供商有独立连接区。
export const API_KEY_PROVIDERS: KnownProvider[] = KNOWN_PROVIDERS.filter(
(p) => p.auth === "api_key",
);
export const TIER_LABELS: Record<string, string> = {
writer: "写手档",
analyst: "分析档",
light: "轻量档",
};
// 档位顺序(路由编辑器列出全部档位,缺省的也能配)。
export const TIERS = ["writer", "analyst", "light"] as const;
export type Tier = (typeof TIERS)[number];
// 某 provider 在路由里使用的默认 modelOAuth 提供商有固定 model其余给空串占位。
export function defaultModelFor(providerId: string): string {
return (
KNOWN_PROVIDERS.find((p) => p.id === providerId)?.defaultModel ?? ""
);
}
// 单条档位路由的可编辑草稿(纯数据)。
export interface RoutingDraft {
tier: string;
provider: string;
model: string;
}
export interface TierRouting {
tier: string;
provider: string;
model: string;
fallback?: string[] | null;
}
// 把已存路由(可能缺档位)展开成「全部档位」的可编辑草稿(缺的给空)。
export function toRoutingDrafts(existing: TierRouting[]): RoutingDraft[] {
const byTier = new Map(existing.map((r) => [r.tier, r]));
return TIERS.map((tier) => {
const row = byTier.get(tier);
return {
tier,
provider: row?.provider ?? "",
model: row?.model ?? "",
};
});
}
// 选择 provider 时自动套用该 provider 的默认 modelOAuth 固定 model否则保留已填值或清空
export function applyProviderChange(
draft: RoutingDraft,
providerId: string,
): RoutingDraft {
const def = defaultModelFor(providerId);
return { ...draft, provider: providerId, model: def !== "" ? def : "" };
}
// 草稿 → PUT body 的 tier_routing只取选了 provider+model 的行(不可变)。
export function draftsToRoutingInput(
drafts: RoutingDraft[],
): { tier: string; provider: string; model: string }[] {
return drafts
.filter((d) => d.provider.trim() !== "" && d.model.trim() !== "")
.map((d) => ({ tier: d.tier, provider: d.provider, model: d.model }));
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long