feat(frontend): 底栏瘦身 + 或让 AI 动作行 + 动词范围提示 + 题材去弹窗(UX P1-1/P1-4/P1-5)
This commit is contained in:
@@ -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-1):act-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" })}
|
||||
|
||||
Reference in New Issue
Block a user