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:
@@ -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` 改分组渲染。TDD:snippet 8 测 + grouping 10 测。前端门禁绿:lint/tsc/**vitest 201**(+18)/build。分支 `feat/ux-r1-r2-review-ergonomics`。下一步(按 plan):R3 验收前清单 → 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` 改分组渲染。TDD:snippet 8 测 + grouping 10 测。前端门禁绿:lint/tsc/**vitest 201**(+18)/build。分支 `feat/ux-r1-r2-review-ergonomics`。下一步(按 plan):R3 验收前清单 → Tier4。
|
||||||
- [2026-06-20] @frontend — **UX R3 完成**(验收前清单 HITL gate,UX §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`。TDD:4 测。前端门禁绿:lint/tsc/**vitest 205**(+4)/build。下一步(按 plan):Tier4(T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。
|
- [2026-06-20] @frontend — **UX R3 完成**(验收前清单 HITL gate,UX §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`。TDD:4 测。前端门禁绿:lint/tsc/**vitest 205**(+4)/build。下一步(按 plan):Tier4(T4-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,余 4rem),6 个固定高度页改用 `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。TDD:ai-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)。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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_UNAVAILABLE,JSON 信封而非帧)。
|
"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_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 });
|
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,
|
||||||
|
|||||||
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