void;
+ presetIds: readonly string[];
+ onTogglePreset: (id: string) => void;
+ // 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
+ defaultOpen: boolean;
+ // 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
+ streaming: boolean;
+ onWrite: () => void;
+ onStop: () => void;
+}
+
+// 本章写作指令面板(P0-2 / P3-3 提升):正文正上方常驻的「和 AI 说话」输入条。
+// 眉题「和 AI 说话」+ 主标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;
+// 主 CTA「写本章」突出(md 尺寸、独占右侧),空章默认展开露出输入框。
+// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
+export function DirectivePanel({
+ directive,
+ onDirectiveChange,
+ presetIds,
+ onTogglePreset,
+ defaultOpen,
+ streaming,
+ onWrite,
+ onStop,
+}: DirectivePanelProps) {
+ const panelId = useId();
+ const [open, setOpen] = useState(defaultOpen);
+ const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0);
+ return (
+
+
+
+ 和 AI 说话
+
+
+
+ {activeCount > 0 ? (
+
+ {activeCount} 项指令
+
+ ) : null}
+ {/* 常驻主 CTA = 写本章:始终可见(折叠时也在),md 尺寸凸显为本条主动作,逐字流式写进正文。 */}
+ {streaming ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ AI 给草稿,你决定采不采用,原文永远你说了算。
+
+ {open ? (
+
+
+
+
+
+ {activeCount > 0 ? (
+
+ 写本章时会把已选文风/剧情预设和你的要求合并进本次生成请求。
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+interface AiVerbsRowProps {
+ streaming: boolean;
+ onContinue: () => void;
+ onRefineSelection: () => void;
+ onRewrite: () => void;
+ onToolbox: () => void;
+}
+
+// 「或让 AI:」次级动作行(P1-1 / P3-3):常驻贴在中央输入条下方,把续写/润色/整章重写/工具箱
+// 归为「同一个 AI 的其它用法」(act-on-chapter 与导航分家)。用较浅面色 + 更软分隔成组,
+// 与上方主指令条拉开层级。每个动词带可见范围提示(P1-4)。生成中禁用(避免与流式写章并发)。
+export function AiVerbsRow({
+ streaming,
+ onContinue,
+ onRefineSelection,
+ onRewrite,
+ onToolbox,
+}: AiVerbsRowProps) {
+ return (
+
+
+ 或让 AI
+
+
+
+
+
+
+ );
+}
+
+interface VerbButtonProps {
+ icon: typeof WrapText;
+ label: string;
+ // 可见(非 hover-only)范围提示:让作者点前就能预判对草稿的影响(P1-4)。工具箱无范围提示。
+ hint?: string;
+ disabled: boolean;
+ onClick: () => void;
+}
+
+function VerbButton({ icon: Icon, label, hint, disabled, onClick }: VerbButtonProps) {
+ return (
+
+ );
+}
+
+// 「?这是什么」:常驻、可再发现的说明(非一次性 coach)——解释「和 AI 说话」的闭环。
+// 按钮 + 轻量弹层,Esc / 点击外部关闭,还原焦点。
+function HitlHelp() {
+ const [open, setOpen] = useState(false);
+ const popId = useId();
+ const rootRef = useRef(null);
+ const buttonRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKeyDown = (e: KeyboardEvent): void => {
+ if (e.key === "Escape") {
+ setOpen(false);
+ buttonRef.current?.focus();
+ }
+ };
+ const onPointerDown = (e: PointerEvent): void => {
+ const target = e.target;
+ if (target instanceof Node && !rootRef.current?.contains(target)) {
+ setOpen(false);
+ }
+ };
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("pointerdown", onPointerDown);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("pointerdown", onPointerDown);
+ };
+ }, [open]);
+
+ return (
+
+
+ {open ? (
+
+
怎么和 AI 说话
+
+ 在这条写下要求,点「写本章」——AI 会把草稿逐字写进正文。草稿随时能改;
+ 满意了去「审稿」验收。原文永远你说了算。
+
+
+ ) : null}
+
+ );
+}
+
+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 (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/components/workbench/ChapterAssistant.tsx b/apps/web/components/workbench/ChapterAssistant.tsx
index 5f13162..c8359ca 100644
--- a/apps/web/components/workbench/ChapterAssistant.tsx
+++ b/apps/web/components/workbench/ChapterAssistant.tsx
@@ -1,10 +1,19 @@
"use client";
-import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
+import type { ReactNode } from "react";
+import {
+ Minus,
+ Pin,
+ PinOff,
+ Plus,
+ RotateCcw,
+ Sparkles,
+ Undo2,
+ X,
+} from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
-import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { buttonClass } from "@/lib/ui/variants";
import {
@@ -35,6 +44,25 @@ export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps
);
}
+// 窄栏小标题(P3-5):font-sans + eyebrow 字号/正字距(对齐 Eyebrow 眉题的视觉),
+// 但保留 语义(不降级为
,守住标题大纲 a11y)。可带右侧行内控件(如步进器)。
+function PanelLabel({
+ children,
+ action,
+}: {
+ children: ReactNode;
+ action?: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ {action ?
{action}
: null}
+
+ );
+}
+
export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
const injection = useInjection(projectId, chapterNo);
const data = injection.data;
@@ -43,21 +71,17 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
return (
-
- ) : null
- }
- />
+
+
+ 本章参考
+
+ {injection.saving ? (
+
+ ) : null}
+
-
-
+
) : null
}
- />
+ >
+ AI 写这章会参考
+
{injection.loading ? (
- 加载参考信息…
+ 加载参考信息…
) : !data ? (
// 首次加载失败:data 仍为空 → 满屏错误框 + 重试。
@@ -113,11 +139,15 @@ export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps
) : null}
{selected.length === 0 ? (
-
- 这一章 AI 还没有要参考的设定/角色。可在大纲里点名,或从下方「已排除」里恢复。
-
+ // 空态占位(不加标题,避免在 h3 子小节内注入 h2 破坏标题大纲)。
+
+
+
+ 这一章 AI 还没要参考的设定/角色。可在大纲里点名,或从下方「已排除」里恢复。
+
+
) : (
-
+
{selected.map((entity) => (
0 ? (
-
-
-
+
+
已排除(本章不参考)
+
{excluded.map((ref) => (
-
-
+
+ 写完后检查
+
+ 写完这章,AI 会把一致性、伏笔、文风、节奏各查一遍。到左栏或底栏的「审稿」逐条过。
+
);
diff --git a/apps/web/components/workbench/ChapterList.tsx b/apps/web/components/workbench/ChapterList.tsx
index 07653f7..1d534ed 100644
--- a/apps/web/components/workbench/ChapterList.tsx
+++ b/apps/web/components/workbench/ChapterList.tsx
@@ -29,7 +29,7 @@ export function ChapterList({
if (collapsed) {
return (