diff --git a/apps/web/components/workbench/Workbench.tsx b/apps/web/components/workbench/Workbench.tsx
index 8a348ca..28d4b84 100644
--- a/apps/web/components/workbench/Workbench.tsx
+++ b/apps/web/components/workbench/Workbench.tsx
@@ -33,7 +33,12 @@ import {
WORKBENCH_CHAPTER_NO,
type ChapterEntry,
} from "@/lib/workbench/chapter";
-import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
+import {
+ composeDirective,
+ PLOT_PRESETS,
+ STYLE_PRESETS,
+ type Preset,
+} from "@/lib/workbench/directive";
import { applyRefinement } from "@/lib/workbench/refineApply";
import { buttonClass } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList";
@@ -381,11 +386,23 @@ function DirectivePanel({
+
+
-
- {STYLE_PRESETS.map((preset) => {
- const active = presetIds.includes(preset.id);
- return (
-
- );
- })}
-
{activeCount > 0 ? (
- 写本章时会把已选预设和自由指令合并进本次生成请求。
+ 写本章时会把已选文风/剧情预设和你的要求合并进本次生成请求。
) : null}
@@ -420,6 +420,39 @@ function DirectivePanel({
);
}
+interface PresetGroupProps {
+ label: string;
+ presets: readonly Preset[];
+ presetIds: readonly string[];
+ onToggle: (id: string) => void;
+}
+
+// 一组预设 chips(文风 / 剧情需求):点选 toggle,选中高亮。id 统一进 presetIds。
+function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
+ return (
+
+
{label}
+
+ {presets.map((preset) => {
+ const active = presetIds.includes(preset.id);
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
// 写章失败提示:友好文案(不暴露 raw code)+ 可选「去设置」动作(F4)。
function StreamErrorNote({
streamError,
diff --git a/apps/web/lib/workbench/directive.test.ts b/apps/web/lib/workbench/directive.test.ts
index 188c256..2fc05e2 100644
--- a/apps/web/lib/workbench/directive.test.ts
+++ b/apps/web/lib/workbench/directive.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { composeDirective, STYLE_PRESETS } from "./directive";
+import { composeDirective, PLOT_PRESETS, STYLE_PRESETS } from "./directive";
describe("composeDirective", () => {
it("returns empty string when nothing selected", () => {
@@ -23,4 +23,16 @@ describe("composeDirective", () => {
const lessAi = STYLE_PRESETS.find((p) => p.id === "less-ai")!.text;
expect(composeDirective("", ["nope", "less-ai"])).toBe(lessAi);
});
+
+ it("resolves 剧情需求 presets alongside 文风", () => {
+ const plot = PLOT_PRESETS[0]!;
+ expect(composeDirective("", [plot.id])).toBe(plot.text);
+ });
+
+ it("orders 文风 presets before 剧情 presets, then free text", () => {
+ const style = STYLE_PRESETS[0]!;
+ const plot = PLOT_PRESETS[0]!;
+ const result = composeDirective("再收紧节奏", [plot.id, style.id]);
+ expect(result).toBe(`${style.text};${plot.text};再收紧节奏`);
+ });
});
diff --git a/apps/web/lib/workbench/directive.ts b/apps/web/lib/workbench/directive.ts
index 2f70f65..82da0c2 100644
--- a/apps/web/lib/workbench/directive.ts
+++ b/apps/web/lib/workbench/directive.ts
@@ -1,33 +1,47 @@
-// T4-b · 本章指令组装(纯逻辑,UX §3)。
-// 作者自由文本 + 风格快捷预设 → 单条 directive 串,传入 draft 生成(直通后端 volatile)。
+// 本章 AI写作三槽(纯逻辑,UX §3;写作工作台重构 Phase 2)。
+// 三槽 = 文风预设 + 剧情需求预设 + 自定义要求(自由文本)→ 单条 directive 串,
+// 直通后端 volatile(MVP 纯前端、零契约、不动 write_craft.md 字节 → 金标准零回归)。
// 组件只渲染 chips/textarea;预设清单与组装规则集中在此,确定性、不可变。
-export interface StylePreset {
+export interface Preset {
id: string;
label: string;
text: string;
}
-// 风格快捷预设(点选拼入指令开头,引导本章文风)。
-export const STYLE_PRESETS: readonly StylePreset[] = [
+// 文风预设(书级文风倾向;点选引导本章表达)。来源:现有 + style_extract 维度离散化。
+export const STYLE_PRESETS: readonly Preset[] = [
{ id: "less-ai", label: "减少AI味", text: "减少 AI 腔,去掉套话与排比堆砌" },
{ id: "colloquial", label: "口语化", text: "用更口语、贴近人物的表达" },
{ id: "fast-pace", label: "快节奏", text: "加快节奏,少铺垫多冲突推进" },
];
-const PRESET_BY_ID: ReadonlyMap = new Map(
- STYLE_PRESETS.map((preset) => [preset.id, preset]),
+// 剧情需求预设(章级桥段诉求;作者「卡文给方向」)。来源:plan §4.2 分类法
+// (tianyayu6/write-web-novels + oh-story-claudecode,均 MIT)。
+export const PLOT_PRESETS: readonly Preset[] = [
+ { id: "plot-faceslap", label: "打脸反转", text: "本章安排一次打脸/反转,先抑后扬给足爽感" },
+ { id: "plot-hook-end", label: "章末钩子", text: "章末留一个强钩子/悬念,驱动追读" },
+ { id: "plot-climax", label: "高潮爆发", text: "本章为高潮,冲突集中爆发、情绪拉满" },
+ { id: "plot-turn", label: "关键转折", text: "本章制造关键转折,改变人物处境或目标" },
+ { id: "plot-reveal", label: "扮猪吃虎", text: "先藏拙后亮实力,制造反差与压制感" },
+];
+
+// 全部预设(文风在前、剧情在后)——组装按此声明序,保证输出稳定可测。
+const ALL_PRESETS: readonly Preset[] = [...STYLE_PRESETS, ...PLOT_PRESETS];
+
+const PRESET_BY_ID: ReadonlyMap = new Map(
+ ALL_PRESETS.map((preset) => [preset.id, preset]),
);
const SEPARATOR = ";";
-// 组合选中预设文案(按 STYLE_PRESETS 原序)+ 自由文本为一条指令。空 → ""。
+// 组合选中预设文案(按 ALL_PRESETS 原序:文风→剧情)+ 自定义自由文本为一条指令。空 → ""。
export function composeDirective(
freeText: string,
presetIds: readonly string[],
): string {
const selected = new Set(presetIds);
- const presetTexts = STYLE_PRESETS.filter((preset) =>
+ const presetTexts = ALL_PRESETS.filter((preset) =>
selected.has(preset.id),
).map((preset) => preset.text);
const trimmedFree = freeText.trim();