feat(ux): B0 注入透明读端点 + F1 写作页右栏真面板
兑现「看到的=写章用的」信任牌(不变量 #6),替换写作页右栏过时假占位
(「M1 暂未接 / M2 开放」)。
后端:
- GET /projects/{id}/chapters/{no}/injection → InjectionResponse(selected
实体 + 入选理由 + recent_n);调既有 assemble() 回放确定性 SelectionTrace,
无 LLM / 无 commit / 无 DDL;项目不存在→404,无大纲→selected:[]。
- 新 schemas/injection.py;tests/test_injection.py(3 测)。
前端:
- gen:api 纳入端点;纯逻辑 lib/workbench/injection.ts(理由/类型→中文徽标
+ 4 vitest)+ useInjection 读 hook。
- ChapterAssistant 改 client 组件:列出选中实体 + 理由徽标 + 空/载/错三态,
四审段指向审稿页;Workbench 传 projectId/chapterNo。
门禁:后端 ruff/format/mypy 159/alembic 无漂移/pytest 454;
前端 lint/typecheck/vitest 167/build。
B0 可控版(PUT override pin/排除/recent_n + selection 加参 + draft 同读
override + 持久化)+ F1 可控控件待后续。
This commit is contained in:
169
apps/api/tests/test_injection.py
Normal file
169
apps/api/tests/test_injection.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""B0 注入透明读端点测试:GET /projects/:id/chapters/:no/injection。
|
||||
|
||||
读取 assemble 的确定性 SelectionTrace(无 LLM、无 commit):
|
||||
- 命中实体 + 入选理由徽标;项目不存在 → 404;无大纲 → selected: [](不报错)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import FakeProjectRepo
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
DigestView,
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
)
|
||||
from ww_shared import ErrorCode
|
||||
|
||||
# ---- 内存 fake memory repos(无 DB;只够断言 SelectionTrace 形状)----
|
||||
|
||||
|
||||
class _OutlineRepo:
|
||||
def __init__(self, rows: dict[int, OutlineView]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self._rows.get(chapter_no)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
return [self._rows[k] for k in sorted(self._rows)]
|
||||
|
||||
|
||||
class _CharRepo:
|
||||
def __init__(self, rows: list[CharacterView]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _WorldRepo:
|
||||
def __init__(self, rows: list[WorldEntityView]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _DigestRepo:
|
||||
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
|
||||
return []
|
||||
|
||||
|
||||
class _ForeshadowRepo:
|
||||
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
|
||||
return []
|
||||
|
||||
|
||||
class _StyleRepo:
|
||||
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
|
||||
return None
|
||||
|
||||
|
||||
class _RulesRepo:
|
||||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
|
||||
return []
|
||||
|
||||
|
||||
class _SpecRepo:
|
||||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
|
||||
return ProjectSpecView(title="测试作品", premise="测试前提")
|
||||
|
||||
|
||||
def _memory_repos(
|
||||
*,
|
||||
outline: dict[int, OutlineView] | None = None,
|
||||
characters: list[CharacterView] | None = None,
|
||||
world: list[WorldEntityView] | None = None,
|
||||
) -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=_OutlineRepo(outline or {}),
|
||||
character=_CharRepo(characters or []),
|
||||
world_entity=_WorldRepo(world or []),
|
||||
digest=_DigestRepo(),
|
||||
foreshadow=_ForeshadowRepo(),
|
||||
style=_StyleRepo(),
|
||||
rules=_RulesRepo(),
|
||||
project=_SpecRepo(),
|
||||
)
|
||||
|
||||
|
||||
def _make_client(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
memory_repos: MemoryRepos,
|
||||
) -> httpx.AsyncClient:
|
||||
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_memory_repos, get_project_repo
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_memory_repos] = lambda: memory_repos
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
async def _create_project(repo: FakeProjectRepo, title: str = "注入") -> uuid.UUID:
|
||||
view = await repo.create(STUB_OWNER_ID, ProjectCreate(title=title))
|
||||
return uuid.UUID(str(view.id))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injection_lists_selected_entities_with_reasons() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _create_project(repo)
|
||||
memory = _memory_repos(
|
||||
outline={3: OutlineView(volume=1, chapter_no=3, beats={"entities": ["青檀"]})},
|
||||
characters=[
|
||||
CharacterView(name="林动", role="主角"),
|
||||
CharacterView(name="青檀", role="配角"),
|
||||
CharacterView(name="路人甲", role="龙套"),
|
||||
],
|
||||
)
|
||||
client = _make_client(project_repo=repo, memory_repos=memory)
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/chapters/3/injection")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["project_id"] == str(pid)
|
||||
assert body["chapter_no"] == 3
|
||||
by_name = {e["name"]: e for e in body["selected"]}
|
||||
assert "林动" in by_name and "main_character" in by_name["林动"]["reasons"]
|
||||
assert "青檀" in by_name and "explicit_beat" in by_name["青檀"]["reasons"]
|
||||
assert "路人甲" not in by_name # 未命中任何理由 → 不注入
|
||||
assert body["recent_n"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injection_unknown_project_404() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters/1/injection")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injection_no_outline_returns_empty() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _create_project(repo)
|
||||
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/chapters/9/injection")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["selected"] == []
|
||||
@@ -26,6 +26,7 @@ from ww_core.domain.project_repo import ProjectCreate, ProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.domain.review_repo import ReviewRepo
|
||||
from ww_core.memory import assemble
|
||||
from ww_core.memory.selection import RECENT_DIGEST_COUNT
|
||||
from ww_core.orchestrator import (
|
||||
ChapterState,
|
||||
SseEvent,
|
||||
@@ -40,6 +41,7 @@ from ww_llm_gateway import Gateway
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.injection import InjectionEntity, InjectionResponse
|
||||
from ww_api.schemas.projects import (
|
||||
AcceptRequest,
|
||||
AcceptResponse,
|
||||
@@ -124,6 +126,38 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
|
||||
return _to_response(view)
|
||||
|
||||
|
||||
@router.get("/{project_id}/chapters/{chapter_no}/injection")
|
||||
async def get_injection(
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
repos: MemoryReposDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
) -> InjectionResponse:
|
||||
"""本章注入透明(B0,读端点):回放确定性 SelectionTrace。无 LLM、无 commit。
|
||||
|
||||
项目不存在 → 404;无大纲 → selected: [](不报错)。看到的=写章用的(不变量 #6)。
|
||||
"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
||||
context = await assemble(repos, project_id, chapter_no)
|
||||
selected = [
|
||||
InjectionEntity(kind=e.kind, name=e.name, reasons=list(e.reasons))
|
||||
for e in context.selection.selected
|
||||
]
|
||||
log.info(
|
||||
"injection_read",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
selected_count=len(selected),
|
||||
)
|
||||
return InjectionResponse(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
selected=selected,
|
||||
recent_n=RECENT_DIGEST_COUNT,
|
||||
)
|
||||
|
||||
|
||||
def _encode_sse(event: SseEvent) -> str:
|
||||
"""把归一事件编码为 text/event-stream 帧:`event: <name>\\ndata: <json>\\n\\n`。"""
|
||||
payload = json.dumps(event.data, ensure_ascii=False)
|
||||
|
||||
31
apps/api/ww_api/schemas/injection.py
Normal file
31
apps/api/ww_api/schemas/injection.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""本章注入透明(B0)的响应 schema(C 扩 / ARCH §3.4、§5.3)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
|
||||
读取无 LLM、无 commit——仅回放 assemble() 的确定性 SelectionTrace(不变量 #6)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class InjectionEntity(BaseModel):
|
||||
"""一个被注入本章上下文的实体 + 入选理由(喂给透明面板的徽标)。"""
|
||||
|
||||
kind: str = Field(description="character / world_entity")
|
||||
name: str
|
||||
reasons: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="explicit_beat / main_character / recent_digest / foreshadow_window",
|
||||
)
|
||||
|
||||
|
||||
class InjectionResponse(BaseModel):
|
||||
"""GET /projects/:id/chapters/:no/injection:本章确定性注入留痕。"""
|
||||
|
||||
project_id: uuid.UUID
|
||||
chapter_no: int
|
||||
selected: list[InjectionEntity] = Field(default_factory=list)
|
||||
recent_n: int = Field(description="近况摘要回看章数(确定性选择的默认参数)")
|
||||
@@ -1,6 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
kindLabel,
|
||||
reasonLabel,
|
||||
type InjectionEntity,
|
||||
} from "@/lib/workbench/injection";
|
||||
import { useInjection } from "@/lib/workbench/useInjection";
|
||||
|
||||
interface ChapterAssistantProps {
|
||||
projectId: string;
|
||||
chapterNo: number;
|
||||
}
|
||||
|
||||
// 右栏「本章助手」(UX §6.3)。
|
||||
// M1 无端点暴露注入选择轨迹/四审结果 → 占位说明,不编造 API。
|
||||
export function ChapterAssistant() {
|
||||
// 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标——
|
||||
// 这是「看到的=写章用的」的信任牌(不变量 #6)。「四审」指向审稿页入口。
|
||||
export function ChapterAssistant({
|
||||
projectId,
|
||||
chapterNo,
|
||||
}: ChapterAssistantProps) {
|
||||
const injection = useInjection(projectId, chapterNo);
|
||||
const selected = injection.data?.selected ?? [];
|
||||
|
||||
return (
|
||||
<aside className="hidden border-l border-line bg-panel p-4 lg:block">
|
||||
<h2 className="text-sm font-semibold text-ink">本章助手</h2>
|
||||
@@ -9,19 +32,63 @@ export function ChapterAssistant() {
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
本章注入(透明)
|
||||
</h3>
|
||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
||||
M1 暂未接:注入的设定/伏笔选择轨迹尚无对应端点,将在记忆服务接入后展示。
|
||||
</p>
|
||||
|
||||
{injection.loading ? (
|
||||
<p className="mt-2 text-xs text-ink-soft">加载注入信息…</p>
|
||||
) : injection.error ? (
|
||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
||||
{injection.error}
|
||||
</p>
|
||||
) : selected.length === 0 ? (
|
||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
||||
本章暂无注入实体(先在大纲点名设定/角色)。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{selected.map((entity) => (
|
||||
<EntityRow key={`${entity.kind}:${entity.name}`} entity={entity} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-4">
|
||||
<section className="mt-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
四审
|
||||
</h3>
|
||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
||||
M2 开放:一致性 / 伏笔 / 文风 / 节奏。
|
||||
<p className="mt-2 text-xs text-ink-soft">
|
||||
一致性 / 伏笔 / 文风 / 节奏,在审稿页逐条裁决。
|
||||
</p>
|
||||
<Link
|
||||
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
||||
className="mt-2 inline-block rounded border border-cinnabar px-3 py-1.5 text-xs text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
|
||||
>
|
||||
去审稿 →
|
||||
</Link>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityRow({ entity }: { entity: InjectionEntity }) {
|
||||
return (
|
||||
<li className="rounded border border-line bg-bg p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded bg-panel px-1.5 py-0.5 text-[10px] text-ink-soft">
|
||||
{kindLabel(entity.kind)}
|
||||
</span>
|
||||
<span className="text-sm text-ink">{entity.name}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{(entity.reasons ?? []).map((reason) => (
|
||||
<span
|
||||
key={reason}
|
||||
className="rounded bg-[var(--color-cinnabar-wash)] px-1.5 py-0.5 text-[10px] text-cinnabar"
|
||||
>
|
||||
{reasonLabel(reason)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ChapterAssistant />
|
||||
<ChapterAssistant projectId={project.id} chapterNo={chapterNo} />
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
92
apps/web/lib/api/schema.d.ts
vendored
92
apps/web/lib/api/schema.d.ts
vendored
@@ -90,6 +90,28 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chapters/{chapter_no}/injection": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Injection
|
||||
* @description 本章注入透明(B0,读端点):回放确定性 SelectionTrace。无 LLM、无 commit。
|
||||
*
|
||||
* 项目不存在 → 404;无大纲 → selected: [](不报错)。看到的=写章用的(不变量 #6)。
|
||||
*/
|
||||
get: operations["get_injection_projects__project_id__chapters__chapter_no__injection_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chapters/{chapter_no}/draft": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -958,6 +980,44 @@ export interface components {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/**
|
||||
* InjectionEntity
|
||||
* @description 一个被注入本章上下文的实体 + 入选理由(喂给透明面板的徽标)。
|
||||
*/
|
||||
InjectionEntity: {
|
||||
/**
|
||||
* Kind
|
||||
* @description character / world_entity
|
||||
*/
|
||||
kind: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/**
|
||||
* Reasons
|
||||
* @description explicit_beat / main_character / recent_digest / foreshadow_window
|
||||
*/
|
||||
reasons?: string[];
|
||||
};
|
||||
/**
|
||||
* InjectionResponse
|
||||
* @description GET /projects/:id/chapters/:no/injection:本章确定性注入留痕。
|
||||
*/
|
||||
InjectionResponse: {
|
||||
/**
|
||||
* Project Id
|
||||
* Format: uuid
|
||||
*/
|
||||
project_id: string;
|
||||
/** Chapter No */
|
||||
chapter_no: number;
|
||||
/** Selected */
|
||||
selected?: components["schemas"]["InjectionEntity"][];
|
||||
/**
|
||||
* Recent N
|
||||
* @description 近况摘要回看章数(确定性选择的默认参数)
|
||||
*/
|
||||
recent_n: number;
|
||||
};
|
||||
/**
|
||||
* OAuthDisconnectResponse
|
||||
* @description 断开:是否删到凭据行。
|
||||
@@ -1597,6 +1657,38 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_injection_projects__project_id__chapters__chapter_no__injection_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
chapter_no: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["InjectionResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_draft_projects__project_id__chapters__chapter_no__draft_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
27
apps/web/lib/workbench/injection.test.ts
Normal file
27
apps/web/lib/workbench/injection.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { kindLabel, reasonLabel } from "./injection";
|
||||
|
||||
describe("reasonLabel", () => {
|
||||
it("maps each known selection reason to a Chinese badge", () => {
|
||||
expect(reasonLabel("explicit_beat")).toBe("本章点名");
|
||||
expect(reasonLabel("main_character")).toBe("主角常驻");
|
||||
expect(reasonLabel("recent_digest")).toBe("近章出现");
|
||||
expect(reasonLabel("foreshadow_window")).toBe("伏笔窗口");
|
||||
});
|
||||
|
||||
it("falls back to the raw code for an unknown reason", () => {
|
||||
expect(reasonLabel("future_reason")).toBe("future_reason");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kindLabel", () => {
|
||||
it("maps entity kinds to Chinese", () => {
|
||||
expect(kindLabel("character")).toBe("角色");
|
||||
expect(kindLabel("world_entity")).toBe("设定");
|
||||
});
|
||||
|
||||
it("falls back to the raw kind when unknown", () => {
|
||||
expect(kindLabel("mystery")).toBe("mystery");
|
||||
});
|
||||
});
|
||||
30
apps/web/lib/workbench/injection.ts
Normal file
30
apps/web/lib/workbench/injection.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// 本章注入透明(B0/F1)纯逻辑:入选理由 / 实体类型 → 中文徽标。
|
||||
// 纯函数,便于 node 环境单测;渲染层只管样式。
|
||||
|
||||
import type { components } from "@/lib/api/schema";
|
||||
|
||||
export type InjectionResponse = components["schemas"]["InjectionResponse"];
|
||||
export type InjectionEntity = components["schemas"]["InjectionEntity"];
|
||||
|
||||
// SelectionReason → 作者可读徽标(对齐 ARCH §3.4 的四来源)。
|
||||
export const REASON_LABELS: Record<string, string> = {
|
||||
explicit_beat: "本章点名",
|
||||
main_character: "主角常驻",
|
||||
recent_digest: "近章出现",
|
||||
foreshadow_window: "伏笔窗口",
|
||||
};
|
||||
|
||||
// EntityKind → 中文。
|
||||
export const KIND_LABELS: Record<string, string> = {
|
||||
character: "角色",
|
||||
world_entity: "设定",
|
||||
};
|
||||
|
||||
// 未知 code 时回退原值(不吞,便于发现新理由)。
|
||||
export function reasonLabel(reason: string): string {
|
||||
return REASON_LABELS[reason] ?? reason;
|
||||
}
|
||||
|
||||
export function kindLabel(kind: string): string {
|
||||
return KIND_LABELS[kind] ?? kind;
|
||||
}
|
||||
50
apps/web/lib/workbench/useInjection.ts
Normal file
50
apps/web/lib/workbench/useInjection.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { InjectionResponse } from "./injection";
|
||||
|
||||
export interface UseInjection {
|
||||
data: InjectionResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// 本章注入透明(B0 读端点):挂载时拉一次确定性 SelectionTrace。
|
||||
// 只读、无 LLM、无 commit——展示「选了谁/为什么」。失败给可读文案不抛。
|
||||
export function useInjection(
|
||||
projectId: string,
|
||||
chapterNo: number,
|
||||
): UseInjection {
|
||||
const [data, setData] = useState<InjectionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
void (async () => {
|
||||
const { data: body, error: err } = await api.GET(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/injection",
|
||||
{ params: { path: { project_id: projectId, chapter_no: chapterNo } } },
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (err || !body) {
|
||||
setError("注入信息暂不可用");
|
||||
setData(null);
|
||||
} else {
|
||||
setData(body);
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, chapterNo]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user