feat(frontend): AI写作三槽——文风 / 剧情需求 / 自定义要求,写章前按需组合
Phase 2(写作工作台重构):把写章指令升级为三槽——文风预设 + 新增剧情需求预设 (打脸反转/章末钩子/高潮/转折/扮猪吃虎,源自 plan §4.2 分类法,tianyayu6/oh-story MIT) + 自定义自由文本。三者经 composeDirective 合并为单条 directive→volatile(MVP 纯前端、 零契约、不动 write_craft.md 字节,金标准零回归)。DirectivePanel 拆成文风/剧情两组 chips。
This commit is contained in:
@@ -33,7 +33,12 @@ import {
|
|||||||
WORKBENCH_CHAPTER_NO,
|
WORKBENCH_CHAPTER_NO,
|
||||||
type ChapterEntry,
|
type ChapterEntry,
|
||||||
} from "@/lib/workbench/chapter";
|
} 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 { applyRefinement } from "@/lib/workbench/refineApply";
|
||||||
import { buttonClass } from "@/lib/ui/variants";
|
import { buttonClass } from "@/lib/ui/variants";
|
||||||
import { ChapterList, ChapterListContent } from "./ChapterList";
|
import { ChapterList, ChapterListContent } from "./ChapterList";
|
||||||
@@ -381,11 +386,23 @@ function DirectivePanel({
|
|||||||
</summary>
|
</summary>
|
||||||
<div id={panelId} className="mt-3 space-y-3">
|
<div id={panelId} className="mt-3 space-y-3">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="写作指令"
|
title="AI 写作诉求"
|
||||||
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
|
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
|
||||||
|
/>
|
||||||
|
<PresetGroup
|
||||||
|
label="文风"
|
||||||
|
presets={STYLE_PRESETS}
|
||||||
|
presetIds={presetIds}
|
||||||
|
onToggle={onTogglePreset}
|
||||||
|
/>
|
||||||
|
<PresetGroup
|
||||||
|
label="剧情需求"
|
||||||
|
presets={PLOT_PRESETS}
|
||||||
|
presetIds={presetIds}
|
||||||
|
onToggle={onTogglePreset}
|
||||||
/>
|
/>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="sr-only">本章指令</span>
|
<span className="mb-1 block text-sm text-ink-soft">自己的要求</span>
|
||||||
<TextArea
|
<TextArea
|
||||||
value={directive}
|
value={directive}
|
||||||
onChange={(e) => onDirectiveChange(e.target.value)}
|
onChange={(e) => onDirectiveChange(e.target.value)}
|
||||||
@@ -393,26 +410,9 @@ function DirectivePanel({
|
|||||||
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
|
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
|
|
||||||
{STYLE_PRESETS.map((preset) => {
|
|
||||||
const active = presetIds.includes(preset.id);
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={preset.id}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={active}
|
|
||||||
onClick={() => onTogglePreset(preset.id)}
|
|
||||||
variant={active ? "outline" : "secondary"}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{activeCount > 0 ? (
|
{activeCount > 0 ? (
|
||||||
<StatusNote variant="info">
|
<StatusNote variant="info">
|
||||||
写本章时会把已选预设和自由指令合并进本次生成请求。
|
写本章时会把已选文风/剧情预设和你的要求合并进本次生成请求。
|
||||||
</StatusNote>
|
</StatusNote>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-sm text-ink-soft">{label}</p>
|
||||||
|
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
|
||||||
|
{presets.map((preset) => {
|
||||||
|
const active = presetIds.includes(preset.id);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={preset.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onToggle(preset.id)}
|
||||||
|
variant={active ? "outline" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 写章失败提示:友好文案(不暴露 raw code)+ 可选「去设置」动作(F4)。
|
// 写章失败提示:友好文案(不暴露 raw code)+ 可选「去设置」动作(F4)。
|
||||||
function StreamErrorNote({
|
function StreamErrorNote({
|
||||||
streamError,
|
streamError,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { composeDirective, STYLE_PRESETS } from "./directive";
|
import { composeDirective, PLOT_PRESETS, STYLE_PRESETS } from "./directive";
|
||||||
|
|
||||||
describe("composeDirective", () => {
|
describe("composeDirective", () => {
|
||||||
it("returns empty string when nothing selected", () => {
|
it("returns empty string when nothing selected", () => {
|
||||||
@@ -23,4 +23,16 @@ describe("composeDirective", () => {
|
|||||||
const lessAi = STYLE_PRESETS.find((p) => p.id === "less-ai")!.text;
|
const lessAi = STYLE_PRESETS.find((p) => p.id === "less-ai")!.text;
|
||||||
expect(composeDirective("", ["nope", "less-ai"])).toBe(lessAi);
|
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};再收紧节奏`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,47 @@
|
|||||||
// T4-b · 本章指令组装(纯逻辑,UX §3)。
|
// 本章 AI写作三槽(纯逻辑,UX §3;写作工作台重构 Phase 2)。
|
||||||
// 作者自由文本 + 风格快捷预设 → 单条 directive 串,传入 draft 生成(直通后端 volatile)。
|
// 三槽 = 文风预设 + 剧情需求预设 + 自定义要求(自由文本)→ 单条 directive 串,
|
||||||
|
// 直通后端 volatile(MVP 纯前端、零契约、不动 write_craft.md 字节 → 金标准零回归)。
|
||||||
// 组件只渲染 chips/textarea;预设清单与组装规则集中在此,确定性、不可变。
|
// 组件只渲染 chips/textarea;预设清单与组装规则集中在此,确定性、不可变。
|
||||||
|
|
||||||
export interface StylePreset {
|
export interface Preset {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 风格快捷预设(点选拼入指令开头,引导本章文风)。
|
// 文风预设(书级文风倾向;点选引导本章表达)。来源:现有 + style_extract 维度离散化。
|
||||||
export const STYLE_PRESETS: readonly StylePreset[] = [
|
export const STYLE_PRESETS: readonly Preset[] = [
|
||||||
{ id: "less-ai", label: "减少AI味", text: "减少 AI 腔,去掉套话与排比堆砌" },
|
{ id: "less-ai", label: "减少AI味", text: "减少 AI 腔,去掉套话与排比堆砌" },
|
||||||
{ id: "colloquial", label: "口语化", text: "用更口语、贴近人物的表达" },
|
{ id: "colloquial", label: "口语化", text: "用更口语、贴近人物的表达" },
|
||||||
{ id: "fast-pace", label: "快节奏", text: "加快节奏,少铺垫多冲突推进" },
|
{ id: "fast-pace", label: "快节奏", text: "加快节奏,少铺垫多冲突推进" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRESET_BY_ID: ReadonlyMap<string, StylePreset> = new Map(
|
// 剧情需求预设(章级桥段诉求;作者「卡文给方向」)。来源:plan §4.2 分类法
|
||||||
STYLE_PRESETS.map((preset) => [preset.id, preset]),
|
// (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<string, Preset> = new Map(
|
||||||
|
ALL_PRESETS.map((preset) => [preset.id, preset]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const SEPARATOR = ";";
|
const SEPARATOR = ";";
|
||||||
|
|
||||||
// 组合选中预设文案(按 STYLE_PRESETS 原序)+ 自由文本为一条指令。空 → ""。
|
// 组合选中预设文案(按 ALL_PRESETS 原序:文风→剧情)+ 自定义自由文本为一条指令。空 → ""。
|
||||||
export function composeDirective(
|
export function composeDirective(
|
||||||
freeText: string,
|
freeText: string,
|
||||||
presetIds: readonly string[],
|
presetIds: readonly string[],
|
||||||
): string {
|
): string {
|
||||||
const selected = new Set(presetIds);
|
const selected = new Set(presetIds);
|
||||||
const presetTexts = STYLE_PRESETS.filter((preset) =>
|
const presetTexts = ALL_PRESETS.filter((preset) =>
|
||||||
selected.has(preset.id),
|
selected.has(preset.id),
|
||||||
).map((preset) => preset.text);
|
).map((preset) => preset.text);
|
||||||
const trimmedFree = freeText.trim();
|
const trimmedFree = freeText.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user