feat(frontend): 编辑器内「润色 / 再沟通」——选段回炉 + 追加意见迭代出版本栈,接受回填

Phase 1(写作工作台重构):编辑器选中一段即可「润色选段」,复用同步 /refine 回炉
(只读、不写库,守不变量 #3);不满意可「再沟通」——以上一版产出为输入 + 作者新意见
再改一版,形成版本栈(useRefine,已单测)。接受回填按内容重锚落回草稿,原文已变则
提示重选、绝不盲替换(applyRefinement,已单测)。Editor 上报选区,结果以内联卡片展示。
This commit is contained in:
Yaojia Wang
2026-07-07 18:26:47 +02:00
parent 8389572cbb
commit 2f0b84191a
7 changed files with 516 additions and 2 deletions

View File

@@ -4,17 +4,37 @@ import { useEffect, useRef } from "react";
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
export interface EditorSelection {
start: number;
end: number;
text: string;
}
interface EditorProps {
value: string;
onChange: (value: string) => void;
streaming: boolean;
// 选区变化上报(供「润色选段」等选区级 AI 动作取材。空选区也会上报start===end
onSelectionChange?: (selection: EditorSelection) => void;
}
// 中栏正文编辑器宋体、720px 居中、行高 1.9UX §2.3 / §6.3)。
// 流式中显示朱砂光标提示prefers-reduced-motion 下不闪烁,见 globals.css
export function Editor({ value, onChange, streaming }: EditorProps) {
export function Editor({
value,
onChange,
streaming,
onSelectionChange,
}: EditorProps) {
const ref = useRef<HTMLTextAreaElement>(null);
const reportSelection = (el: HTMLTextAreaElement): void => {
if (!onSelectionChange) return;
const start = el.selectionStart;
const end = el.selectionEnd;
onSelectionChange({ start, end, text: value.slice(start, end) });
};
// 自适应高度textarea 高度=内容高度overflow-hidden 不内部滚动),由外层写作区
// 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。
// min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。
@@ -35,6 +55,7 @@ export function Editor({ value, onChange, streaming }: EditorProps) {
id="chapter-editor"
value={value}
onChange={(e) => onChange(e.target.value)}
onSelect={(e) => reportSelection(e.currentTarget)}
readOnly={streaming}
aria-busy={streaming}
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"

View File

@@ -0,0 +1,133 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, MessageSquarePlus, RotateCcw, X } from "lucide-react";
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 { useRefine } from "@/lib/workbench/useRefine";
interface RefinePanelProps {
projectId: string;
chapterNo: number;
// 选中原文;打开即对其回炉一次。
original: string;
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。
onAccept: (refined: string) => void;
onClose: () => void;
}
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版,
// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3accept 才动草稿。
export function RefinePanel({
projectId,
chapterNo,
original,
onAccept,
onClose,
}: RefinePanelProps) {
const { status, versions, latest, refine, recommunicate } = useRefine();
const [instruction, setInstruction] = useState("");
const ranRef = useRef(false);
// 打开即对选段回炉一次(仅一次,避免重复请求)。
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
void refine(projectId, chapterNo, original);
}, [projectId, chapterNo, original, refine]);
const busy = status === "refining";
const onRecommunicate = (): void => {
const trimmed = instruction.trim();
if (!trimmed || busy) return;
setInstruction("");
void recommunicate(projectId, chapterNo, trimmed);
};
return (
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-3">
<SectionHeader
title="润色 / 再沟通"
description="对选中段回炉;不满意可追加意见让 AI 再改一版,满意后接受回填。"
/>
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭润色">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<figure className="rounded border border-line bg-bg p-3">
<figcaption className="mb-1 text-2xs text-ink-soft"></figcaption>
<p className="whitespace-pre-wrap text-sm text-ink-soft">{original}</p>
</figure>
<figure className="rounded border border-cinnabar/40 bg-bg p-3">
<figcaption className="mb-1 flex items-center gap-2 text-2xs text-ink-soft">
{versions.length > 0 ? (
<span className="rounded bg-panel px-1.5 py-0.5 tabular-nums">
{versions.length}
</span>
) : null}
</figcaption>
{busy && !latest ? (
<ThinkingIndicator label="润色中" className="text-xs text-cinnabar" />
) : latest ? (
<p className="whitespace-pre-wrap text-sm text-ink">{latest.refined}</p>
) : status === "error" ? (
<StatusNote variant="danger" className="text-xs" title="回炉失败">
<button
type="button"
onClick={() => void refine(projectId, chapterNo, original)}
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : null}
</figure>
</div>
<div className="mt-3 space-y-2">
<label className="block">
<span className="sr-only"></span>
<TextArea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
rows={2}
placeholder="再沟通:想怎么改?(如「再收紧节奏」「保留这句比喻」)"
/>
</label>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={onRecommunicate}
disabled={busy || instruction.trim().length === 0}
variant="secondary"
size="sm"
>
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => latest && onAccept(latest.refined)}
disabled={busy || !latest}
variant="primary"
size="sm"
>
<Check className="h-4 w-4" aria-hidden="true" />
</Button>
{busy ? (
<ThinkingIndicator label="生成中" className="text-xs text-cinnabar" />
) : null}
</div>
</div>
</div>
);
}

View File

@@ -12,11 +12,13 @@ import {
PanelRight,
PenLine,
Square,
Wand2,
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { useToast } from "@/components/Toast";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
@@ -30,10 +32,12 @@ import {
type ChapterEntry,
} from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
import { applyRefinement } from "@/lib/workbench/refineApply";
import { buttonClass } from "@/lib/ui/variants";
import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor";
import { Editor, type EditorSelection } from "./Editor";
import { RefinePanel } from "./RefinePanel";
interface WorkbenchProps {
project: ProjectResponse;
@@ -64,9 +68,14 @@ export function Workbench({
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState("");
const [presetIds, setPresetIds] = useState<string[]>([]);
// 选区级 AI 动作(润色/再沟通):编辑器上报的最新选区 + 结果卡开合。
const [selection, setSelection] = useState<EditorSelection | null>(null);
const [refineOpen, setRefineOpen] = useState(false);
const toast = useToast();
const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream();
const lastStreamText = useRef("");
const canRefine = selection !== null && selection.text.trim().length > 0;
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
@@ -104,6 +113,24 @@ export function Workbench({
autosave.onChange(value);
};
// 接受润色/再沟通产出:按内容重锚把选段替换为产出并落草稿;原文已变则提示重选(不盲替换)。
const onAcceptRefine = (refined: string): void => {
if (selection) {
const next = applyRefinement(text, selection.text, refined, {
start: selection.start,
end: selection.end,
});
if (next === null) {
toast("正文已改动,找不到原选段,请重新选择后再润色。", "error");
} else {
setText(next);
autosave.onChange(next);
}
}
setRefineOpen(false);
setSelection(null);
};
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
const wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null);
@@ -153,11 +180,21 @@ export function Workbench({
presetIds={presetIds}
onTogglePreset={togglePreset}
/>
{refineOpen && canRefine && selection ? (
<RefinePanel
projectId={project.id}
chapterNo={chapterNo}
original={selection.text}
onAccept={onAcceptRefine}
onClose={() => setRefineOpen(false)}
/>
) : null}
<div className="flex-1 overflow-auto px-6 py-8">
<Editor
value={text}
onChange={onEditorChange}
streaming={stream.isStreaming}
onSelectionChange={setSelection}
/>
</div>
<Toolbar
@@ -171,6 +208,8 @@ export function Workbench({
liveMessage={liveMessage}
onWrite={onWrite}
onStop={stream.stop}
canRefine={canRefine}
onRefineSelection={() => setRefineOpen(true)}
/>
</section>
@@ -358,6 +397,9 @@ interface ToolbarProps {
liveMessage: string;
onWrite: () => void;
onStop: () => void;
// 选区级润色:有非空选区时可用;点击打开润色/再沟通结果卡。
canRefine: boolean;
onRefineSelection: () => void;
}
function Toolbar({
@@ -371,6 +413,8 @@ function Toolbar({
liveMessage,
onWrite,
onStop,
canRefine,
onRefineSelection,
}: 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">
@@ -388,6 +432,18 @@ function Toolbar({
</Button>
)}
{!streaming ? (
<Button
onClick={onRefineSelection}
disabled={!canRefine}
variant="secondary"
size="sm"
title={canRefine ? undefined : "先在正文里选中一段再润色"}
>
<Wand2 className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
{streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」
// 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。