Files
writer-work-flow/apps/web/components/workbench/Workbench.tsx

454 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookOpen,
ChevronDown,
ClipboardCheck,
FileText,
Library,
PanelLeft,
PanelRight,
PenLine,
Square,
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import type { ProjectResponse } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream";
import {
WORKBENCH_CHAPTER_NO,
type ChapterEntry,
} from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
import { buttonClass } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor";
interface WorkbenchProps {
project: ProjectResponse;
// 当前章号(来自写作路由 `?chapter=N`);缺省回退第 1 章。
chapterNo?: number;
// RSC 种入的已存草稿正文GET .../draft无草稿404→ ""(空编辑器)。
initialText?: string;
// 大纲章节目录GET .../outline无大纲 → 空数组(目录回退到仅当前章 + 去大纲提示)。
chapters?: ChapterEntry[];
}
// 移动端右栏(目录/助手)经抽屉触达;桌面用三栏栅格。
type MobilePanel = "chapters" | "assistant" | null;
// M1单章工作台。章节列表为占位无章节列表端点默认第 1 章。
// WORKBENCH_CHAPTER_NO 移到 lib/workbench/chapter.ts非客户端模块供 Server Component 安全导入。
export function Workbench({
project,
chapterNo = WORKBENCH_CHAPTER_NO,
initialText = "",
chapters = [],
}: WorkbenchProps) {
// 用已存草稿作初值;流式开始后由下方 effect 覆盖(流不会被初值反扑——
// stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。
const [text, setText] = useState(initialText);
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState("");
const [presetIds, setPresetIds] = useState<string[]>([]);
const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream();
const lastStreamText = useRef("");
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => {
const streamed = stream.state.text;
if (
(stream.state.phase === "streaming" ||
stream.state.phase === "done" ||
stream.state.phase === "aborted") &&
streamed !== lastStreamText.current
) {
lastStreamText.current = streamed;
setText(streamed);
autosave.onChange(streamed);
}
}, [stream.state.phase, stream.state.text, autosave]);
const onWrite = (): void => {
void stream.start(
project.id,
chapterNo,
composeDirective(directive, presetIds),
);
};
const togglePreset = (id: string): void => {
setPresetIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const onEditorChange = (value: string): void => {
setText(value);
autosave.onChange(value);
};
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
const wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null);
// 流式里程碑播报(仅终结句,非逐 token完成/停止/失败时写入 role=status 区。
const liveMessage = ((): string => {
switch (stream.state.phase) {
case "done":
return `本章生成完成,共 ${wordCount}`;
case "aborted":
return "已停止";
case "error":
return stream.state.error
? friendlyError(stream.state.error.code, stream.state.error.message)
.text
: "本章生成失败,请稍后重试。";
default:
return "";
}
})();
return (
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo}`}
projectId={project.id}
activeNav="write"
>
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] xl:grid-cols-[12rem_1fr_18rem]">
<ChapterList
projectId={project.id}
chapters={chapters}
currentChapterNo={chapterNo}
/>
<section className="flex min-w-0 flex-col bg-bg">
<MobileContextBar
chapterNo={chapterNo}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef}
/>
<DirectivePanel
directive={directive}
onDirectiveChange={setDirective}
presetIds={presetIds}
onTogglePreset={togglePreset}
/>
<div className="flex-1 overflow-auto px-6 py-8">
<Editor
value={text}
onChange={onEditorChange}
streaming={stream.isStreaming}
/>
</div>
<Toolbar
projectId={project.id}
chapterNo={chapterNo}
wordCount={wordCount}
savedLabel={autosave.savedLabel}
saveStatus={autosave.status}
streaming={stream.isStreaming}
streamError={stream.state.error}
liveMessage={liveMessage}
onWrite={onWrite}
onStop={stream.stop}
/>
</section>
<ChapterAssistant projectId={project.id} chapterNo={chapterNo} />
</div>
{/* 窄屏 / 中屏(10241280):目录 / 本章助手经抽屉触达≥xl 已在三栏栅格里直显。 */}
<Drawer
open={mobilePanel === "chapters"}
onClose={closePanel}
side="left"
label="目录"
triggerRef={chapterTriggerRef}
>
<ChapterListContent
projectId={project.id}
chapters={chapters}
currentChapterNo={chapterNo}
/>
</Drawer>
<Drawer
open={mobilePanel === "assistant"}
onClose={closePanel}
side="right"
label="本章助手"
triggerRef={assistantTriggerRef}
>
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer>
</AppShell>
);
}
interface MobileContextBarProps {
chapterNo: number;
onOpenChapters: () => void;
onOpenAssistant: () => void;
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
}
function MobileContextBar({
chapterNo,
onOpenChapters,
onOpenAssistant,
chapterTriggerRef,
assistantTriggerRef,
}: MobileContextBarProps) {
return (
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 xl:hidden">
<Button
ref={chapterTriggerRef}
onClick={onOpenChapters}
variant="secondary"
size="sm"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
{chapterNo}
</span>
<Button
ref={assistantTriggerRef}
onClick={onOpenAssistant}
variant="secondary"
size="sm"
>
<PanelRight className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
}
interface DirectivePanelProps {
directive: string;
onDirectiveChange: (value: string) => void;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
}
// 本章指令面板T4-b可折叠的 textarea + 风格快捷预设 chips。
// 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成。
function DirectivePanel({
directive,
onDirectiveChange,
presetIds,
onTogglePreset,
}: DirectivePanelProps) {
const panelId = useId();
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">
<ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-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="写作指令"
description="用于补充或覆盖本章大纲节拍,只影响本次生成。"
/>
<label className="block">
<span className="sr-only"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
/>
</label>
<div className="flex flex-wrap gap-2" role="group" aria-label="风格预设">
{STYLE_PRESETS.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onTogglePreset(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div>
{activeCount > 0 ? (
<StatusNote variant="info">
</StatusNote>
) : null}
</div>
</details>
);
}
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({
streamError,
}: {
streamError: { code: string; message: string };
}) {
const friendly = friendlyError(streamError.code, streamError.message);
return (
<p className="mb-2 text-sm text-conflict">
{friendly.text}
{friendly.actionHref ? (
<>
{" "}
<Link
href={friendly.actionHref}
className="underline hover:text-cinnabar"
>
{friendly.actionLabel}
</Link>
</>
) : null}
</p>
);
}
interface ToolbarProps {
projectId: string;
chapterNo: number;
wordCount: number;
savedLabel: string | null;
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
onWrite: () => void;
onStop: () => void;
}
function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
liveMessage,
onWrite,
onStop,
}: ToolbarProps) {
return (
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{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>
)}
{streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」
// 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。
<span className="inline-flex items-center gap-2">
<ThinkingIndicator label="生成中" className="text-xs text-cinnabar" />
<span
aria-hidden="true"
className="font-mono text-xs text-cinnabar"
>
{wordCount}
</span>
</span>
) : (
<span className="font-mono text-xs text-ink-soft">
{wordCount}
</span>
)}
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
<span className="sr-only" role="status" aria-live="polite">
{liveMessage}
</span>
<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>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm" })}
>
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
稿
</Link>
</div>
<span
role="status"
aria-live="polite"
className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto"
>
<FileText className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
{saveStatus === "saving"
? "保存中…"
: saveStatus === "error"
? "保存失败"
: (savedLabel ?? "尚未保存")}
</span>
</div>
</div>
);
}