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:
@@ -10,6 +10,7 @@ import { friendlyError } from "@/lib/errors/messages";
|
||||
import { useAutosave } from "@/lib/autosave/useAutosave";
|
||||
import { useDraftStream } from "@/lib/stream/useDraftStream";
|
||||
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
|
||||
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
|
||||
import { ChapterList, ChapterListContent } from "./ChapterList";
|
||||
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
||||
import { Editor } from "./Editor";
|
||||
@@ -32,6 +33,9 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
|
||||
// stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。
|
||||
const [text, setText] = useState(initialText);
|
||||
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 stream = useDraftStream();
|
||||
const lastStreamText = useRef("");
|
||||
@@ -52,7 +56,17 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
|
||||
}, [stream.state.phase, stream.state.text, autosave]);
|
||||
|
||||
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 => {
|
||||
@@ -74,6 +88,12 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
|
||||
<ChapterList currentChapterNo={chapterNo} />
|
||||
|
||||
<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">
|
||||
<Editor
|
||||
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)。
|
||||
function StreamErrorNote({
|
||||
streamError,
|
||||
|
||||
@@ -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_UNAVAILABLE,JSON 信封而非帧)。
|
||||
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_UNAVAILABLE,JSON 信封而非帧)。
|
||||
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,
|
||||
|
||||
26
apps/web/lib/workbench/directive.test.ts
Normal file
26
apps/web/lib/workbench/directive.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
apps/web/lib/workbench/directive.ts
Normal file
41
apps/web/lib/workbench/directive.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user