feat(frontend): 编辑器内「润色 / 再沟通」——选段回炉 + 追加意见迭代出版本栈,接受回填
Phase 1(写作工作台重构):编辑器选中一段即可「润色选段」,复用同步 /refine 回炉 (只读、不写库,守不变量 #3);不满意可「再沟通」——以上一版产出为输入 + 作者新意见 再改一版,形成版本栈(useRefine,已单测)。接受回填按内容重锚落回草稿,原文已变则 提示重选、绝不盲替换(applyRefinement,已单测)。Editor 上报选区,结果以内联卡片展示。
This commit is contained in:
@@ -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 打扰屏读。
|
||||
|
||||
Reference in New Issue
Block a user