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:
@@ -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="润色、整章重写、续写、工具箱生成的往复会自动留痕,重开也不丢。"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user