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

@@ -41,7 +41,11 @@ function reducer(state: StreamState, action: Action): StreamState {
export interface UseDraftStream {
state: StreamState;
isStreaming: boolean;
start: (projectId: string, chapterNo: number) => Promise<void>;
start: (
projectId: string,
chapterNo: number,
directive?: string,
) => Promise<void>;
stop: () => void;
reset: (text: string) => void;
}
@@ -62,55 +66,72 @@ export function useDraftStream(): UseDraftStream {
dispatch({ type: "reset", text });
}, []);
const start = useCallback(async (projectId: string, chapterNo: number) => {
const controller = new AbortController();
controllerRef.current = controller;
dispatch({ type: "start" });
try {
const res = await fetch(
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/draft`,
{
method: "POST",
headers: { Accept: "text/event-stream" },
signal: controller.signal,
},
);
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 信封,沿用默认文案。
const start = useCallback(
async (projectId: string, chapterNo: number, directive?: string) => {
const controller = new AbortController();
controllerRef.current = controller;
dispatch({ type: "start" });
try {
// 仅当本章指令非空才发 JSON body无指令退回裸 POST与旧后端调用兼容
const trimmed = directive?.trim();
const init: RequestInit =
trimmed && trimmed.length > 0
? {
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ directive: trimmed }),
signal: controller.signal,
}
: {
method: "POST",
headers: { Accept: "text/event-stream" },
signal: controller.signal,
};
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 });
return;
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;
}
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 {
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);
}