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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user