feat(frontend): 底栏瘦身 + 或让 AI 动作行 + 动词范围提示 + 题材去弹窗(UX P1-1/P1-4/P1-5)

This commit is contained in:
Yaojia Wang
2026-07-10 18:00:42 +02:00
parent 9f6cbb5598
commit e9c5502c2d
2 changed files with 111 additions and 100 deletions

View File

@@ -4,12 +4,10 @@ import Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookMarked,
BookOpen,
ChevronDown,
ClipboardCheck,
FileText,
HelpCircle,
Library,
MessagesSquare,
PanelLeft,
PanelRight,
@@ -98,9 +96,9 @@ export function Workbench({
const [toolboxOpen, setToolboxOpen] = useState(false);
// WFW-8整章「再沟通/重写」结果卡开合(意见 → 流式重写一版 → 版本栈 → 接受替换整章)。
const [rewriteOpen, setRewriteOpen] = useState(false);
// WFW-7首次写章的极轻 genre 采集门。项目无题材 → 写章前先点一个题材(仅临时并入本次指令,不落库)。
// WFW-7 / P1-5:首次写章的极轻 genre 采集门。项目无题材 → 正文上方常驻一条内联「题材」选择条
// (非弹窗),作者点一个题材再写;仅临时并入本次指令、不落库,避免无题材 slop且第一次发送不被打断。
const genreGate = useGenreGate(project.genre ?? null);
const [genrePromptOpen, setGenrePromptOpen] = useState(false);
// WFW-6 上下文速查抽屉加法式、flag 灰度;与三张 AI 结果卡互不干扰。
const [contextOpen, setContextOpen] = useState(false);
// AC-3 AI 对话抽屉Workbench 级单实例append 即使抽屉关着也能触发flag 灰度。
@@ -141,21 +139,15 @@ export function Workbench({
};
const onWrite = (): void => {
// 无题材且未点选 → 先弹采集卡,选后再写HITL不静默生成无题材 slop
// 无题材且未点选 → 引导先点上方常驻「题材」条再写HITL不静默生成无题材 slop
// 选择条一直可见,理想路径是发送前已选好,第一次「写本章」即顺滑流式、不被弹窗打断。
if (genreGate.needsGenre && genreGate.chosenGenre === null) {
setGenrePromptOpen(true);
toast("先在上方选个题材AI 写得更贴合、少套话。", "info");
return;
}
startWrite(genreGate.effectiveGenre);
};
// 采集卡点题材:记录本次题材并立刻开写。用 genre 直传绕开 setState 异步。
const onPickGenre = (genre: string): void => {
genreGate.setChosenGenre(genre);
setGenrePromptOpen(false);
startWrite(genre);
};
const togglePreset = (id: string): void => {
setPresetIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
@@ -252,7 +244,7 @@ export function Workbench({
const onAppendDraftFromHistory = (content: string): boolean => {
appendToDraft(content);
toast("已插入正文。", "success");
toast("已加到章末。", "success");
return true;
};
@@ -263,12 +255,12 @@ export function Workbench({
// 合成零区间强制 slice 校验失败 → 走 indexOf 按存档的原选段重锚;找不到则不盲替换。
const next = applyRefinement(text, segment, refined, { start: 0, end: 0 });
if (next === null) {
toast("正文已改动,找不到原选段,无法回填。", "error");
toast("正文已改动,找不到原选段,无法替换。", "error");
return false;
}
setText(next);
autosave.onChange(next);
toast("已回填选段。", "success");
toast("已替换这段。", "success");
return true;
};
@@ -315,12 +307,12 @@ export function Workbench({
chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef}
/>
{genrePromptOpen ? (
{genreGate.needsGenre ? (
// 项目无题材正文上方常驻内联题材条非弹窗发送前选好即顺滑写章P1-5
<GenrePicker
genres={GENRES}
value={genreGate.chosenGenre}
onPick={onPickGenre}
onDismiss={() => setGenrePromptOpen(false)}
onPick={genreGate.setChosenGenre}
/>
) : null}
<DirectivePanel
@@ -333,6 +325,13 @@ export function Workbench({
onWrite={onWrite}
onStop={stream.stop}
/>
<AiVerbsRow
streaming={stream.isStreaming}
onContinue={openContinue}
onRefineSelection={openRefine}
onRewrite={openRewrite}
onToolbox={openToolbox}
/>
{refineOpen && canRefine && selection ? (
<RefinePanel
projectId={project.id}
@@ -398,12 +397,7 @@ export function Workbench({
streaming={stream.isStreaming}
streamError={stream.state.error}
liveMessage={liveMessage}
onWrite={onWrite}
onStop={stream.stop}
onRefineSelection={openRefine}
onContinue={openContinue}
onToolbox={openToolbox}
onRewrite={openRewrite}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
onOpenConversation={() => setConversationOpen(true)}
@@ -432,7 +426,7 @@ export function Workbench({
open={mobilePanel === "assistant"}
onClose={closePanel}
side="right"
label="本章助手"
label="本章参考"
triggerRef={assistantTriggerRef}
>
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
@@ -558,6 +552,21 @@ function DirectivePanel({
{activeCount}
</span>
) : null}
{/* 常驻「发送键」= 写本章始终可见折叠时也在逐字流式写进正文。带可见范围提示P1-4。 */}
{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" />
<span className="text-2xs font-normal text-panel/75">
·
</span>
</Button>
)}
<HitlHelp />
</div>
</div>
@@ -591,19 +600,6 @@ function DirectivePanel({
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">
/
@@ -615,6 +611,77 @@ function DirectivePanel({
);
}
interface AiVerbsRowProps {
streaming: boolean;
onContinue: () => void;
onRefineSelection: () => void;
onRewrite: () => void;
onToolbox: () => void;
}
// 「或让 AI:」次级动作行P1-1常驻贴在中央输入条下方把续写/润色/整章重写/工具箱
// 归为「同一个 AI 的其它用法」act-on-chapter 与导航分家。每个动词带可见范围提示P1-4
// 生成中禁用(避免与流式写章并发),触发逻辑沿用 Workbench 既有 open* 回调(仅搬家、不改行为)。
function AiVerbsRow({
streaming,
onContinue,
onRefineSelection,
onRewrite,
onToolbox,
}: AiVerbsRowProps) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line bg-panel px-4 py-2 sm:px-6">
<span className="text-xs text-ink-soft"> AI</span>
<VerbButton
icon={WrapText}
label="续写"
hint="接着往下写"
disabled={streaming}
onClick={onContinue}
/>
<VerbButton
icon={Wand2}
label="润色选段"
hint="选中段·打磨"
disabled={streaming}
onClick={onRefineSelection}
/>
<VerbButton
icon={RefreshCw}
label="整章重写"
hint="整章·基于现有重写"
disabled={streaming}
onClick={onRewrite}
/>
<VerbButton
icon={Sparkles}
label="工具箱"
disabled={streaming}
onClick={onToolbox}
/>
</div>
);
}
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 (
<Button onClick={onClick} disabled={disabled} variant="secondary" size="sm">
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
{hint ? <span className="text-2xs text-ink-soft">{hint}</span> : null}
</Button>
);
}
// 「?这是什么」:常驻、可再发现的说明(非一次性 coach——解释「和 AI 说话」的闭环。
// 仿 AiToolbarMoreMenu按钮 + 轻量弹层Esc / 点击外部关闭,还原焦点。
function HitlHelp() {
@@ -739,6 +806,8 @@ function StreamErrorNote({
);
}
// 底栏瘦身P1-1act-on-chapter 动词与纯导航都已上移/移除,底栏只余
// 状态(字数/保存/播报)+ 流式时的「停」+ 唯一强调的「审稿」前进 CTA + 速查/对话记录抽屉入口。
interface ToolbarProps {
projectId: string;
chapterNo: number;
@@ -749,20 +818,12 @@ interface ToolbarProps {
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
onWrite: () => void;
// 流式时的「停」:底栏 sticky滚到长正文下方也够得着中央条会滚出视野
onStop: () => void;
// 选区级润色常驻可点无选区时点击给提示P0-4有选区打开润色/再沟通结果卡。
onRefineSelection: () => void;
// 续写:读本章前文续写候选。
onContinue: () => void;
// 工具箱:编辑器内联调用生成器,结果回填正文。
onToolbox: () => void;
// 整章再沟通/重写WFW-8
onRewrite: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
// 打开 AI 对话抽屉AC-3
// 打开对话记录抽屉AC-3
onOpenConversation: () => void;
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
}
@@ -776,12 +837,7 @@ function Toolbar({
streaming,
streamError,
liveMessage,
onWrite,
onStop,
onRefineSelection,
onContinue,
onToolbox,
onRewrite,
onOpenContext,
contextTriggerRef,
onOpenConversation,
@@ -797,35 +853,6 @@ function Toolbar({
<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>
)}
{!streaming ? (
<>
<Button onClick={onContinue} variant="secondary" size="sm">
<WrapText className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={onRefineSelection}
variant="secondary"
size="sm"
>
<Wand2 className="h-4 w-4" aria-hidden="true" />
</Button>
<Button onClick={onToolbox} variant="secondary" size="sm">
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
<Button onClick={onRewrite} variant="secondary" size="sm">
<RefreshCw className="h-4 w-4" aria-hidden="true" />
</Button>
</>
) : null}
{streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」
@@ -872,24 +899,7 @@ function Toolbar({
</Button>
) : null}
<Link
href={`/projects/${projectId}/outline`}
className={buttonClass({ variant: "secondary", size: "sm" })}
>
<BookOpen className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href={`/projects/${projectId}/foreshadow`}
className={buttonClass({
variant: "secondary",
size: "sm",
className: "hidden sm:inline-flex",
})}
>
<Library className="h-4 w-4" aria-hidden="true" />
</Link>
{/* 唯一强调的「审稿」= 写→审→验收的前进 CTA导航去重后仅此一处强调左栏另有入口。 */}
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm" })}