feat(toolbox): T6 创作工具箱通用生成器框架 — 8 新生成器 + 声明驱动落地页 + P2 收尾

通用执行路径驱动全部生成器("加生成器=加一份声明"):
- @llm: ww_agents +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/
  glossary/opening/fine-outline,只声明 tier)+ build_outline_chapter_context
- @backend: ww_skills GeneratorTool 描述符 + TOOLBOX(11) + get_tool;3 通用端点
  GET /skills/toolbox · POST .../skills/{tool_key}/generate(预览不写库,仅记账) ·
  POST .../ingest(复用 continuity 409 + partition_writes 白名单);纯 context 派发
- @frontend: 工具箱落地页 RSC + 声明驱动 GeneratorRunner + lib/toolbox 纯函数
  + LeftNav「工具箱」+ ⌘K nav-toolbox/action-gen-*;legacy 3 跳现页
- @qa: tests/test_t6_toolbox_e2e.py 5 用例真 pg + mock 网关零 token,无端点 bug
- P2 收尾: 限流→decisions.md 记延后(单用户原型);noopener/Committable 早已修

守不变量 #2(只声明 tier)/#3(预览不写库,入库经验收 gate)/#9(缓存前缀)。无 DB 迁移。
门禁绿: 后端 ruff/format/mypy 195/alembic 无漂移/pytest 583;前端 lint/tsc/vitest 279/build。
spec 回写 PRODUCT_SPEC §7 + ARCHITECTURE §7.2 端点表。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-22 20:37:55 +02:00
parent 1f1afa37b6
commit f43ccd293f
46 changed files with 4848 additions and 12 deletions

View File

@@ -0,0 +1,84 @@
"""T6.3 创作工具箱 context 派发纯函数单测LLM-free确定性
每种策略的注入文本形状 + with_outline_chapter 缺节拍降级(空 beats 不报错)。
"""
from __future__ import annotations
import pytest
from ww_api.services.toolbox_context import build_toolbox_context
def test_brief_only_includes_setting_and_brief() -> None:
text = build_toolbox_context(
"brief_only", brief="爽文脑洞", project_context="标题:测试\n题材:玄幻"
)
assert "## 作品设定" in text
assert "标题:测试" in text
assert "## 创作需求" in text
assert "爽文脑洞" in text
def test_brief_only_empty_brief_degrades() -> None:
text = build_toolbox_context("brief_only", brief="", project_context="标题:测试")
# 空 brief → 降级占位(由 system_prompt 的「自由发散」纪律处理)。
assert "自由发散" in text
def test_with_project_uses_brief_context() -> None:
text = build_toolbox_context(
"with_project", brief="多版简介", project_context="标题:测试\n前提:废柴逆袭"
)
assert "前提:废柴逆袭" in text
assert "多版简介" in text
def test_with_world_injects_world_block() -> None:
text = build_toolbox_context(
"with_world",
brief="取个名字",
project_context="标题:测试",
world_context="- [力量体系] 灵脉:灵力守恒",
)
assert "## 世界观" in text
assert "灵脉" in text
assert "取个名字" in text
def test_with_world_empty_world_degrades() -> None:
text = build_toolbox_context(
"with_world", brief="x", project_context="标题:测试", world_context=""
)
assert "暂无世界观设定" in text
def test_with_outline_chapter_injects_beats() -> None:
text = build_toolbox_context(
"with_outline_chapter",
brief="展开细纲",
project_context="标题:测试",
chapter_no=3,
beats=["主角觉醒", "初遇反派"],
)
assert "第 3 章大纲节拍" in text
assert "主角觉醒" in text
assert "初遇反派" in text
assert "展开细纲" in text
def test_with_outline_chapter_missing_beats_does_not_error() -> None:
# 缺章/缺大纲 → 空节拍占位,不报错(预览降级)。
text = build_toolbox_context(
"with_outline_chapter",
brief="",
project_context="标题:测试",
chapter_no=99,
beats=[],
)
assert "第 99 章大纲节拍" in text
assert "暂无大纲节拍" in text
def test_unknown_strategy_raises() -> None:
with pytest.raises(ValueError):
build_toolbox_context("nope", brief="x", project_context="y") # type: ignore[arg-type]

View File

@@ -0,0 +1,377 @@
"""T6.2/T6.3 创作工具箱通用端点测试(内存替身,无 DB/无网络)。
覆盖:
- GET /skills/toolbox列出全部工具legacy + 新)+ ingestable/legacy_route 形。
- POST .../generate按 key 解析 spec → 结构化预览;未知 key 404legacy key 404无凭据 503。
- POST .../ingestworld_entities continuity 409 gate + acknowledge 放行 + partition_writes 丢越权;
不可入库工具 422outline 细纲入库 upsert。
"""
from __future__ import annotations
import os
import uuid
from typing import Any
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo, FakeSession
from test_projects import _empty_memory_repos
from ww_agents import Conflict, ContinuityReview
from ww_agents.schemas import Idea, IdeaListResult
from ww_core.domain.outline_write_repo import OutlineWriteView
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.world_entity_repo import WorldEntityWriteView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
STUB_OWNER = uuid.UUID(int=1)
class _SchemaRoutingGateway:
"""按 `req.output_schema` 返对应 parsedgenerate / 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 _FakeWorldWriteRepo:
def __init__(self) -> None:
self.rows: list[dict[str, Any]] = []
async def create(
self, project_id: uuid.UUID, *, type: str, name: str, rules: list[str]
) -> WorldEntityWriteView:
self.rows.append({"type": type, "name": name, "rules": list(rules)})
return WorldEntityWriteView(id=uuid.uuid4(), type=type, name=name)
class _FakeOutlineWriteRepo:
def __init__(self) -> None:
self.rows: list[dict[str, Any]] = []
async def upsert_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]],
) -> OutlineWriteView:
self.rows.append({"chapter_no": chapter_no, "beats": list(beats)})
return OutlineWriteView(
chapter_no=chapter_no, volume=volume, beats=list(beats), foreshadow_windows=[]
)
def _ideas() -> IdeaListResult:
return IdeaListResult(ideas=[Idea(premise="废柴觉醒系统", hook="开局即巅峰", genre_fit="玄幻")])
def _no_conflicts() -> ContinuityReview:
return ContinuityReview(conflicts=[])
def _with_conflicts() -> ContinuityReview:
return ContinuityReview(
conflicts=[Conflict(type="设定违例", where="吞噬系统", refs=["灵脉"], suggestion="改设定")]
)
def _make_app(
*,
project_repo: FakeProjectRepo,
gateway: Any,
session: FakeSession | None = None,
world_repo: _FakeWorldWriteRepo | None = None,
outline_write_repo: _FakeOutlineWriteRepo | None = None,
memory: Any = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_memory_repos,
get_outline_write_repo,
get_project_repo,
get_tier_gateway_builder,
get_world_entity_write_repo,
)
from ww_db import get_session
session = session or FakeSession()
world_repo = world_repo or _FakeWorldWriteRepo()
outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo()
async def _build_ok(_tier: str) -> Any:
return gateway
async def _build_no_creds(_tier: str) -> Any:
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_world_entity_write_repo] = lambda: world_repo
app.dependency_overrides[get_outline_write_repo] = lambda: outline_write_repo
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_tier_gateway_builder] = lambda: (
_build_no_creds if no_creds else _build_ok
)
return app, session, world_repo, outline_write_repo
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")
# ---- GET /skills/toolbox ----
@pytest.mark.asyncio
async def test_list_toolbox_contains_all_keys() -> None:
repo = FakeProjectRepo()
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
async with _client(app) as client:
resp = await client.get("/skills/toolbox")
assert resp.status_code == 200
tools = resp.json()["tools"]
keys = {t["key"] for t in tools}
assert keys >= {
"worldbuilding",
"character",
"outline",
"brainstorm",
"book-title",
"blurb",
"name",
"golden-finger",
"glossary",
"opening",
"fine-outline",
}
by_key = {t["key"]: t for t in tools}
# legacy 携 legacy_route + is_legacy新工具 ingestable 标记。
assert by_key["worldbuilding"]["is_legacy"] is True
assert by_key["worldbuilding"]["legacy_route"]
assert by_key["brainstorm"]["is_legacy"] is False
assert by_key["golden-finger"]["ingestable"] is True
assert by_key["brainstorm"]["ingestable"] is False
# input_fields 形brief textarea
assert any(f["name"] == "brief" for f in by_key["brainstorm"]["input_fields"])
# ---- POST .../generate ----
@pytest.mark.asyncio
async def test_generate_resolves_spec_and_returns_preview() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({IdeaListResult: _ideas()})
app, session, *_ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/brainstorm/generate", json={"brief": "废柴流"}
)
assert resp.status_code == 200
body = resp.json()
assert body["tool_key"] == "brainstorm"
assert body["output_kind"] == "IdeaListResult"
assert body["preview"]["ideas"][0]["premise"] == "废柴觉醒系统"
assert session.commits == 1 # 预览不写业务表,但落 ledger
@pytest.mark.asyncio
async def test_generate_unknown_tool_404() -> 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.post(f"/projects/{pid}/skills/nope/generate", json={"brief": "x"})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_generate_legacy_tool_404() -> None:
# legacy 工具无通用执行路径(前端走 legacy_route
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/worldbuilding/generate", json={"brief": "x"}
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_generate_unknown_project_404() -> None:
repo = FakeProjectRepo()
app, *_ = _make_app(
project_repo=repo, gateway=_SchemaRoutingGateway({IdeaListResult: _ideas()})
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/skills/brainstorm/generate", json={"brief": "x"}
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_generate_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}/skills/brainstorm/generate", json={"brief": "x"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0
# ---- POST .../ingestworld_entities gate----
def _gf_payload() -> dict[str, Any]:
return {
"world_entities": [
{"type": "力量体系", "name": "吞噬系统", "rules": ["每日上限", "不可越级"]}
]
}
@pytest.mark.asyncio
async def test_ingest_world_entities_no_conflict_writes_201() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=_gf_payload())
assert resp.status_code == 201
body = resp.json()
assert body["table"] == "world_entities"
assert body["created"] == ["吞噬系统"]
assert body["rejected_tables"] == []
assert len(world_repo.rows) == 1
assert world_repo.rows[0]["rules"] == ["每日上限", "不可越级"]
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_world_entities_conflict_blocks_409() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=_gf_payload())
assert resp.status_code == 409
err = resp.json()["error"]
assert err["code"] == ErrorCode.CONFLICT_UNRESOLVED
assert err["details"]["conflict_count"] == 1
assert len(world_repo.rows) == 0
assert session.commits == 1 # 落 precheck ledger不写业务表
@pytest.mark.asyncio
async def test_ingest_world_entities_acknowledged_writes() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
app, _, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
payload = {**_gf_payload(), "acknowledge_conflicts": True}
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=payload)
assert resp.status_code == 201
assert len(world_repo.rows) == 1
@pytest.mark.asyncio
async def test_ingest_non_ingestable_tool_422() -> None:
# brainstorm 纯预览 → 不可入库。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/brainstorm/ingest", json={})
assert resp.status_code == 422
assert resp.json()["error"]["code"] == ErrorCode.VALIDATION
@pytest.mark.asyncio
async def test_ingest_outline_upserts_scenes() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({})
app, session, _, outline_repo = _make_app(project_repo=repo, gateway=gateway)
payload = {
"chapter_no": 3,
"scenes": [
{"idx": 1, "beat": "初遇反派", "purpose": "推进", "conflict": "对峙", "hook": "悬念"},
{"idx": 0, "beat": "主角觉醒", "purpose": "塑造", "conflict": "内心", "hook": "钩子"},
],
}
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/fine-outline/ingest", json=payload)
assert resp.status_code == 201
body = resp.json()
assert body["table"] == "outline"
assert len(outline_repo.rows) == 1
# 场景按 idx 升序拼装成 beats确定性
assert outline_repo.rows[0]["beats"] == ["主角觉醒", "初遇反派"]
assert outline_repo.rows[0]["chapter_no"] == 3
assert session.commits == 1

View File

@@ -26,6 +26,7 @@ from ww_api.routers import (
rules,
settings_providers,
style,
toolbox,
)
from ww_api.security.credentials import _fernet
from ww_api.services.project_deps import seed_stub_user
@@ -115,6 +116,7 @@ def create_app() -> FastAPI:
app.include_router(style.router)
app.include_router(generation.router)
app.include_router(generation.skills_router)
app.include_router(toolbox.router)
app.include_router(settings_providers.router)
app.include_router(kimi_oauth.router)
return app

View File

@@ -0,0 +1,453 @@
"""创作工具箱通用端点C3 扩 T6 / 通用生成器框架;不变量 #1/#2/#3/#9
声明驱动、一条执行路径——「加一个生成器」= 在 `ww_skills.TOOLBOX` 加一份声明,无需新端点:
- `GET /skills/toolbox` 列出全部生成器描述符legacy + 新工具)。
- `POST /projects/:id/skills/:tool_key/generate` 按 context_strategy 组材料 → 网关结构化预览。
- `POST /projects/:id/skills/:tool_key/ingest` 仅可入库工具:过 continuity 预检 + 白名单 → 写库。
legacy 工具spec=None无通用执行路径——generate/ingest 对其返 404前端走 legacy_route 跳现页)。
未知 tool_key→404项目不存在→404无凭据→503 LLM_UNAVAILABLE缺章/大纲→空节拍不报错(预览)。
提交边界:网关 ledger + 写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit
落 precheck usage同 characters 入库纪律,不变量 #3
"""
from __future__ import annotations
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import AgentSpec, Conflict, ContinuityReview, continuity_spec
from ww_core.domain.outline_write_repo import OutlineWriteRepo
from ww_core.domain.project_repo import ProjectRepo
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
from ww_core.domain.world_entity_repo import WorldEntityWriteRepo
from ww_core.orchestrator import run_generator
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, ErrorEnvelope
from ww_skills import GeneratorTool, get_tool, partition_writes
from ww_api.logging_config import get_logger
from ww_api.routers.generation import _project_context, _world_context
from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView
from ww_api.schemas.toolbox import (
OutlineSceneIngestView,
ToolboxListResponse,
ToolDescriptorView,
ToolGeneratePreviewResponse,
ToolGenerateRequest,
ToolIngestRequest,
ToolIngestResponse,
ToolInputFieldView,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import (
TierGatewayBuilder,
get_memory_repos,
get_outline_read_repo,
get_outline_write_repo,
get_project_repo,
get_tier_gateway_builder,
get_world_entity_write_repo,
)
from ww_api.services.toolbox_context import build_toolbox_context
log = get_logger("ww.api.toolbox")
router = APIRouter(tags=["toolbox"])
# OpenAPI 错误响应声明类型化错误形§7.1)。
_TOOL_ERRORS: dict[int | str, dict[str, Any]] = {
404: {"model": ErrorEnvelope, "description": "工具或项目不存在 / legacy 工具无通用执行"},
422: {"model": ErrorEnvelope, "description": "工具不支持该操作(如不可入库)"},
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
}
_INGEST_ERRORS: dict[int | str, dict[str, Any]] = {
**_TOOL_ERRORS,
409: {"model": ErrorEnvelope, "description": "存在未裁决 continuity 冲突"},
}
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)]
WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)]
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def _descriptor_view(tool: GeneratorTool) -> ToolDescriptorView:
return ToolDescriptorView(
key=tool.key,
title=tool.title,
subtitle=tool.subtitle,
genre=tool.genre,
is_legacy=tool.spec is None,
ingestable=tool.ingest is not None,
input_fields=[
ToolInputFieldView(
name=f.name,
label=f.label,
type=f.type,
required=f.required,
default=f.default,
help=f.help,
)
for f in tool.input_fields
],
legacy_route=tool.legacy_route,
)
@router.get("/skills/toolbox")
async def list_toolbox() -> ToolboxListResponse:
"""创作工具箱全量描述符legacy + 新工具;前端据此渲染卡片栅格 + 表单 + 路由)。"""
from ww_skills import TOOLBOX
# 确定性顺序(按 key 升序),便于前端稳定渲染与测试断言。
tools = [_descriptor_view(TOOLBOX[key]) for key in sorted(TOOLBOX)]
return ToolboxListResponse(tools=tools)
def _require_real_tool(tool_key: str) -> GeneratorTool:
"""解析工具:未知 → 404legacy无 spec→ 404前端走 legacy_route"""
tool = get_tool(tool_key)
if tool is None:
raise AppError(ErrorCode.NOT_FOUND, f"未知工具:{tool_key}", {"tool_key": tool_key})
if tool.spec is None:
raise AppError(
ErrorCode.NOT_FOUND,
f"工具 {tool_key} 无通用执行路径请使用其专属页面legacy_route",
{"tool_key": tool_key, "legacy_route": tool.legacy_route},
)
return tool
async def _chapter_beats(
outline_repo: OutlineRepo, project_id: uuid.UUID, chapter_no: int
) -> list[str]:
"""读指定章的大纲粗节拍(缺章/缺大纲 → 空 list不报错DB JSONB dict→list 解包)。"""
view = await outline_repo.get(project_id, chapter_no)
if view is None:
return []
raw = view.beats or {}
return list(raw.get("beats", [])) if isinstance(raw, dict) else []
@router.post("/projects/{project_id}/skills/{tool_key}/generate", responses=_TOOL_ERRORS)
async def generate_with_tool(
project_id: uuid.UUID,
tool_key: str,
body: ToolGenerateRequest,
request: Request,
project_repo: ProjectRepoDep,
memory: MemoryReposDep,
outline_repo: OutlineReadRepoDep,
build_gateway: GatewayBuilderDep,
session: SessionDep,
) -> ToolGeneratePreviewResponse:
"""通用生成(结构化预览,不入库)。未知/ legacy 工具→404项目不存在→404无凭据→503。"""
request_id = getattr(request.state, "request_id", None)
tool = _require_real_tool(tool_key)
spec = tool.spec
assert spec is not None # _require_real_tool 已保证mypy 收窄)
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}")
# brief_only 给精简设定、其余给完整设定(确定性序列化,无时间戳/UUID
if tool.context_strategy == "brief_only":
project_context = _project_context(project.title, project.genre, None, None)
else:
project_context = _project_context(
project.title, project.genre, project.premise, project.theme
)
world_context = ""
beats: list[str] = []
if tool.context_strategy == "with_world":
world_context = await _world_context(memory, project_id)
elif tool.context_strategy == "with_outline_chapter":
chapter_no = body.chapter_no or 1
beats = await _chapter_beats(outline_repo, project_id, chapter_no)
context = build_toolbox_context(
tool.context_strategy,
brief=body.brief,
project_context=project_context,
world_context=world_context,
chapter_no=body.chapter_no,
beats=beats,
)
gateway = await build_gateway(spec.tier)
parsed = await run_generator(
spec,
context=context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
# 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage否则丢失
await session.commit()
log.info(
"toolbox_generate_done",
tool_key=tool_key,
project_id=str(project_id),
request_id=request_id,
output_kind=type(parsed).__name__,
)
return ToolGeneratePreviewResponse(
tool_key=tool_key,
output_kind=type(parsed).__name__,
preview=parsed.model_dump(),
)
async def _precheck_world_entities(
spec: AgentSpec,
*,
items: list[WorldEntityCardView],
world_context: str,
gateway: Gateway,
project_id: uuid.UUID,
) -> list[Conflict]:
"""入库前 continuity 预检world_entities把待入库实体 vs 既有世界观真相源比对。
仿 `precheck_generated_cards` 但服务于世界观实体(金手指/词条)。复用 `continuity_spec`
analyst 档,只读,不变量 #3——直接构 LlmRequest 走网关,节点逻辑无 LLM 非确定性。
"""
lines = [
f"- [{e.type}] {e.name}{(''.join(e.rules) if e.rules else '(无硬规则)')}"
for e in items
]
context = (
"## 待校验:本次生成的世界观实体(尚未入库)\n"
f"{chr(10).join(lines) or '(无)'}\n\n"
"## 真相源:既有世界观硬规则\n"
f"{world_context or '(暂无世界观设定)'}"
)
req = LlmRequest(
tier=spec.tier,
system=[Block(text=spec.system_prompt, cache=True)],
input=context,
output_schema=spec.output_schema,
scope=Scope(user_id=STUB_OWNER_ID, project_id=project_id),
)
resp = await gateway.run(req)
parsed = resp.parsed
if not isinstance(parsed, ContinuityReview):
raise ValueError("gateway returned no parsed ContinuityReview for toolbox precheck")
return list(parsed.conflicts)
def _conflict_409(conflicts: list[Conflict]) -> AppError:
return 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),
},
)
@router.post(
"/projects/{project_id}/skills/{tool_key}/ingest", status_code=201, responses=_INGEST_ERRORS
)
async def ingest_with_tool(
project_id: uuid.UUID,
tool_key: str,
body: ToolIngestRequest,
request: Request,
project_repo: ProjectRepoDep,
memory: MemoryReposDep,
world_write_repo: WorldWriteRepoDep,
outline_write_repo: OutlineWriteRepoDep,
build_gateway: GatewayBuilderDep,
session: SessionDep,
) -> ToolIngestResponse:
"""通用入库(仅可入库工具):过 continuity 预检 + 白名单 → 写 ingest.table。
未知/legacy 工具→404不可入库工具→422有冲突且未确认→409越权写表丢弃+审计。
"""
request_id = getattr(request.state, "request_id", None)
tool = _require_real_tool(tool_key)
spec = tool.spec
assert spec is not None
if tool.ingest is None:
raise AppError(
ErrorCode.VALIDATION,
f"工具 {tool_key} 不支持入库(纯预览)",
{"tool_key": tool_key},
)
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}")
table = tool.ingest.table
if table == "world_entities":
return await _ingest_world_entities(
spec,
body=body,
project_id=project_id,
memory=memory,
world_write_repo=world_write_repo,
session=session,
build_gateway=build_gateway,
request_id=request_id,
tool_key=tool_key,
)
if table == "outline":
return await _ingest_outline(
spec,
body=body,
project_id=project_id,
outline_write_repo=outline_write_repo,
session=session,
request_id=request_id,
tool_key=tool_key,
)
# 描述符的 IngestSpec.table 已限白名单;其余表本期通用入库未实现。
raise AppError(ErrorCode.VALIDATION, f"入库表 {table} 暂未支持通用入库", {"table": table})
async def _ingest_world_entities(
spec: AgentSpec,
*,
body: ToolIngestRequest,
project_id: uuid.UUID,
memory: MemoryRepos,
world_write_repo: WorldEntityWriteRepo,
session: AsyncSession,
build_gateway: TierGatewayBuilder,
request_id: str | None,
tool_key: str,
) -> ToolIngestResponse:
"""world_entities 入库(金手指/词条continuity 预检 + 白名单 → 写 world_entities。"""
items = body.world_entities
# gate 1入库前 continuity 预检analyst 网关;编排器追加的检查,守不变量 #1/#3
world_context = await _world_context(memory, project_id)
precheck_gateway = await build_gateway("analyst")
conflicts = await _precheck_world_entities(
continuity_spec,
items=items,
world_context=world_context,
gateway=precheck_gateway,
project_id=project_id,
)
if conflicts and not body.acknowledge_conflicts:
await session.commit() # 落 precheck 网关 usage不写业务表。
log.info(
"toolbox_ingest_blocked_by_conflicts",
tool_key=tool_key,
project_id=str(project_id),
request_id=request_id,
conflict_count=len(conflicts),
)
raise _conflict_409(conflicts)
# gate 2权限白名单——按 spec 声明的 writes 过滤(越权表丢弃 + 审计)。
allowed, rejected = partition_writes(spec, {"world_entities": items})
if rejected:
log.warning(
"toolbox_ingest_rejected_over_permission",
tool_key=tool_key,
project_id=str(project_id),
request_id=request_id,
rejected_tables=rejected,
)
created: list[str] = []
for entity in allowed.get("world_entities", []):
view = await world_write_repo.create(
project_id, type=entity.type, name=entity.name, rules=list(entity.rules)
)
created.append(view.name)
await session.commit()
log.info(
"toolbox_ingest_done",
tool_key=tool_key,
project_id=str(project_id),
request_id=request_id,
table="world_entities",
created_count=len(created),
)
return ToolIngestResponse(table="world_entities", created=created, rejected_tables=rejected)
async def _ingest_outline(
spec: AgentSpec,
*,
body: ToolIngestRequest,
project_id: uuid.UUID,
outline_write_repo: OutlineWriteRepo,
session: AsyncSession,
request_id: str | None,
tool_key: str,
) -> ToolIngestResponse:
"""outline 入库(细纲):白名单 → 把场景节拍 upsert 到目标章的 outline 行。
细纲不做 continuity 预检(场景是粗节拍的展开,非与世界观真相源比对的设定/角色);
仍过 partition_writes 白名单。无章号 → VALIDATION。
"""
if body.chapter_no is None:
raise AppError(ErrorCode.VALIDATION, "细纲入库需指定 chapter_no", {"tool_key": tool_key})
# gate权限白名单细纲 spec 声明 writes=["outline"])。
scenes: list[OutlineSceneIngestView] = body.scenes
allowed, rejected = partition_writes(spec, {"outline": scenes})
if rejected:
log.warning(
"toolbox_ingest_rejected_over_permission",
tool_key=tool_key,
project_id=str(project_id),
request_id=request_id,
rejected_tables=rejected,
)
allowed_scenes: list[OutlineSceneIngestView] = allowed.get("outline", [])
# 场景节拍序列化为该章 beats按 idx 升序拼装,确定性)。
ordered = sorted(allowed_scenes, key=lambda s: s.idx)
beats = [s.beat for s in ordered]
created: list[str] = []
if "outline" in allowed:
view = await outline_write_repo.upsert_chapter(
project_id,
volume=1,
chapter_no=body.chapter_no,
beats=beats,
foreshadow_windows=[],
)
created = [str(s.idx) for s in ordered]
log.info(
"toolbox_outline_upserted",
tool_key=tool_key,
project_id=str(project_id),
chapter_no=view.chapter_no,
scene_count=len(ordered),
)
await session.commit()
log.info(
"toolbox_ingest_done",
tool_key=tool_key,
project_id=str(project_id),
request_id=request_id,
table="outline",
created_count=len(created),
)
return ToolIngestResponse(table="outline", created=created, rejected_tables=rejected)

View File

@@ -0,0 +1,141 @@
"""创作工具箱通用端点的请求/响应 schemaC3 扩 T6 / 通用生成器框架)。
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
- `GET /skills/toolbox` → `ToolboxListResponse`(声明式描述符视图,含 legacy_route 供前端路由)。
- `POST .../skills/{tool_key}/generate` → `ToolGeneratePreviewResponse`(结构化预览,不入库)。
- `POST .../skills/{tool_key}/ingest` → `ToolIngestResponse`(过 continuity gate + 白名单后入库)。
预览/入库共用既有 schemaworld_entities 入库走 `WorldEntityCardView`(贴 worldbuilder 形),
outline 入库走 `OutlineSceneIngestView`(贴 fine-outline 场景行)。
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView
# 一次性生成数量上限沿用 generation 的保守值(避免巨量调用);章号下限。
MIN_CHAPTER_NO = 1
# ---- GET /skills/toolbox描述符列表----
class ToolInputFieldView(BaseModel):
"""单个声明式表单字段视图(贴 ww_skills.InputField"""
name: str
label: str
type: str = Field(description="控件类型text / textarea / number / select 等")
required: bool = True
default: str | None = None
help: str | None = None
class ToolDescriptorView(BaseModel):
"""单个生成器卡片描述符视图(前端据此渲染卡片栅格 + 输入表单)。"""
key: str = Field(description="路由用稳定标识brainstorm / book-title ...")
title: str
subtitle: str
genre: str | None = None
is_legacy: bool = Field(description="legacy 工具spec=None→ 前端走 legacy_route 跳现有页面")
ingestable: bool = Field(description="是否可入库(声明了 ingest 目标)")
input_fields: list[ToolInputFieldView] = Field(default_factory=list)
legacy_route: str | None = Field(
default=None, description="legacy 工具指向的现有页面路径(含 {id} 占位)"
)
class ToolboxListResponse(BaseModel):
"""GET /skills/toolbox创作工具箱全量描述符legacy + 新工具)。"""
tools: list[ToolDescriptorView] = Field(default_factory=list)
# ---- POST .../skills/{tool_key}/generate结构化预览----
class ToolGenerateRequest(BaseModel):
"""通用生成请求(覆盖全部工具的可选入参;按工具 input_fields 取用snake_case"""
brief: str = Field(default="", description="一句话需求/方向(可空,空则按题材发散)")
chapter_no: int | None = Field(
default=None, ge=MIN_CHAPTER_NO, description="按章展开类工具的章号(开篇/细纲)"
)
count: int | None = Field(default=None, ge=1, le=12, description="生成数量(部分工具可用)")
kind: str | None = Field(default=None, description="命名对象类别等(部分工具可用)")
class ToolGeneratePreviewResponse(BaseModel):
"""通用生成预览:结构化产物(不持久化;仅记 ledger
`output_kind` = 该工具产物的 schema 名(如 IdeaListResult供前端按类型选预览渲染器
`preview` = 解析后的结构化产物(按各工具 output_schema 的形,键名见 ww_agents.schemas
"""
tool_key: str
output_kind: str = Field(description="产物 schema 名(前端按此选预览渲染器)")
preview: dict[str, Any] = Field(
default_factory=dict,
description="结构化产物(各工具 output_schema 的 model_dump不入库",
)
# ---- POST .../skills/{tool_key}/ingest入库过 continuity gate + 白名单)----
class OutlineSceneIngestView(BaseModel):
"""细纲入库的单章场景行(贴 ww_agents.Scene落 outline 表)。"""
idx: int
beat: str
purpose: str = ""
conflict: str = ""
hook: str = ""
class ToolIngestRequest(BaseModel):
"""通用入库请求:待持久化的产物 + 冲突确认。
仅声明了 `ingest` 的工具可用。按入库表取用对应字段:
- world_entities金手指/词条)→ `world_entities`(贴 WorldEntityCardViewtype/name/rules
- outline细纲→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView
`acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate
"""
world_entities: list[WorldEntityCardView] = Field(
default_factory=list, description="world_entities 入库项(金手指/词条)"
)
chapter_no: int | None = Field(
default=None, ge=MIN_CHAPTER_NO, description="outline 入库的目标章号(细纲)"
)
scenes: list[OutlineSceneIngestView] = Field(
default_factory=list, description="outline 入库的场景行(细纲)"
)
acknowledge_conflicts: bool = Field(
default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库"
)
class ToolIngestResponse(BaseModel):
"""通用入库结果201写入项标识 + 被白名单丢弃的越权表(审计)。"""
table: str = Field(description="入库目标表")
created: list[str] = Field(
default_factory=list, description="写入项的标识(实体名 / 场景索引),按入参顺序"
)
rejected_tables: list[str] = Field(
default_factory=list, description="被权限白名单丢弃的越权写表名(审计;正常为空)"
)
class ToolIngestConflictDetails(BaseModel):
"""409 冲突详情(贴 generation 的 IngestConflictView"""
conflicts: list[IngestConflictView] = Field(default_factory=list)
conflict_count: int = 0

View File

@@ -9,6 +9,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Annotated
import httpx
@@ -211,6 +212,27 @@ def get_outline_read_repo(
return SqlOutlineRepo(session)
# 创作工具箱通用端点的网关来源:按 tier 动态建网关(工具的 tier 在运行时才知)。
# 测试经 `app.dependency_overrides[get_tier_gateway_builder]` 注入返回 mock 网关的 builder。
TierGatewayBuilder = Callable[[Tier], Awaitable[Gateway]]
def get_tier_gateway_builder(
session: Annotated[AsyncSession, Depends(get_session)],
) -> TierGatewayBuilder:
"""返回「按 tier 建网关」的可注入缝(创作工具箱按工具 spec.tier 动态路由)。
无凭据时 builder 调用内 `build_gateway_for_tier` 抛 `LLM_UNAVAILABLE`503——
与固定档位网关缝语义一致。测试 override 返回固定 mock 网关的 builder绝不联网
"""
store = SqlCredentialStore(session)
async def _build(tier: Tier) -> Gateway:
return await build_gateway_for_tier(session, store, tier)
return _build
async def get_worldbuilder_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:

View File

@@ -0,0 +1,60 @@
"""创作工具箱 · context 派发PURELLM-free确定性
把 `ContextStrategy` 映射成喂给网关的注入文本。**纯函数**:给定已组装好的材料
(项目设定 / 世界观文本 / 本章节拍),同输入同输出、无 IO、无时间戳——便于单测与缓存。
IO读 projects/world_entities/outline发生在端点本模块只负责「材料 → 注入文本」的
确定性拼装,复用 `ww_core.orchestrator` 的既有 context buildersbrief / outline-chapter
与 `routers/generation` 的 `_project_context` / `_world_context` 序列化口径。
策略 → 材料:
- `brief_only` :作品设定(仅标题/题材级)+ 一句话需求;
- `with_project` :作品设定(含前提/主题)+ 一句话需求;
- `with_world` :作品设定 + world_entities 硬规则卡 + 一句话需求;
- `with_outline_chapter`:作品设定 + 指定章大纲节拍(缺章/缺大纲 → 空节拍,不报错)。
"""
from __future__ import annotations
from collections.abc import Sequence
from ww_core.orchestrator import build_brief_context, build_outline_chapter_context
from ww_skills import ContextStrategy
def build_toolbox_context(
strategy: ContextStrategy,
*,
brief: str,
project_context: str,
world_context: str = "",
chapter_no: int | None = None,
beats: Sequence[str] = (),
) -> str:
"""据注入策略组装喂网关的文本PURE确定性
`project_context` 由调用方按策略序列化brief_only 给精简设定、with_project 给完整设定);
`world_context` 仅 with_world 用(已序列化的硬规则卡);`chapter_no`/`beats` 仅
with_outline_chapter 用(缺章/缺大纲时 beats 为空,降级到空节拍而非报错)。
"""
if strategy in ("brief_only", "with_project"):
return build_brief_context(brief=brief, project_context=project_context)
if strategy == "with_world":
base = build_brief_context(brief=brief, project_context=project_context)
world_block = world_context.strip() or "(暂无世界观设定)"
return f"{base}\n\n## 世界观(硬规则供契合/校验)\n{world_block}"
if strategy == "with_outline_chapter":
# 缺章号时退回首章占位(端点对需要章号的工具会先校验,这里只做确定性兜底)。
no = chapter_no if chapter_no is not None else 1
outline_block = build_outline_chapter_context(
chapter_no=no, beats=beats, project_context=project_context
)
brief_block = brief.strip()
if brief_block:
return f"{outline_block}\n\n## 创作需求\n{brief_block}"
return outline_block
# 受限枚举不应到达此分支;保守抛错而非静默错配策略。
raise ValueError(f"unknown context strategy: {strategy!r}")

View File

@@ -0,0 +1,34 @@
import { notFound } from "next/navigation";
import { ToolboxPage } from "@/components/toolbox/ToolboxPage";
import { fetchProject, fetchToolbox } from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ open?: string }>;
}
// 创作工具箱页。Server Component 取项目 + 工具描述符GET /skills/toolbox错误降级为空
// ?open=<tool_key> 由命令面板 action-gen-<key> 跳转,进页直开对应生成器(仅新工具)。
export default async function ToolboxRoutePage({
params,
searchParams,
}: PageProps) {
const { id } = await params;
const { open } = await searchParams;
try {
const [project, toolbox] = await Promise.all([
fetchProject(id),
fetchToolbox(),
]);
return (
<ToolboxPage
project={project}
tools={toolbox.tools ?? []}
initialOpenKey={open}
/>
);
} catch {
notFound();
}
}

View File

@@ -9,8 +9,10 @@ import {
moveHighlight,
projectCommands,
type Command,
type ToolCommandInfo,
} from "@/lib/command/palette";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { api } from "@/lib/api/client";
// 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null {
@@ -31,14 +33,17 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
const toolsLoadedRef = useRef(false);
const projectId = projectIdFromPath(pathname);
const commands = useMemo<Command[]>(
() =>
projectId
? [...projectCommands(projectId), ...globalCommands()]
? [...projectCommands(projectId, tools), ...globalCommands()]
: globalCommands(),
[projectId],
[projectId, tools],
);
const results = useMemo(
() => filterCommands(commands, query),
@@ -76,6 +81,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
useEffect(() => {
if (!open || !projectId || toolsLoadedRef.current) return;
toolsLoadedRef.current = true;
let cancelled = false;
void (async () => {
try {
const { data } = await api.GET("/skills/toolbox");
if (cancelled || !data?.tools) return;
setTools(
data.tools
.filter((t) => !t.is_legacy)
.map((t) => ({ key: t.key, title: t.title, genre: t.genre })),
);
} catch {
// 静默降级:命令面板照常工作,仅缺生成器动作。
}
})();
return () => {
cancelled = true;
};
}, [open, projectId]);
useEffect(() => {
setHighlight(0);
}, [query]);

View File

@@ -0,0 +1,271 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useToast } from "@/components/Toast";
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
import {
buildGenerateRequest,
initialFieldValues,
missingRequiredFields,
previewRows,
type FieldValues,
type PreviewItem,
} from "@/lib/toolbox/toolbox";
import { ingestTable } from "@/lib/toolbox/ingest";
import { useGenerator } from "@/lib/toolbox/useGenerator";
interface GeneratorRunnerProps {
projectId: string;
tool: ToolDescriptorView;
onClose: () => void;
}
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
// 按 output_kind 渲染结构化预览 → 可入库者勾选 + 入库(复用 ConflictAdjudication 过 409
// 不硬编码任何工具的表单/预览/入库形状——全部由声明 + lib/toolbox 纯逻辑驱动。
export function GeneratorRunner({
projectId,
tool,
onClose,
}: GeneratorRunnerProps) {
const gen = useGenerator();
const toast = useToast();
const [values, setValues] = useState<FieldValues>(() =>
initialFieldValues(tool.input_fields),
);
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
const [selected, setSelected] = useState<Set<number>>(new Set());
const setField = useCallback((name: string, value: string): void => {
setValues((prev) => ({ ...prev, [name]: value }));
}, []);
const onGenerate = useCallback(async (): Promise<void> => {
const missing = missingRequiredFields(tool.input_fields, values);
if (missing.length > 0) {
toast(`请填写:${missing.join("、")}`, "error");
return;
}
setSelected(new Set());
await gen.generate(projectId, tool.key, buildGenerateRequest(values));
}, [gen, projectId, tool, values, toast]);
const rows = useMemo(
() => previewRows(gen.rawPreview),
[gen.rawPreview],
);
const table = gen.outputKind ? ingestTable(gen.outputKind) : null;
const canIngest = tool.ingestable && table !== null && rows.length > 0;
const toggle = useCallback((idx: number): void => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) {
next.delete(idx);
} else {
next.add(idx);
}
return next;
});
}, []);
const selectedRows = useMemo(
() => rows.filter((_, i) => selected.has(i)),
[rows, selected],
);
const runIngest = useCallback(
async (acknowledge: boolean): Promise<void> => {
if (!gen.outputKind) return;
const chapterNo = Number(values["chapter_no"]?.trim() || "") || null;
await gen.ingest(projectId, tool.key, {
outputKind: gen.outputKind,
rows: selectedRows,
chapterNo,
acknowledgeConflicts: acknowledge,
});
},
[gen, projectId, tool.key, selectedRows, values],
);
const generating = gen.genStatus === "generating";
const ingesting = gen.ingestStatus === "ingesting";
return (
<section
className="flex flex-col gap-4 rounded border border-line bg-panel p-5"
aria-label={tool.title}
>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-serif text-lg text-ink">{tool.title}</h2>
<p className="mt-1 text-sm text-ink-soft">{tool.subtitle}</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
>
</button>
</div>
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
void onGenerate();
}}
>
{(tool.input_fields ?? []).map((field) => (
<FormField
key={field.name}
field={field}
value={values[field.name] ?? ""}
onChange={(v) => setField(field.name, v)}
/>
))}
<button
type="submit"
disabled={generating}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{generating ? "生成中…" : "生成"}
</button>
</form>
{gen.genStatus === "preview" && gen.preview ? (
<div className="flex flex-col gap-3">
<h3 className="font-serif text-sm text-ink">
{gen.preview.items.length}
{canIngest ? "(勾选后可入库)" : null}
</h3>
<ul className="flex flex-col gap-2">
{gen.preview.items.map((item, i) => (
<PreviewRow
key={i}
item={item}
index={i}
selectable={canIngest}
checked={selected.has(i)}
onToggle={toggle}
/>
))}
</ul>
{canIngest && gen.ingestStatus !== "conflict" ? (
<button
type="button"
onClick={() => void runIngest(false)}
disabled={ingesting || selected.size === 0}
className="self-start rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar disabled:opacity-50"
>
{ingesting ? "入库中…" : `入库选中(${selected.size})至 ${table}`}
</button>
) : null}
{gen.ingestStatus === "conflict" && gen.conflicts ? (
<ConflictAdjudication
conflicts={gen.conflicts}
busy={ingesting}
onAcknowledge={() => void runIngest(true)}
onCancel={() => gen.reset()}
/>
) : null}
{gen.ingestStatus === "done" ? (
<p className="text-sm text-ink-soft">
{gen.created.length}
</p>
) : null}
</div>
) : null}
</section>
);
}
interface FormFieldProps {
field: ToolInputFieldView;
value: string;
onChange: (value: string) => void;
}
// 声明式控件映射type → text / textarea / numberselect 暂同 text后端未给 options
function FormField({ field, value, onChange }: FormFieldProps) {
const labelText = field.required ? `${field.label} *` : field.label;
const common =
"mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink";
return (
<label className="block text-sm text-ink-soft">
{labelText}
{field.type === "textarea" ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={3}
className={`${common} resize-y`}
aria-label={field.label}
/>
) : (
<input
type={field.type === "number" ? "number" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
className={common}
aria-label={field.label}
/>
)}
{field.help ? (
<span className="mt-1 block text-xs text-ink-soft/80">{field.help}</span>
) : null}
</label>
);
}
interface PreviewRowProps {
item: PreviewItem;
index: number;
selectable: boolean;
checked: boolean;
onToggle: (index: number) => void;
}
// 统一预览行渲染heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
function PreviewRow({
item,
index,
selectable,
checked,
onToggle,
}: PreviewRowProps) {
return (
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
{selectable ? (
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(index)}
className="mt-1"
aria-label={`选择第 ${index + 1} 项入库`}
/>
) : null}
<div className="min-w-0 flex-1">
{item.heading ? (
<p className="mb-1 font-serif text-base text-ink">{item.heading}</p>
) : null}
{item.fields.map((f, j) => (
<p key={j} className="text-ink-soft">
<span className="text-ink-soft/80">{f.label}</span>
{f.value}
</p>
))}
{item.body ? (
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
) : null}
</div>
</li>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import type { ToolDescriptorView } from "@/lib/api/types";
interface ToolCardProps {
tool: ToolDescriptorView;
// 点击legacy 工具导航到现有页面;新工具开 GeneratorRunner。
onOpen: (tool: ToolDescriptorView) => void;
}
// 工具箱卡片(对齐 ProjectCard 纸感视觉):标题 + 副标题 + 题材徽标 + NEW/可入库标。
// 纯展示;点击交由 ToolboxPage 分派legacy 跳转 / 新工具开 runner
export function ToolCard({ tool, onOpen }: ToolCardProps) {
return (
<button
type="button"
onClick={() => onOpen(tool)}
className="flex h-full min-h-[140px] flex-col rounded border border-line bg-panel p-5 text-left shadow-paper transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none"
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<h2 className="font-serif text-xl text-ink">{tool.title}</h2>
{tool.genre ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{tool.genre}
</span>
) : null}
{tool.is_legacy ? null : (
<span className="rounded bg-[var(--color-cinnabar-wash)] px-2 py-0.5 text-xs text-cinnabar">
NEW
</span>
)}
{tool.ingestable ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
</span>
) : null}
</div>
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p>
</button>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
import { ToolCard } from "./ToolCard";
import { GeneratorRunner } from "./GeneratorRunner";
interface ToolboxPageProps {
project: ProjectResponse;
tools: ToolDescriptorView[];
// 命令面板 action-gen-<key> 深链:进页直接打开该工具的 runner仅新工具
initialOpenKey?: string;
}
// 创作工具箱(通用生成器框架):声明驱动的卡片栅格。
// legacy 工具(世界观/人设/大纲)跳现有页面保其更丰富的入库流;新工具开 GeneratorRunner。
export function ToolboxPage({
project,
tools,
initialOpenKey,
}: ToolboxPageProps) {
const router = useRouter();
const findOpenable = useCallback(
(key: string | undefined): ToolDescriptorView | null => {
if (!key) return null;
const tool = tools.find((t) => t.key === key);
return tool && !isLegacyTool(tool) ? tool : null;
},
[tools],
);
const [active, setActive] = useState<ToolDescriptorView | null>(() =>
findOpenable(initialOpenKey),
);
const onOpen = useCallback(
(tool: ToolDescriptorView): void => {
if (isLegacyTool(tool) && tool.legacy_route) {
router.push(resolveLegacyRoute(tool.legacy_route, project.id));
return;
}
setActive(tool);
},
[router, project.id],
);
const sorted = useMemo(
// 新工具靠前更想被发现legacy 收后;同组保持后端顺序。
() => [...tools].sort((a, b) => Number(a.is_legacy) - Number(b.is_legacy)),
[tools],
);
return (
<AppShell
title={`${project.title}`}
subtitle="工具箱"
projectId={project.id}
activeNav="toolbox"
>
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<header>
<h1 className="font-serif text-lg text-ink"></h1>
<p className="mt-1 text-xs text-ink-soft">
/ / / / / / /
</p>
</header>
{active ? (
<GeneratorRunner
projectId={project.id}
tool={active}
onClose={() => setActive(null)}
/>
) : sorted.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map((tool) => (
<ToolCard key={tool.key} tool={tool} onOpen={onOpen} />
))}
</div>
)}
</div>
</AppShell>
);
}

View File

@@ -487,6 +487,68 @@ export interface paths {
patch?: never;
trace?: never;
};
"/skills/toolbox": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Toolbox
* @description 创作工具箱全量描述符legacy + 新工具;前端据此渲染卡片栅格 + 表单 + 路由)。
*/
get: operations["list_toolbox_skills_toolbox_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/skills/{tool_key}/generate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Generate With Tool
* @description 通用生成(结构化预览,不入库)。未知/ legacy 工具→404项目不存在→404无凭据→503。
*/
post: operations["generate_with_tool_projects__project_id__skills__tool_key__generate_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/skills/{tool_key}/ingest": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Ingest With Tool
* @description 通用入库(仅可入库工具):过 continuity 预检 + 白名单 → 写 ingest.table。
*
* 未知/legacy 工具→404不可入库工具→422有冲突且未确认→409越权写表丢弃+审计。
*/
post: operations["ingest_with_tool_projects__project_id__skills__tool_key__ingest_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings/providers": {
parameters: {
query?: never;
@@ -1216,6 +1278,31 @@ export interface components {
/** Chapters */
chapters?: components["schemas"]["OutlineChapterView"][];
};
/**
* OutlineSceneIngestView
* @description 细纲入库的单章场景行(贴 ww_agents.Scene落 outline 表)。
*/
OutlineSceneIngestView: {
/** Idx */
idx: number;
/** Beat */
beat: string;
/**
* Purpose
* @default
*/
purpose: string;
/**
* Conflict
* @default
*/
conflict: string;
/**
* Hook
* @default
*/
hook: string;
};
/**
* ProjectCreateRequest
* @description POST /projects立项向导字段owner_id 由后端补 stub不入参
@@ -1568,6 +1655,175 @@ export interface components {
/** Fallback */
fallback?: string[];
};
/**
* ToolDescriptorView
* @description 单个生成器卡片描述符视图(前端据此渲染卡片栅格 + 输入表单)。
*/
ToolDescriptorView: {
/**
* Key
* @description 路由用稳定标识brainstorm / book-title ...
*/
key: string;
/** Title */
title: string;
/** Subtitle */
subtitle: string;
/** Genre */
genre?: string | null;
/**
* Is Legacy
* @description legacy 工具spec=None→ 前端走 legacy_route 跳现有页面
*/
is_legacy: boolean;
/**
* Ingestable
* @description 是否可入库(声明了 ingest 目标)
*/
ingestable: boolean;
/** Input Fields */
input_fields?: components["schemas"]["ToolInputFieldView"][];
/**
* Legacy Route
* @description legacy 工具指向的现有页面路径(含 {id} 占位)
*/
legacy_route?: string | null;
};
/**
* ToolGeneratePreviewResponse
* @description 通用生成预览:结构化产物(不持久化;仅记 ledger
*
* `output_kind` = 该工具产物的 schema 名(如 IdeaListResult供前端按类型选预览渲染器
* `preview` = 解析后的结构化产物(按各工具 output_schema 的形,键名见 ww_agents.schemas
*/
ToolGeneratePreviewResponse: {
/** Tool Key */
tool_key: string;
/**
* Output Kind
* @description 产物 schema 名(前端按此选预览渲染器)
*/
output_kind: string;
/**
* Preview
* @description 结构化产物(各工具 output_schema 的 model_dump不入库
*/
preview?: {
[key: string]: unknown;
};
};
/**
* ToolGenerateRequest
* @description 通用生成请求(覆盖全部工具的可选入参;按工具 input_fields 取用snake_case
*/
ToolGenerateRequest: {
/**
* Brief
* @description 一句话需求/方向(可空,空则按题材发散)
* @default
*/
brief: string;
/**
* Chapter No
* @description 按章展开类工具的章号(开篇/细纲)
*/
chapter_no?: number | null;
/**
* Count
* @description 生成数量(部分工具可用)
*/
count?: number | null;
/**
* Kind
* @description 命名对象类别等(部分工具可用)
*/
kind?: string | null;
};
/**
* ToolIngestRequest
* @description 通用入库请求:待持久化的产物 + 冲突确认。
*
* 仅声明了 `ingest` 的工具可用。按入库表取用对应字段:
* - world_entities金手指/词条)→ `world_entities`(贴 WorldEntityCardViewtype/name/rules
* - outline细纲→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView
* `acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate
*/
ToolIngestRequest: {
/**
* World Entities
* @description world_entities 入库项(金手指/词条)
*/
world_entities?: components["schemas"]["WorldEntityCardView"][];
/**
* Chapter No
* @description outline 入库的目标章号(细纲)
*/
chapter_no?: number | null;
/**
* Scenes
* @description outline 入库的场景行(细纲)
*/
scenes?: components["schemas"]["OutlineSceneIngestView"][];
/**
* Acknowledge Conflicts
* @description 作者已知悉并接受 continuity 冲突 → 放行入库
* @default false
*/
acknowledge_conflicts: boolean;
};
/**
* ToolIngestResponse
* @description 通用入库结果201写入项标识 + 被白名单丢弃的越权表(审计)。
*/
ToolIngestResponse: {
/**
* Table
* @description 入库目标表
*/
table: string;
/**
* Created
* @description 写入项的标识(实体名 / 场景索引),按入参顺序
*/
created?: string[];
/**
* Rejected Tables
* @description 被权限白名单丢弃的越权写表名(审计;正常为空)
*/
rejected_tables?: string[];
};
/**
* ToolInputFieldView
* @description 单个声明式表单字段视图(贴 ww_skills.InputField
*/
ToolInputFieldView: {
/** Name */
name: string;
/** Label */
label: string;
/**
* Type
* @description 控件类型text / textarea / number / select 等
*/
type: string;
/**
* Required
* @default true
*/
required: boolean;
/** Default */
default?: string | null;
/** Help */
help?: string | null;
};
/**
* ToolboxListResponse
* @description GET /skills/toolbox创作工具箱全量描述符legacy + 新工具)。
*/
ToolboxListResponse: {
/** Tools */
tools?: components["schemas"]["ToolDescriptorView"][];
};
/** ValidationError */
ValidationError: {
/** Location */
@@ -2681,6 +2937,143 @@ export interface operations {
};
};
};
list_toolbox_skills_toolbox_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ToolboxListResponse"];
};
};
};
};
generate_with_tool_projects__project_id__skills__tool_key__generate_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
tool_key: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ToolGenerateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ToolGeneratePreviewResponse"];
};
};
/** @description 工具或项目不存在 / legacy 工具无通用执行 */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 工具不支持该操作(如不可入库) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description LLM 不可用 */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
ingest_with_tool_projects__project_id__skills__tool_key__ingest_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
tool_key: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ToolIngestRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ToolIngestResponse"];
};
};
/** @description 工具或项目不存在 / legacy 工具无通用执行 */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 存在未裁决 continuity 冲突 */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 工具不支持该操作(如不可入库) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description LLM 不可用 */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
list_providers_settings_providers_get: {
parameters: {
query?: never;

View File

@@ -13,6 +13,7 @@ import type {
RuleListResponse,
SkillListResponse,
StyleFingerprintResponse,
ToolboxListResponse,
WorldEntityListResponse,
} from "./types";
@@ -107,6 +108,16 @@ export async function fetchSkills(): Promise<SkillListResponse> {
return getJson<SkillListResponse>("/skills");
}
// 创作工具箱描述符GET /skills/toolbox全局只读
// 任何错误(含后端未上线)降级为空列表,工具箱页照常渲染(不阻塞进页)。
export async function fetchToolbox(): Promise<ToolboxListResponse> {
try {
return await getJson<ToolboxListResponse>("/skills/toolbox");
} catch {
return { tools: [] };
}
}
// 设定库 Codex跨会话全量已入库角色GET .../characters无行→空列表非 404
export async function fetchCharacters(
projectId: string,

View File

@@ -91,3 +91,19 @@ export type RuleView = components["schemas"]["RuleView"];
export type RuleListResponse = components["schemas"]["RuleListResponse"];
export type SkillView = components["schemas"]["SkillView"];
export type SkillListResponse = components["schemas"]["SkillListResponse"];
// 创作工具箱声明驱动的通用生成器GET /skills/toolbox + 通用 generate/ingest
export type ToolboxListResponse =
components["schemas"]["ToolboxListResponse"];
export type ToolDescriptorView =
components["schemas"]["ToolDescriptorView"];
export type ToolInputFieldView =
components["schemas"]["ToolInputFieldView"];
export type ToolGenerateRequest =
components["schemas"]["ToolGenerateRequest"];
export type ToolGeneratePreviewResponse =
components["schemas"]["ToolGeneratePreviewResponse"];
export type ToolIngestRequest = components["schemas"]["ToolIngestRequest"];
export type ToolIngestResponse = components["schemas"]["ToolIngestResponse"];
export type OutlineSceneIngestView =
components["schemas"]["OutlineSceneIngestView"];

View File

@@ -17,6 +17,32 @@ describe("projectCommands", () => {
expect(genChar?.kind).toBe("action");
expect(genChar?.href).toBe("/projects/p1/codex?gen=character");
});
it("always offers a 工具箱 nav command", () => {
const toolbox = projectCommands("p1").find((c) => c.id === "nav-toolbox");
expect(toolbox?.kind).toBe("navigate");
expect(toolbox?.href).toBe("/projects/p1/toolbox");
});
it("generates one action-gen-<key> per toolbox tool deep-linking with ?open", () => {
const cmds = projectCommands("p1", [
{ key: "brainstorm", title: "脑洞生成器", genre: "玄幻" },
{ key: "book-title", title: "书名生成器" },
]);
const brainstorm = cmds.find((c) => c.id === "action-gen-brainstorm");
expect(brainstorm?.kind).toBe("action");
expect(brainstorm?.href).toBe("/projects/p1/toolbox?open=brainstorm");
expect(brainstorm?.title).toBe("脑洞生成器");
expect(brainstorm?.keywords).toContain("玄幻");
expect(cmds.find((c) => c.id === "action-gen-book-title")?.href).toBe(
"/projects/p1/toolbox?open=book-title",
);
});
it("emits no tool commands when none provided", () => {
const ids = projectCommands("p1").map((c) => c.id);
expect(ids.some((id) => id === "action-gen-brainstorm")).toBe(false);
});
});
describe("globalCommands", () => {

View File

@@ -15,9 +15,30 @@ export interface Command {
group: string;
}
// 工具箱生成器的最小描述(命令面板按此生成 action-gen-<key>,无需整份 descriptor
export interface ToolCommandInfo {
key: string;
title: string;
genre?: string | null;
}
// 项目内可用命令projectId 已知)。导航项给 href动作项生成角色等给 id。
export function projectCommands(projectId: string): Command[] {
// tools工具箱生成器列表可空→ 每个生成一条 action-gen-<key> 深链命令(命令面板发现性)。
export function projectCommands(
projectId: string,
tools: readonly ToolCommandInfo[] = [],
): Command[] {
const p = `/projects/${projectId}`;
const toolCommands: Command[] = tools.map((tool) => ({
id: `action-gen-${tool.key}`,
title: tool.title,
keywords: ["生成器", "工具箱", "generator", tool.key, tool.genre ?? ""].filter(
(k) => k.length > 0,
),
kind: "action",
href: `${p}/toolbox?open=${tool.key}`,
group: "动作",
}));
return [
{
id: "nav-write",
@@ -67,6 +88,14 @@ export function projectCommands(projectId: string): Command[] {
href: `${p}/codex`,
group: "导航",
},
{
id: "nav-toolbox",
title: "工具箱",
keywords: ["toolbox", "工具箱", "生成器", "脑洞", "书名", "金手指"],
kind: "navigate",
href: `${p}/toolbox`,
group: "导航",
},
{
id: "nav-rules",
title: "规则",
@@ -99,6 +128,7 @@ export function projectCommands(projectId: string): Command[] {
href: `${p}/codex?gen=world`,
group: "动作",
},
...toolCommands,
];
}

View File

@@ -10,10 +10,10 @@ import {
const PID = "p1";
describe("projectNavItems", () => {
it("builds the eight project entries scoped to the project id", () => {
it("builds the nine project entries scoped to the project id", () => {
const items = projectNavItems(PID);
expect(items).toHaveLength(8);
expect(items).toHaveLength(9);
expect(items.map((i) => i.key)).toEqual([
"write",
"outline",
@@ -21,6 +21,7 @@ describe("projectNavItems", () => {
"review",
"style",
"codex",
"toolbox",
"rules",
"skills",
]);

View File

@@ -9,7 +9,8 @@ export type ActiveNav =
| "style"
| "codex"
| "rules"
| "skills";
| "skills"
| "toolbox";
export interface NavItem {
href: string;
@@ -36,6 +37,7 @@ export function projectNavItems(projectId: string): NavItem[] {
{ href: `/projects/${projectId}/review`, label: "审稿", key: "review" },
{ href: `/projects/${projectId}/style`, label: "文风", key: "style" },
{ href: `/projects/${projectId}/codex`, label: "设定库", key: "codex" },
{ href: `/projects/${projectId}/toolbox`, label: "工具箱", key: "toolbox" },
{ href: `/projects/${projectId}/rules`, label: "规则", key: "rules" },
{ href: `/projects/${projectId}/skills`, label: "技能库", key: "skills" },
];

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { buildIngestRequest, ingestTable } from "./ingest";
describe("ingestTable", () => {
it("maps ingestable kinds to their target table", () => {
expect(ingestTable("GoldenFingerResult")).toBe("world_entities");
expect(ingestTable("GlossaryResult")).toBe("world_entities");
expect(ingestTable("DetailedOutlineResult")).toBe("outline");
});
it("returns null for non-ingestable kinds", () => {
expect(ingestTable("IdeaListResult")).toBeNull();
expect(ingestTable("unknown")).toBeNull();
});
});
describe("buildIngestRequest", () => {
it("maps golden finger rows into world_entities with merged rules", () => {
const body = buildIngestRequest({
outputKind: "GoldenFingerResult",
rows: [
{ name: "吞噬系统", mechanism: "吞噬获取", growth: "等级提升", limits: "需进食" },
],
acknowledgeConflicts: true,
});
expect(body).toEqual({
world_entities: [
{
type: "力量体系",
name: "吞噬系统",
rules: ["吞噬获取", "等级提升", "需进食"],
},
],
acknowledge_conflicts: true,
});
});
it("drops blank rule parts for golden finger", () => {
const body = buildIngestRequest({
outputKind: "GoldenFingerResult",
rows: [{ name: "x", mechanism: "m", growth: "", limits: "" }],
});
expect(body?.world_entities?.[0]?.rules).toEqual(["m"]);
});
it("maps glossary rows preserving type and rules array", () => {
const body = buildIngestRequest({
outputKind: "GlossaryResult",
rows: [{ name: "灵气", type: "概念", rules: ["不可逆"] }],
});
expect(body?.world_entities?.[0]).toEqual({
type: "概念",
name: "灵气",
rules: ["不可逆"],
});
});
it("defaults glossary type to 概念 when missing", () => {
const body = buildIngestRequest({
outputKind: "GlossaryResult",
rows: [{ name: "灵气" }],
});
expect(body?.world_entities?.[0]?.type).toBe("概念");
});
it("maps detailed outline rows to scenes with chapter_no", () => {
const body = buildIngestRequest({
outputKind: "DetailedOutlineResult",
rows: [
{ idx: 2, beat: "对峙", purpose: "立威", conflict: "正反", hook: "悬念" },
{ beat: "收束" },
],
chapterNo: 7,
});
expect(body?.chapter_no).toBe(7);
expect(body?.scenes).toEqual([
{ idx: 2, beat: "对峙", purpose: "立威", conflict: "正反", hook: "悬念" },
{ idx: 1, beat: "收束", purpose: "", conflict: "", hook: "" },
]);
expect(body?.acknowledge_conflicts).toBe(false);
});
it("returns null for non-ingestable output kind", () => {
expect(
buildIngestRequest({ outputKind: "IdeaListResult", rows: [] }),
).toBeNull();
});
});

View File

@@ -0,0 +1,113 @@
// 工具箱入库纯逻辑:把结构化预览产物按 output_kind 形变为 ToolIngestRequest 的入库项。
// 复用既有入库双 gatecontinuity 预检 409 + partition_writes 白名单),故仅负责 body 组装。
// 对齐契约world_entities金手指/词条)贴 WorldEntityCardViewoutline细纲贴 OutlineSceneIngestView。
import type {
OutlineSceneIngestView,
ToolIngestRequest,
WorldEntityCardView,
} from "@/lib/api/types";
function asString(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asInt(v: unknown, fallback: number): number {
if (typeof v === "number" && Number.isFinite(v)) return Math.round(v);
if (typeof v === "string") {
const n = Number(v.trim());
if (Number.isFinite(n)) return Math.round(n);
}
return fallback;
}
// 金手指 → world_entitiestype 固定「力量体系」mechanism/growth/limits 合并入 rules硬规则供续审比对
function goldenFingerToEntity(
row: Record<string, unknown>,
): WorldEntityCardView {
const rules = [
asString(row["mechanism"]),
asString(row["growth"]),
asString(row["limits"]),
].filter((r) => r.length > 0);
return { type: "力量体系", name: asString(row["name"]), rules };
}
// 词条 → world_entities保留 type/namerules 取 rules已是硬规则清单
function glossaryToEntity(row: Record<string, unknown>): WorldEntityCardView {
return {
type: asString(row["type"]) || "概念",
name: asString(row["name"]),
rules: asStringArray(row["rules"]),
};
}
// 细纲场景 → outline scenes贴 OutlineSceneIngestViewidx/beat/purpose/conflict/hook
function rowToScene(
row: Record<string, unknown>,
fallbackIdx: number,
): OutlineSceneIngestView {
return {
idx: asInt(row["idx"], fallbackIdx),
beat: asString(row["beat"]),
purpose: asString(row["purpose"]),
conflict: asString(row["conflict"]),
hook: asString(row["hook"]),
};
}
export interface IngestBuildInput {
outputKind: string;
// 作者勾选要入库的预览行(结构化产物原始记录)。
rows: readonly Record<string, unknown>[];
// 细纲入库的目标章号outputKind=DetailedOutlineResult 时必填)。
chapterNo?: number | null;
acknowledgeConflicts?: boolean;
}
// 入库目标表(供 UI 文案 / 判定)。未知 → null不可入库
export function ingestTable(outputKind: string): string | null {
switch (outputKind) {
case "GoldenFingerResult":
case "GlossaryResult":
return "world_entities";
case "DetailedOutlineResult":
return "outline";
default:
return null;
}
}
// 组装通用入库请求;不可入库的 output_kind → null调用方据此隐藏入库 UI
export function buildIngestRequest(
input: IngestBuildInput,
): ToolIngestRequest | null {
const acknowledge_conflicts = input.acknowledgeConflicts ?? false;
switch (input.outputKind) {
case "GoldenFingerResult":
return {
world_entities: input.rows.map(goldenFingerToEntity),
acknowledge_conflicts,
};
case "GlossaryResult":
return {
world_entities: input.rows.map(glossaryToEntity),
acknowledge_conflicts,
};
case "DetailedOutlineResult":
return {
chapter_no: input.chapterNo ?? null,
scenes: input.rows.map((row, i) => rowToScene(row, i)),
acknowledge_conflicts,
};
default:
return null;
}
}

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
import {
buildGenerateRequest,
initialFieldValues,
isLegacyTool,
mapPreview,
missingRequiredFields,
previewRows,
resolveLegacyRoute,
} from "./toolbox";
const field = (over: Partial<ToolInputFieldView> = {}): ToolInputFieldView => ({
name: "brief",
label: "需求",
type: "textarea",
required: true,
...over,
});
describe("initialFieldValues", () => {
it("seeds defaults and empty strings", () => {
const values = initialFieldValues([
field({ name: "brief", default: undefined }),
field({ name: "count", type: "number", default: "3", required: false }),
]);
expect(values).toEqual({ brief: "", count: "3" });
});
it("handles undefined fields", () => {
expect(initialFieldValues(undefined)).toEqual({});
});
});
describe("missingRequiredFields", () => {
it("reports labels of blank required fields only", () => {
const fields = [
field({ name: "brief", label: "需求", required: true }),
field({ name: "count", label: "数量", required: false }),
];
expect(missingRequiredFields(fields, { brief: " ", count: "" })).toEqual([
"需求",
]);
});
it("passes when all required present", () => {
expect(
missingRequiredFields([field()], { brief: "一个脑洞" }),
).toEqual([]);
});
});
describe("buildGenerateRequest", () => {
it("trims brief and maps known number fields", () => {
expect(
buildGenerateRequest({ brief: " 修真 ", count: "5", chapter_no: "3" }),
).toEqual({ brief: "修真", count: 5, chapter_no: 3 });
});
it("omits blank/invalid numbers and empty kind", () => {
expect(
buildGenerateRequest({ brief: "x", count: "", chapter_no: "abc", kind: " " }),
).toEqual({ brief: "x" });
});
it("includes kind when present", () => {
expect(buildGenerateRequest({ brief: "x", kind: "门派" })).toEqual({
brief: "x",
kind: "门派",
});
});
it("defaults brief to empty string", () => {
expect(buildGenerateRequest({})).toEqual({ brief: "" });
});
});
describe("previewRows", () => {
it("picks the first array field as records", () => {
const rows = previewRows({ ideas: [{ premise: "a" }, { premise: "b" }] });
expect(rows).toHaveLength(2);
expect(rows[0]).toEqual({ premise: "a" });
});
it("returns empty for missing/non-array", () => {
expect(previewRows(undefined)).toEqual([]);
expect(previewRows({ note: "x" })).toEqual([]);
});
});
describe("mapPreview", () => {
it("maps IdeaListResult to heading + fields", () => {
const model = mapPreview("IdeaListResult", {
ideas: [{ premise: "废柴逆袭", hook: "系统觉醒", genre_fit: "玄幻" }],
});
expect(model.kind).toBe("IdeaListResult");
expect(model.items[0]?.heading).toBe("废柴逆袭");
expect(model.items[0]?.fields).toEqual([
{ label: "爽点", value: "系统觉醒" },
{ label: "题材契合", value: "玄幻" },
]);
});
it("maps BlurbResult text into body and angle into heading", () => {
const model = mapPreview("BlurbResult", {
variants: [{ angle: "悬念向", text: "一段简介" }],
});
expect(model.items[0]?.heading).toBe("悬念向");
expect(model.items[0]?.body).toBe("一段简介");
});
it("maps GlossaryResult rules into a joined field", () => {
const model = mapPreview("GlossaryResult", {
terms: [{ name: "灵气", type: "概念", rules: ["不可逆", "有上限"] }],
});
const ruleField = model.items[0]?.fields.find((f) => f.label === "硬规则");
expect(ruleField?.value).toBe("不可逆;有上限");
});
it("maps OpeningResult as pure body", () => {
const model = mapPreview("OpeningResult", {
variants: [{ text: "开篇第一段" }],
});
expect(model.items[0]?.heading).toBeNull();
expect(model.items[0]?.body).toBe("开篇第一段");
});
it("falls back gracefully for unknown output_kind", () => {
const model = mapPreview("MysteryResult", {
things: [{ a: "first", b: "second" }],
});
expect(model.items[0]?.heading).toBe("first");
expect(model.items[0]?.fields).toEqual([{ label: "b", value: "second" }]);
});
it("yields no items for empty preview", () => {
expect(mapPreview("IdeaListResult", undefined).items).toEqual([]);
});
});
describe("resolveLegacyRoute", () => {
it("substitutes the {id} placeholder", () => {
expect(resolveLegacyRoute("/projects/{id}/codex?gen=world", "p9")).toBe(
"/projects/p9/codex?gen=world",
);
});
});
describe("isLegacyTool", () => {
const tool = (over: Partial<ToolDescriptorView>): ToolDescriptorView => ({
key: "k",
title: "t",
subtitle: "s",
is_legacy: false,
ingestable: false,
...over,
});
it("is true only with legacy flag and a route", () => {
expect(
isLegacyTool(tool({ is_legacy: true, legacy_route: "/x/{id}" })),
).toBe(true);
expect(isLegacyTool(tool({ is_legacy: true }))).toBe(false);
expect(
isLegacyTool(tool({ is_legacy: false, legacy_route: "/x/{id}" })),
).toBe(false);
});
});

View File

@@ -0,0 +1,259 @@
// 创作工具箱纯逻辑:声明式表单值 → 请求体组装、字段校验、output_kind → 预览渲染模型、legacy 路由解析。
// 对齐通用 toolbox 契约GET /skills/toolbox + generate/ingest。纯逻辑便于 node 环境单测。
// 关键边界UI 不硬编码每个工具的表单/预览,全部由 descriptor + 此处映射驱动("加生成器=加声明")。
import type {
ToolDescriptorView,
ToolGenerateRequest,
ToolInputFieldView,
} from "@/lib/api/types";
// 声明式表单字段值(控件原始值;提交前经此处归一为 ToolGenerateRequest
export type FieldValues = Record<string, string>;
// —— 表单初值 ——
// 从 descriptor 的 input_fields 取默认值组装初始表单状态(缺省值用空串,保证受控输入)。
export function initialFieldValues(
fields: readonly ToolInputFieldView[] | undefined,
): FieldValues {
const values: FieldValues = {};
for (const field of fields ?? []) {
values[field.name] = field.default ?? "";
}
return values;
}
// —— 校验 ——
// 必填字段缺失 → 返回字段名列表(空数组=通过。number 类型仅校验非空(值合法性交后端)。
export function missingRequiredFields(
fields: readonly ToolInputFieldView[] | undefined,
values: FieldValues,
): string[] {
const missing: string[] = [];
for (const field of fields ?? []) {
if (!field.required) continue;
if ((values[field.name] ?? "").trim().length === 0) {
missing.push(field.label);
}
}
return missing;
}
// —— 请求体组装 ——
// 已知入参字段brief/chapter_no/count/kind按 ToolGenerateRequest snake_case 取用;
// 其余字段名忽略descriptor 的 input_fields 命名须落在该联合内。number 安全解析,非法→省略。
const KNOWN_NUMBER_FIELDS = ["chapter_no", "count"] as const;
function parseOptionalNumber(raw: string | undefined): number | null {
if (raw === undefined) return null;
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
export function buildGenerateRequest(values: FieldValues): ToolGenerateRequest {
const body: ToolGenerateRequest = { brief: (values["brief"] ?? "").trim() };
for (const key of KNOWN_NUMBER_FIELDS) {
const n = parseOptionalNumber(values[key]);
if (n !== null) body[key] = n;
}
const kind = values["kind"]?.trim();
if (kind) body.kind = kind;
return body;
}
// —— output_kind → 预览渲染模型 ——
// 把后端结构化产物preview: 任意 schema 的 model_dump归一为「卡片列表」渲染模型
// 组件据此渲染(小而稳的 switch + 兜底),无需为每个工具写专属组件。
export interface PreviewField {
label: string;
value: string;
}
export interface PreviewItem {
// 标题行(如脑洞 premise / 书名 title / 实体 name可空纯文本变体
heading: string | null;
// 次要字段angle/rationale/mechanism…逐行展示。
fields: PreviewField[];
// 大段正文(开篇/简介变体的 text独占一段。
body: string | null;
}
export interface PreviewModel {
// 用于「入库」时贴对应 schemaworld_entities / outline scenes
kind: string;
items: PreviewItem[];
}
function asString(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asRecordArray(v: unknown): Record<string, unknown>[] {
if (!Array.isArray(v)) return [];
return v.filter(
(x): x is Record<string, unknown> => typeof x === "object" && x !== null,
);
}
// 取 preview 对象里第一个数组型字段各工具的列表键名不同ideas/titles/variants/names/...)。
function firstArrayEntry(
preview: Record<string, unknown> | undefined,
): unknown[] {
if (!preview) return [];
for (const value of Object.values(preview)) {
if (Array.isArray(value)) return value;
}
return [];
}
// 把一条记录的若干「次要字段」按 (key,label) 映射收集为 PreviewField缺省字段跳过
function collectFields(
row: Record<string, unknown>,
specs: readonly { key: string; label: string }[],
): PreviewField[] {
const fields: PreviewField[] = [];
for (const spec of specs) {
const value = asString(row[spec.key]);
if (value) fields.push({ label: spec.label, value });
}
return fields;
}
function rulesToField(row: Record<string, unknown>): PreviewField[] {
const rules = asStringArray(row["rules"]);
return rules.length > 0
? [{ label: "硬规则", value: rules.join("") }]
: [];
}
// 取 preview 里的原始记录数组(供入库形变;与 mapPreview 同源,保证选中下标对齐)。
export function previewRows(
preview: Record<string, unknown> | undefined,
): Record<string, unknown>[] {
return asRecordArray(firstArrayEntry(preview));
}
// output_kind → 行渲染策略。未知 kind 走兜底heading=首个字符串字段,其余键值列出)。
export function mapPreview(
outputKind: string,
preview: Record<string, unknown> | undefined,
): PreviewModel {
const rows = asRecordArray(firstArrayEntry(preview));
const items = rows.map((row) => previewItem(outputKind, row));
return { kind: outputKind, items };
}
function previewItem(
outputKind: string,
row: Record<string, unknown>,
): PreviewItem {
switch (outputKind) {
case "IdeaListResult":
return {
heading: asString(row["premise"]) || null,
fields: collectFields(row, [
{ key: "hook", label: "爽点" },
{ key: "genre_fit", label: "题材契合" },
]),
body: null,
};
case "TitleListResult":
return {
heading: asString(row["title"]) || null,
fields: collectFields(row, [{ key: "rationale", label: "立意" }]),
body: null,
};
case "BlurbResult":
return {
heading: asString(row["angle"]) || null,
fields: [],
body: asString(row["text"]) || null,
};
case "NameListResult":
return {
heading: asString(row["name"]) || null,
fields: collectFields(row, [
{ key: "kind", label: "类别" },
{ key: "note", label: "说明" },
]),
body: null,
};
case "GoldenFingerResult":
return {
heading: asString(row["name"]) || null,
fields: collectFields(row, [
{ key: "mechanism", label: "机制" },
{ key: "growth", label: "成长" },
{ key: "limits", label: "限制" },
]),
body: null,
};
case "GlossaryResult":
return {
heading: asString(row["name"]) || null,
fields: [
...collectFields(row, [
{ key: "type", label: "类型" },
{ key: "definition", label: "释义" },
]),
...rulesToField(row),
],
body: null,
};
case "OpeningResult":
return { heading: null, fields: [], body: asString(row["text"]) || null };
case "DetailedOutlineResult":
return {
heading: asString(row["beat"]) || null,
fields: collectFields(row, [
{ key: "idx", label: "序" },
{ key: "purpose", label: "作用" },
{ key: "conflict", label: "冲突" },
{ key: "hook", label: "钩子" },
]),
body: null,
};
default:
return fallbackItem(row);
}
}
// 兜底:首个字符串字段当标题,其余键值平铺(未知 output_kind 也能展示,不报错)。
function fallbackItem(row: Record<string, unknown>): PreviewItem {
const entries = Object.entries(row);
let heading: string | null = null;
const fields: PreviewField[] = [];
for (const [key, raw] of entries) {
const value = Array.isArray(raw) ? asStringArray(raw).join("") : asString(raw);
if (!value) continue;
if (heading === null) {
heading = value;
} else {
fields.push({ label: key, value });
}
}
return { heading, fields, body: null };
}
// —— legacy 路由解析 ——
// legacy 工具的 legacy_route 含 {id} 占位(如 /projects/{id}/codex?gen=world→ 替换为真实 projectId。
export function resolveLegacyRoute(
route: string,
projectId: string,
): string {
return route.replace(/\{id\}/g, projectId);
}
// 判定工具点击应导航legacy还是开 runner新工具
export function isLegacyTool(tool: Readonly<ToolDescriptorView>): boolean {
return tool.is_legacy && !!tool.legacy_route;
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import {
extractIngestConflicts,
generationErrorMessage,
type IngestConflicts,
} from "@/lib/generation/cards";
import { mapPreview, type PreviewModel } from "./toolbox";
import { buildIngestRequest, type IngestBuildInput } from "./ingest";
import type { ToolGenerateRequest } from "@/lib/api/types";
export type GenStatus = "idle" | "generating" | "preview" | "error";
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error";
export interface UseGenerator {
genStatus: GenStatus;
preview: PreviewModel | null;
// 原始结构化产物(供入库按选中下标形变;与 preview.items 同源对齐)。
rawPreview: Record<string, unknown> | undefined;
outputKind: string | null;
ingestStatus: IngestStatus;
// 409 冲突详情(待作者裁决);裁决后带 acknowledge 重发。
conflicts: IngestConflicts | null;
created: string[];
generate: (
projectId: string,
toolKey: string,
body: ToolGenerateRequest,
) => Promise<void>;
// 入库选中行acknowledge 用于过 409已查看冲突后重发
ingest: (
projectId: string,
toolKey: string,
input: IngestBuildInput,
) => Promise<boolean>;
reset: () => void;
}
// 通用生成器状态机idle→generating→preview+ 入库 409 裁决流,仿 useCharacterGen。
// 声明驱动generate/ingest 不感知具体工具,按 tool_key + descriptor 形变后调通用端点。
export function useGenerator(): UseGenerator {
const [genStatus, setGenStatus] = useState<GenStatus>("idle");
const [preview, setPreview] = useState<PreviewModel | null>(null);
const [rawPreview, setRawPreview] = useState<
Record<string, unknown> | undefined
>(undefined);
const [outputKind, setOutputKind] = useState<string | null>(null);
const [ingestStatus, setIngestStatus] = useState<IngestStatus>("idle");
const [conflicts, setConflicts] = useState<IngestConflicts | null>(null);
const [created, setCreated] = useState<string[]>([]);
const toast = useToast();
const generate = useCallback<UseGenerator["generate"]>(
async (projectId, toolKey, body) => {
setGenStatus("generating");
setPreview(null);
setRawPreview(undefined);
setOutputKind(null);
setConflicts(null);
setIngestStatus("idle");
setCreated([]);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/skills/{tool_key}/generate",
{
params: { path: { project_id: projectId, tool_key: toolKey } },
body,
},
);
if (error || !data) {
setGenStatus("error");
toast(generationErrorMessage(error), "error");
return;
}
setPreview(mapPreview(data.output_kind, data.preview));
setRawPreview(data.preview);
setOutputKind(data.output_kind);
setGenStatus("preview");
} catch {
setGenStatus("error");
toast("生成请求异常,请检查网络。", "error");
}
},
[toast],
);
const ingest = useCallback<UseGenerator["ingest"]>(
async (projectId, toolKey, input) => {
const body = buildIngestRequest(input);
if (!body) {
toast("该生成器不支持入库。", "error");
return false;
}
if (input.rows.length === 0) {
toast("请至少选择一项入库。", "error");
return false;
}
setIngestStatus("ingesting");
try {
const { data, error } = await api.POST(
"/projects/{project_id}/skills/{tool_key}/ingest",
{
params: { path: { project_id: projectId, tool_key: toolKey } },
body,
},
);
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;
}
setCreated(data.created ?? []);
setConflicts(null);
setIngestStatus("done");
toast(`已入库 ${data.created?.length ?? 0} 项至 ${data.table}`, "success");
if (data.rejected_tables && data.rejected_tables.length > 0) {
toast(`越权写表被丢弃:${data.rejected_tables.join("、")}`, "info");
}
return true;
} catch {
setIngestStatus("error");
toast("入库请求异常,请检查网络。", "error");
return false;
}
},
[toast],
);
const reset = useCallback((): void => {
setGenStatus("idle");
setPreview(null);
setRawPreview(undefined);
setOutputKind(null);
setIngestStatus("idle");
setConflicts(null);
setCreated([]);
}, []);
return {
genStatus,
preview,
rawPreview,
outputKind,
ingestStatus,
conflicts,
created,
generate,
ingest,
reset,
};
}

File diff suppressed because one or more lines are too long