feat(frontend): 正文上方常驻 AI 输入条 + 对话记录正名 + 润色提示 + HITL 微文案

回答用户原话「我怎么和 AI 对话」(docs/design/ux-exploration.md §4 P0-1/2/4/5)。

P0-2 把折叠的「本章写作指令[可选]」原地提升为正文正上方常驻输入条:标题
「告诉 AI 这章想怎么写」、去「可选」徽标、空章默认展开、「AI 写作诉求」→
「你的写作要求」、邀请式 placeholder;发送键即「写本章」(streaming 时切
「停」),复用 composeDirective + 文风/剧情 chips + 流式打字机,不改成聊天气泡。
P0-1 底栏「AI 对话」→「对话记录」(只读日志正名),抽屉标题/aria/空态同改。
P0-4「润色选段」常驻可点,无选区点击弹 toast 提示(去 hover-only title)。
P0-5 输入条旁常驻 HITL 微文案 +「?这是什么」可再发现弹层(非一次性 coach)。
This commit is contained in:
Yaojia Wang
2026-07-10 17:43:59 +02:00
parent 61a6931bb1
commit ceae24740a
3 changed files with 173 additions and 61 deletions

View File

@@ -41,7 +41,7 @@ interface AiConversationDrawerProps {
triggerRef?: RefObject<HTMLElement | null>;
}
// 每章「AI 对话」抽屉(对话气泡视图):本章对话 + 项目级生成两段,作者右 / AI 左kind 徽标 + 线程归组。
// 每章「对话记录」抽屉(只读往复日志,气泡视图):本章对话 + 项目级生成两段,作者右 / AI 左kind 徽标 + 线程归组。
// 结构克隆 ContextDrawer右侧 slide-over + overlayScrim + role=dialog + Esc + focus trap + scroll lock + 还原焦点。
// append-only 只读展示;对 ai 产出可「从历史再接受」——只回填编辑器草稿,不绕开验收事务(守不变量 #3
export function AiConversationDrawer({
@@ -103,7 +103,7 @@ export function AiConversationDrawer({
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="AI 对话"
aria-label="对话记录"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (panelRef.current) handleTabTrap(panelRef.current, e);
@@ -116,12 +116,12 @@ export function AiConversationDrawer({
className="h-4 w-4 text-cinnabar"
aria-hidden="true"
/>
AI
</span>
<Button
ref={closeRef}
onClick={onClose}
aria-label="关闭 AI 对话"
aria-label="关闭对话记录"
variant="ghost"
size="icon"
>
@@ -190,7 +190,7 @@ function DrawerBody({
return (
<EmptyState
icon={MessagesSquare}
title="还没有 AI 对话"
title="这里留存你和 AI 的往复记录"
description="润色、整章重写、续写、工具箱生成的往复会自动留痕,重开也不丢。"
/>
);

View File

@@ -8,6 +8,7 @@ import {
ChevronDown,
ClipboardCheck,
FileText,
HelpCircle,
Library,
MessagesSquare,
PanelLeft,
@@ -43,7 +44,7 @@ import {
type Preset,
} from "@/lib/workbench/directive";
import { applyRefinement } from "@/lib/workbench/refineApply";
import { buttonClass } from "@/lib/ui/variants";
import { buttonClass, focusRing } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor, type EditorSelection } from "./Editor";
@@ -185,7 +186,12 @@ export function Workbench({
};
// 四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。
// 润色以选区为对象无选区时不静默失败给出可见提示引导先选段P0-4替代 hover-only title
const openRefine = (): void => {
if (!canRefine) {
toast("先在正文里选中一段再润色", "info");
return;
}
setContinueOpen(false);
setToolboxOpen(false);
setRewriteOpen(false);
@@ -322,6 +328,10 @@ export function Workbench({
onDirectiveChange={setDirective}
presetIds={presetIds}
onTogglePreset={togglePreset}
defaultOpen={initialText.trim().length === 0}
streaming={stream.isStreaming}
onWrite={onWrite}
onStop={stream.stop}
/>
{refineOpen && canRefine && selection ? (
<RefinePanel
@@ -390,7 +400,6 @@ export function Workbench({
liveMessage={liveMessage}
onWrite={onWrite}
onStop={stream.stop}
canRefine={canRefine}
onRefineSelection={openRefine}
onContinue={openContinue}
onToolbox={openToolbox}
@@ -501,68 +510,174 @@ interface DirectivePanelProps {
onDirectiveChange: (value: string) => void;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
// 空章默认展开(新手最需要看到输入框的时机);满章默认收起以让出编辑视野。
defaultOpen: boolean;
// 发送键 = 写本章:把已选文风/剧情 + 自定义要求组装后逐字流式写进正文(不改成聊天气泡)。
streaming: boolean;
onWrite: () => void;
onStop: () => void;
}
// 本章指令面板(T4-b可折叠的 textarea + 风格快捷预设 chips
// 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成
// 本章写作指令面板(P0-2 提升):正文正上方常驻的「和 AI 说话」输入条
// 标题「告诉 AI 这章想怎么写」+ HITL 微文案 +「?这是什么」常驻可见;空章默认展开露出输入框
// 发送键即「写本章」,沿用 composeDirective + 文风/剧情 chips + 流式打字机写进正文。
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 (
<details className="group border-b border-line bg-panel px-4 py-3 sm:px-6">
<summary
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
aria-controls={panelId}
>
<span className="flex items-center gap-2 font-serif text-base text-ink">
<section className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={`flex min-w-0 cursor-pointer items-center gap-2 rounded font-serif text-base text-ink hover:text-cinnabar ${focusRing}`}
>
<ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180"
className={`h-4 w-4 shrink-0 text-ink-soft transition-transform ${
open ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</span>
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
</span>
</summary>
<div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="AI 写作诉求"
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
/>
</label>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
<span className="truncate"> AI </span>
</button>
<div className="flex shrink-0 items-center gap-2">
{activeCount > 0 ? (
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount}
</span>
) : null}
<HitlHelp />
</div>
</div>
</details>
<p className="mt-1 text-xs text-ink-soft">
AI 稿
</p>
{open ? (
<div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="你的写作要求"
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="写点要求AI 更懂你(留空则按大纲写)"
/>
</label>
<div className="flex flex-wrap items-center gap-3">
{streaming ? (
<Button onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={onWrite} variant="primary" size="sm">
<PenLine className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
</div>
) : null}
</section>
);
}
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach——解释「和 AI 说话」的闭环。
// 仿 AiToolbarMoreMenu按钮 + 轻量弹层Esc / 点击外部关闭,还原焦点。
function HitlHelp() {
const [open, setOpen] = useState(false);
const popId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={popId}
onClick={() => setOpen((v) => !v)}
className={buttonClass({
variant: "ghost",
size: "sm",
className: "gap-1 text-xs text-ink-soft",
})}
>
<HelpCircle className="h-4 w-4" aria-hidden="true" />
</button>
{open ? (
<div
id={popId}
role="dialog"
aria-label="怎么和 AI 说话"
className="absolute right-0 z-30 mt-2 w-72 max-w-[80vw] rounded border border-line bg-panel p-3 text-sm text-ink-soft shadow-paper"
>
<p className="mb-1 font-serif text-sm text-ink"> AI </p>
<p>
AI 稿稿
稿
</p>
</div>
) : null}
</div>
);
}
@@ -636,8 +751,7 @@ interface ToolbarProps {
liveMessage: string;
onWrite: () => void;
onStop: () => void;
// 选区级润色:有非空选区时可用;点击打开润色/再沟通结果卡。
canRefine: boolean;
// 选区级润色:常驻可点无选区时点击给提示P0-4有选区打开润色/再沟通结果卡。
onRefineSelection: () => void;
// 续写:读本章前文续写候选。
onContinue: () => void;
@@ -664,7 +778,6 @@ function Toolbar({
liveMessage,
onWrite,
onStop,
canRefine,
onRefineSelection,
onContinue,
onToolbox,
@@ -698,10 +811,8 @@ function Toolbar({
</Button>
<Button
onClick={onRefineSelection}
disabled={!canRefine}
variant="secondary"
size="sm"
title={canRefine ? undefined : "先在正文里选中一段再润色"}
>
<Wand2 className="h-4 w-4" aria-hidden="true" />
@@ -755,10 +866,10 @@ function Toolbar({
onClick={onOpenConversation}
variant="secondary"
size="sm"
title="打开 AI 对话(润色/重写/续写/工具箱往复留痕)"
title="打开对话记录(润色/重写/续写/工具箱往复留痕的只读日志"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
AI
</Button>
) : null}
<Link