feat(ux): T4-b 前端 — 本章指令框 + 风格快捷预设传入 draft 生成

新 lib/workbench/directive.ts (STYLE_PRESETS + composeDirective 纯函数+测);useDraftStream.start 加 directive 参数(非空时 POST JSON body);Workbench 加「本章指令」textarea + 风格预设 chips,写本章时 composeDirective 传入。TDD: directive 测。前端门禁绿。docs(progress): 记 Tier4 完成。
This commit is contained in:
Yaojia Wang
2026-06-20 18:27:47 +02:00
parent 2155193cdc
commit cc0aabdd9a
5 changed files with 214 additions and 49 deletions

View File

@@ -178,3 +178,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
- [2026-06-19] @backend **bugfix 空写章 prompt**`assemble`C5从不含项目 premise/logline/theme/title 且无写章指令 全新项目 premise产空 `stable_core`+`volatile`写章触发 LLM `400 message must not be empty`修复`MemoryRepos` 加第 8 repo `project:ProjectSpecRepo``SqlProjectSpecRepo` project_id `projects``assemble` 作品蓝本放入 `stable_core` 首段书级 spec缓存前缀 #9+ `volatile` 始终含请创作第 N 章的正文。」。 `domain/repositories.py`(+ProjectSpecView/Repo) · `memory/sql_repositories.py`(+SqlProjectSpecRepo, sql_memory_repos 装配) · `memory/assemble.py`(_build_spec_section + 注入)测试 `test_memory.py`(+bare-project/chapter-directive 两测+fake) · `test_projects.py`/`test_generation.py`( project= fake)。无 DDL/迁移只读既有列)。门禁全绿ruff/format/mypy(154 文件)/alembic check(无漂移)/pytest **438 passed** contracts C5 + gotcha消费方注意手搓 `MemoryRepos(...)` 须补 `project=` - [2026-06-19] @backend **bugfix 空写章 prompt**`assemble`C5从不含项目 premise/logline/theme/title 且无写章指令 全新项目 premise产空 `stable_core`+`volatile`写章触发 LLM `400 message must not be empty`修复`MemoryRepos` 加第 8 repo `project:ProjectSpecRepo``SqlProjectSpecRepo` project_id `projects``assemble` 作品蓝本放入 `stable_core` 首段书级 spec缓存前缀 #9+ `volatile` 始终含请创作第 N 章的正文。」。 `domain/repositories.py`(+ProjectSpecView/Repo) · `memory/sql_repositories.py`(+SqlProjectSpecRepo, sql_memory_repos 装配) · `memory/assemble.py`(_build_spec_section + 注入)测试 `test_memory.py`(+bare-project/chapter-directive 两测+fake) · `test_projects.py`/`test_generation.py`( project= fake)。无 DDL/迁移只读既有列)。门禁全绿ruff/format/mypy(154 文件)/alembic check(无漂移)/pytest **438 passed** contracts C5 + gotcha消费方注意手搓 `MemoryRepos(...)` 须补 `project=`
- [2026-06-20] @frontend **UX R1+R2 完成**审稿裁决人体学纯前端无后端改)。R1冲突卡内联命中段预览——新纯函数 `lib/review/snippet.ts` `where` N 解析 1-based 段号 取终稿对应段截断预览无段号/越界/空段null 回退跳转」),`ConflictCard` `snippet` prop 渲染朱砂引用块R2冲突按 type 分组 + 严重度排序设定违例>能力不符>时间线倒错>地理矛盾>性格漂移,未知殿后)+ 组计数徽标 + 「跳到下一条未裁决」(按显示顺序环回)+ 主次裁决按钮(采纳改法=朱砂主、忽略/手改=次级)——新纯函数 `lib/review/grouping.ts``groupConflicts`/`displayOrder`/`nextUnresolvedInOrder`**分组只改显示顺序、每条携原始 index守 conflict_index 覆盖 range 不变量**`ReviewReport` 改分组渲染。TDDsnippet 8 测 + grouping 10 测。前端门禁绿lint/tsc/**vitest 201**(+18)/build。分支 `feat/ux-r1-r2-review-ergonomics`。下一步(按 planR3 验收前清单 → Tier4。 - [2026-06-20] @frontend **UX R1+R2 完成**审稿裁决人体学纯前端无后端改)。R1冲突卡内联命中段预览——新纯函数 `lib/review/snippet.ts` `where` N 解析 1-based 段号 取终稿对应段截断预览无段号/越界/空段null 回退跳转」),`ConflictCard` `snippet` prop 渲染朱砂引用块R2冲突按 type 分组 + 严重度排序设定违例>能力不符>时间线倒错>地理矛盾>性格漂移,未知殿后)+ 组计数徽标 + 「跳到下一条未裁决」(按显示顺序环回)+ 主次裁决按钮(采纳改法=朱砂主、忽略/手改=次级)——新纯函数 `lib/review/grouping.ts``groupConflicts`/`displayOrder`/`nextUnresolvedInOrder`**分组只改显示顺序、每条携原始 index守 conflict_index 覆盖 range 不变量**`ReviewReport` 改分组渲染。TDDsnippet 8 测 + grouping 10 测。前端门禁绿lint/tsc/**vitest 201**(+18)/build。分支 `feat/ux-r1-r2-review-ergonomics`。下一步(按 planR3 验收前清单 → Tier4。
- [2026-06-20] @frontend**UX R3 完成**(验收前清单 HITL gateUX §7.4,纯前端无后端改,续 `feat/ux-r1-r2-review-ergonomics`)。新纯函数 `lib/review/accept-preview.ts``buildAcceptPreview`:据现有 client state 列「本次验收将更新」——晋升版次 + 提炼章节摘要 + 写回裁决(有冲突时) + 验收后伏笔到期扫描(可能置 OVERDUE) + 伏笔建议待登记提醒(有建议时))。**只列后端 accept 事务确实做的事**(核对 `projects.py` accept 端点promote+digest+decisions+overdue 扫描;**不**声称改人物 latest_state/自动落库伏笔建议);带「以上为预期,最终以验收回执为准」免责。`AcceptPanel``foreshadowCount` prop + 未阻断时渲染清单,`ReviewReport``foreshadow.length`。TDD4 测。前端门禁绿lint/tsc/**vitest 205**(+4)/build。下一步按 planTier4T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。 - [2026-06-20] @frontend**UX R3 完成**(验收前清单 HITL gateUX §7.4,纯前端无后端改,续 `feat/ux-r1-r2-review-ergonomics`)。新纯函数 `lib/review/accept-preview.ts``buildAcceptPreview`:据现有 client state 列「本次验收将更新」——晋升版次 + 提炼章节摘要 + 写回裁决(有冲突时) + 验收后伏笔到期扫描(可能置 OVERDUE) + 伏笔建议待登记提醒(有建议时))。**只列后端 accept 事务确实做的事**(核对 `projects.py` accept 端点promote+digest+decisions+overdue 扫描;**不**声称改人物 latest_state/自动落库伏笔建议);带「以上为预期,最终以验收回执为准」免责。`AcceptPanel``foreshadowCount` prop + 未阻断时渲染清单,`ReviewReport``foreshadow.length`。TDD4 测。前端门禁绿lint/tsc/**vitest 205**(+4)/build。下一步按 planTier4T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。
- [2026-06-20] @frontend**Tier4 完成**T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop。T4-a`lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3余 4rem6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b全链后端 `assemble/_build_volatile``directive` 形参(「本章指令」段领衔 volatile**绝不入 stable_core**,守缓存前缀不变量 #9`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDDai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩T4-b

View File

@@ -10,6 +10,7 @@ import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave"; import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream"; import { useDraftStream } from "@/lib/stream/useDraftStream";
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter"; import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor"; import { Editor } from "./Editor";
@@ -32,6 +33,9 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
// stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。 // stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。
const [text, setText] = useState(initialText); const [text, setText] = useState(initialText);
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null); const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState("");
const [presetIds, setPresetIds] = useState<string[]>([]);
const autosave = useAutosave(project.id, chapterNo); const autosave = useAutosave(project.id, chapterNo);
const stream = useDraftStream(); const stream = useDraftStream();
const lastStreamText = useRef(""); const lastStreamText = useRef("");
@@ -52,7 +56,17 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
}, [stream.state.phase, stream.state.text, autosave]); }, [stream.state.phase, stream.state.text, autosave]);
const onWrite = (): void => { const onWrite = (): void => {
void stream.start(project.id, chapterNo); void stream.start(
project.id,
chapterNo,
composeDirective(directive, presetIds),
);
};
const togglePreset = (id: string): void => {
setPresetIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}; };
const onEditorChange = (value: string): void => { const onEditorChange = (value: string): void => {
@@ -74,6 +88,12 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
<ChapterList currentChapterNo={chapterNo} /> <ChapterList currentChapterNo={chapterNo} />
<section className="flex min-w-0 flex-col bg-bg"> <section className="flex min-w-0 flex-col bg-bg">
<DirectivePanel
directive={directive}
onDirectiveChange={setDirective}
presetIds={presetIds}
onTogglePreset={togglePreset}
/>
<div className="flex-1 overflow-auto px-6 py-8"> <div className="flex-1 overflow-auto px-6 py-8">
<Editor <Editor
value={text} value={text}
@@ -120,6 +140,62 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
); );
} }
interface DirectivePanelProps {
directive: string;
onDirectiveChange: (value: string) => void;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
}
// 本章指令面板T4-b可折叠的 textarea + 风格快捷预设 chips。
// 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成。
function DirectivePanel({
directive,
onDirectiveChange,
presetIds,
onTogglePreset,
}: DirectivePanelProps) {
return (
<details className="border-b border-line bg-panel px-6 py-2">
<summary className="cursor-pointer text-sm text-ink-soft hover:text-cinnabar">
</summary>
<div className="mt-2 space-y-2">
<label className="block">
<span className="sr-only"></span>
<textarea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink placeholder:text-ink-soft focus:border-cinnabar focus:outline-none"
/>
</label>
<div className="flex flex-wrap gap-2">
{STYLE_PRESETS.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onTogglePreset(preset.id)}
className={
active
? "rounded-full border border-cinnabar bg-cinnabar px-3 py-1 text-xs text-panel"
: "rounded-full border border-line px-3 py-1 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
}
>
{preset.label}
</button>
);
})}
</div>
</div>
</details>
);
}
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4 // 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({ function StreamErrorNote({
streamError, streamError,

View File

@@ -41,7 +41,11 @@ function reducer(state: StreamState, action: Action): StreamState {
export interface UseDraftStream { export interface UseDraftStream {
state: StreamState; state: StreamState;
isStreaming: boolean; isStreaming: boolean;
start: (projectId: string, chapterNo: number) => Promise<void>; start: (
projectId: string,
chapterNo: number,
directive?: string,
) => Promise<void>;
stop: () => void; stop: () => void;
reset: (text: string) => void; reset: (text: string) => void;
} }
@@ -62,55 +66,72 @@ export function useDraftStream(): UseDraftStream {
dispatch({ type: "reset", text }); dispatch({ type: "reset", text });
}, []); }, []);
const start = useCallback(async (projectId: string, chapterNo: number) => { const start = useCallback(
const controller = new AbortController(); async (projectId: string, chapterNo: number, directive?: string) => {
controllerRef.current = controller; const controller = new AbortController();
dispatch({ type: "start" }); controllerRef.current = controller;
try { dispatch({ type: "start" });
const res = await fetch( try {
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/draft`, // 仅当本章指令非空才发 JSON body无指令退回裸 POST与旧后端调用兼容
{ const trimmed = directive?.trim();
method: "POST", const init: RequestInit =
headers: { Accept: "text/event-stream" }, trimmed && trimmed.length > 0
signal: controller.signal, ? {
}, method: "POST",
); headers: {
if (!res.ok || !res.body) { Accept: "text/event-stream",
// 流前错误(如无凭据 → 503 LLM_UNAVAILABLEJSON 信封而非帧)。 "Content-Type": "application/json",
let code = "STREAM_FAILED"; },
let message = `写章请求失败(${res.status}`; body: JSON.stringify({ directive: trimmed }),
try { signal: controller.signal,
const body = (await res.json()) as { }
error?: { code?: string; message?: string }; : {
}; method: "POST",
if (body.error?.code) code = body.error.code; headers: { Accept: "text/event-stream" },
if (body.error?.message) message = body.error.message; signal: controller.signal,
} catch { };
// 非 JSON 信封,沿用默认文案。 const res = await fetch(
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/draft`,
init,
);
if (!res.ok || !res.body) {
// 流前错误(如无凭据 → 503 LLM_UNAVAILABLEJSON 信封而非帧)。
let code = "STREAM_FAILED";
let message = `写章请求失败(${res.status}`;
try {
const body = (await res.json()) as {
error?: { code?: string; message?: string };
};
if (body.error?.code) code = body.error.code;
if (body.error?.message) message = body.error.message;
} catch {
// 非 JSON 信封,沿用默认文案。
}
dispatch({ type: "fail", code, message });
return;
} }
dispatch({ type: "fail", code, message }); const reader = res.body.getReader();
return; const decoder = new TextDecoder();
const buffer = new SseFrameBuffer();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const events = buffer.push(decoder.decode(value, { stream: true }));
if (events.length > 0) dispatch({ type: "events", events });
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
// 用户主动停止state 已置 aborted。
return;
}
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", code: "NETWORK", message });
} finally {
controllerRef.current = null;
} }
const reader = res.body.getReader(); },
const decoder = new TextDecoder(); [],
const buffer = new SseFrameBuffer(); );
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const events = buffer.push(decoder.decode(value, { stream: true }));
if (events.length > 0) dispatch({ type: "events", events });
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
// 用户主动停止state 已置 aborted。
return;
}
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", code: "NETWORK", message });
} finally {
controllerRef.current = null;
}
}, []);
return { return {
state, state,

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { composeDirective, STYLE_PRESETS } from "./directive";
describe("composeDirective", () => {
it("returns empty string when nothing selected", () => {
expect(composeDirective("", [])).toBe("");
expect(composeDirective(" ", [])).toBe("");
});
it("uses free text only when no presets", () => {
expect(composeDirective("多写战斗", [])).toBe("多写战斗");
});
it("combines presets (in canonical order) then free text", () => {
const result = composeDirective("多写战斗", ["fast-pace", "less-ai"]);
const lessAi = STYLE_PRESETS.find((p) => p.id === "less-ai")!.text;
const fastPace = STYLE_PRESETS.find((p) => p.id === "fast-pace")!.text;
expect(result).toBe(`${lessAi}${fastPace};多写战斗`);
});
it("ignores unknown preset ids", () => {
const lessAi = STYLE_PRESETS.find((p) => p.id === "less-ai")!.text;
expect(composeDirective("", ["nope", "less-ai"])).toBe(lessAi);
});
});

View File

@@ -0,0 +1,41 @@
// T4-b · 本章指令组装纯逻辑UX §3
// 作者自由文本 + 风格快捷预设 → 单条 directive 串,传入 draft 生成(直通后端 volatile
// 组件只渲染 chips/textarea预设清单与组装规则集中在此确定性、不可变。
export interface StylePreset {
id: string;
label: string;
text: string;
}
// 风格快捷预设(点选拼入指令开头,引导本章文风)。
export const STYLE_PRESETS: readonly StylePreset[] = [
{ id: "less-ai", label: "减少AI味", text: "减少 AI 腔,去掉套话与排比堆砌" },
{ id: "colloquial", label: "口语化", text: "用更口语、贴近人物的表达" },
{ id: "fast-pace", label: "快节奏", text: "加快节奏,少铺垫多冲突推进" },
];
const PRESET_BY_ID: ReadonlyMap<string, StylePreset> = new Map(
STYLE_PRESETS.map((preset) => [preset.id, preset]),
);
const SEPARATOR = "";
// 组合选中预设文案(按 STYLE_PRESETS 原序)+ 自由文本为一条指令。空 → ""。
export function composeDirective(
freeText: string,
presetIds: readonly string[],
): string {
const selected = new Set(presetIds);
const presetTexts = STYLE_PRESETS.filter((preset) =>
selected.has(preset.id),
).map((preset) => preset.text);
const trimmedFree = freeText.trim();
const parts = trimmedFree ? [...presetTexts, trimmedFree] : presetTexts;
return parts.join(SEPARATOR);
}
// 仅暴露给测试/组件的辅助id 是否为已知预设。
export function isKnownPreset(id: string): boolean {
return PRESET_BY_ID.has(id);
}