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