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:
Yaojia Wang
2026-06-20 10:57:03 +02:00
parent 7e63534366
commit 8058cfb11a
12 changed files with 513 additions and 10 deletions

View File

@@ -90,6 +90,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-06-20] UX计划 B0(读)+F1 — **✅ 本章注入透明(读端点 + 右栏真面板)**。兑现差异化信任牌「看到的=写章用的」(不变量 #6把先前写作页右栏「M1 暂未接 / M2 开放」的过时假占位换成真数据。**后端**(@backend)`GET /projects/{id}/chapters/{no}/injection``InjectionResponse{project_id,chapter_no,selected:[{kind,name,reasons[]}],recent_n}`,调既有 `assemble()` 回放确定性 `SelectionTrace`**无 LLM、无 commit、无 DDL**项目不存在→404无大纲→`selected:[]`。文件:`schemas/injection.py`(新) · `routers/projects.py`(+GET+2 import) · `tests/test_injection.py`(新,3 测)。**前端**(@frontend)`gen:api` 纳入端点;纯逻辑 `lib/workbench/injection.ts`(理由/类型→中文徽标 + 4 vitest) + `useInjection` 读 hook`ChapterAssistant` 改 client 组件列出选中实体 + 入选理由徽标(本章点名/主角常驻/近章出现/伏笔窗口)+ 空/载/错三态,「四审」段改指向审稿页;`Workbench``projectId/chapterNo`。**全仓门禁绿**:后端 ruff/format/mypy **159 Success**/alembic 无漂移/pytest **454 passed**;前端 lint/typecheck/vitest **167 passed**/build。记 contracts C3扩。**B0 余项可控版PUT override pin/排除/recent_n + selection 加参 + draft 同读 override + 持久化)+ F1 可控控件待后续。**
- [2026-06-20] @frontend**✅ 接上两个新读端点:大纲页 / 写作工作台重访时回显已存内容**。`pnpm gen:api` 纳入 `GET .../outline` + `GET .../chapters/{no}/draft` + schema `DraftView``lib/api/types.ts``DraftView` 别名。新增两个 RSC 读 helper仿 `fetchForeshadow`/`fetchProject` 服务端读、无 CORS`fetchOutline(projectId)``OutlineChapterView[]`200 解包 chapters**任何错误/空降级为 `[]`** 不阻塞进页),`fetchDraft(projectId,chapterNo)``DraftView|null`(复用 `getJsonOrNull`**404→null 当空编辑器**,不抛)。**大纲页**(`outline/page.tsx`) 进页 `fetchOutline``initialChapters` 种入 `OutlineEditor``useOutline` 已据非空初值置 `ready`)→ 重访显已生成大纲;生成流照常覆盖所显章节。**写作工作台**(`write/page.tsx` + `Workbench`) 进页 `fetchDraft``content` 作编辑器初值(`useState(initialText)`)→ 重访重载已写正文404/新章 → `""` 空编辑器;**SSE 流式写 + AbortController + PUT 自动保存全不动**(流起始空、仅 streaming/done/aborted 变化才覆盖,初值不反扑活跃流);导出 `WORKBENCH_CHAPTER_NO`(原 `M1_CHAPTER_NO`)供页面拉对应章草稿。新增 `lib/api/server.test.ts`(5 vitestmock fetch 验解包/降级/404→null)。前端门禁全绿gen:api / lint / typecheck / vitest **160**(155→+5) / build。文件`lib/api/{types,server,server.test}.ts` · `app/projects/[id]/{outline,write}/page.tsx` · `components/workbench/Workbench.tsx`。消费 contracts C3扩@backend 两读端点)。
- [2026-06-20] @backend**`GET /projects/{id}/chapters/{no}/draft` 草稿读端点(补缺口,镜像 GET /outline**。写作工作台先前只有 `POST .../draft`(SSE 流写)+`PUT .../draft`(自动保存) 无读端点 → 重访工作台编辑器空白、看似「未保存」(其实 `chapters` 行在库)。补对称读侧:`GET /projects/{id}/chapters/{no}/draft` → 200 `DraftView{project_id,chapter_no,volume,status,version,content,length}`**比 PUT 的 `DraftResponse``content`**——读侧需正文重建编辑器);无草稿行/正文空白 → **404 `NOT_FOUND`**(工作台据此呈现空编辑器,仿其它缺资源端点)。**直接复用既有 `chapter_repo.get_draft`**(同续审 `_resolve_review_draft` 读缝,`ChapterDraftView` 已含全字段,无新 repo 方法/view只读不写库多版本固定返草稿版次(`DRAFT_VERSION=1`)。文件:`schemas/projects.py`(+`DraftView`) · `routers/projects.py`(+GET endpoint+import) · `tests/test_projects.py`(+2回灌正文 / 无草稿 404)。**全仓门禁绿**ruff/format 干净、mypy **154 Success**、alembic **无漂移**、pytest **444 passed**442→+2。OpenAPI 新增 1 端点 → **@frontend`cd apps/web && pnpm gen:api`** + 工作台初次加载拉已保存草稿回灌编辑器。记 contracts C3扩。
- [2026-06-20] @backend**`GET /projects/{id}/outline` 大纲读端点(补缺口,仿 Codex 读端点)**。大纲页先前只有 `POST .../outline`(生成+落库)无读端点 → 重访页面已落库大纲不回显、看似「未保存」。补对称读侧:`GET /projects/{id}/outline` → 200 `OutlineResponse{chapters:[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows}]}`(与 POST **同形**,按 chapter_no 升序);项目存在无大纲 → **200 空列表**(非 404项目不存在 → 404 `NOT_FOUND`。**复用 C5 读侧**:扩 `OutlineRepo`(domain) + `SqlOutlineRepo`(assemble 读侧) 加 `list_for_project`(仿 Character/WorldEntityRepoorder_by chapter_no既有 `get` 不动);新注入缝 `get_outline_read_repo`(仿 `get_rules_read_repo`)。beats DB JSONB `{"beats":[...]}` → 解包裸 `list[str]`(同 POST。文件`routers/outline.py`(+GET) · `services/project_deps.py`(+dep) · `domain/repositories.py`(+protocol method) · `memory/sql_repositories.py`(+impl) · `tests/{test_outline.py(+3),fakes_projects.py(+FakeOutlineReadRepo)}` + 补 2 既有 outline faketest_memory/test_projects满足扩展后的 protocol。**全仓门禁绿**ruff/format 干净、mypy **154 Success**、alembic **无漂移**、pytest **442 passed**439→+3。OpenAPI 新增 1 端点 → **@frontend`cd apps/web && pnpm gen:api`** + 大纲页初次加载拉已持久化大纲。记 contracts C3扩。

View 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"] == []

View File

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

View File

@@ -0,0 +1,31 @@
"""本章注入透明B0的响应 schemaC 扩 / 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="近况摘要回看章数(确定性选择的默认参数)")

View File

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

View File

@@ -87,7 +87,7 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
/>
</section>
<ChapterAssistant />
<ChapterAssistant projectId={project.id} chapterNo={chapterNo} />
</div>
</AppShell>
);

View File

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

View 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");
});
});

View 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;
}

View 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

View File

@@ -300,4 +300,6 @@
## 契约变更日志append-only
> 格式:`- [date] @skill 改 Cx<改了什么> → 影响 <依赖方/任务>`
- [2026-06-20] @backend C3新增 **`GET /projects/{project_id}/chapters/{chapter_no}/injection`**本章注入透明B0 读端点)→ `InjectionResponse{project_id, chapter_no, selected:[InjectionEntity{kind,name,reasons[]}], recent_n}``schemas/injection.py`snake_case)。实现仅调既有 `assemble()` 回放确定性 `SelectionTrace`——** LLM commit DDL**项目不存在404无大纲`selected:[]`reasons 取值同 `SelectionReason`explicit_beat/main_character/recent_digest/foreshadow_window)。→ 影响 @frontend `pnpm gen:api` + `ChapterAssistant` 消费)。**B0 可控版PUT override + `select_relevant_entities` pinned/excluded/recent_n + draft 端点同读 override + 持久化尚未实现届时再扩本契约。**
- [2026-06-18] @llm C1`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed` schema `parsed is None`纯文本路径不变记账仍 **1 条 usage_ledger**/调用usage raw completion 提取)。`ProviderResult` 新增 `parsed` 字段结构化路径经可注入 `StructuredClient` Protocol fake测试不联网)。→ 影响 T2.2续审节点可直接 `gateway.run(req).parsed`)、未来所有结构化输出 Agent