feat(frontend): 编辑器内「润色 / 再沟通」——选段回炉 + 追加意见迭代出版本栈,接受回填
Phase 1(写作工作台重构):编辑器选中一段即可「润色选段」,复用同步 /refine 回炉 (只读、不写库,守不变量 #3);不满意可「再沟通」——以上一版产出为输入 + 作者新意见 再改一版,形成版本栈(useRefine,已单测)。接受回填按内容重锚落回草稿,原文已变则 提示重选、绝不盲替换(applyRefinement,已单测)。Editor 上报选区,结果以内联卡片展示。
This commit is contained in:
@@ -4,17 +4,37 @@ import { useEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
|
import { cn, focusRing, proseBody } from "@/lib/ui/variants";
|
||||||
|
|
||||||
|
export interface EditorSelection {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
|
// 选区变化上报(供「润色选段」等选区级 AI 动作取材)。空选区也会上报(start===end)。
|
||||||
|
onSelectionChange?: (selection: EditorSelection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 中栏正文编辑器:宋体、720px 居中、行高 1.9(UX §2.3 / §6.3)。
|
// 中栏正文编辑器:宋体、720px 居中、行高 1.9(UX §2.3 / §6.3)。
|
||||||
// 流式中显示朱砂光标提示(prefers-reduced-motion 下不闪烁,见 globals.css)。
|
// 流式中显示朱砂光标提示(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 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 不内部滚动),由外层写作区
|
// 自适应高度:textarea 高度=内容高度(overflow-hidden 不内部滚动),由外层写作区
|
||||||
// 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。
|
// 单一滚动条统管。修复正文出现嵌套滚动条 + 定高 60vh 不铺满展示区的怪象。
|
||||||
// min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。
|
// min-h-[60vh] 仍作空稿时的最小可写高度(内容更长时按内容增高、外层滚动)。
|
||||||
@@ -35,6 +55,7 @@ export function Editor({ value, onChange, streaming }: EditorProps) {
|
|||||||
id="chapter-editor"
|
id="chapter-editor"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onSelect={(e) => reportSelection(e.currentTarget)}
|
||||||
readOnly={streaming}
|
readOnly={streaming}
|
||||||
aria-busy={streaming}
|
aria-busy={streaming}
|
||||||
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"
|
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"
|
||||||
|
|||||||
133
apps/web/components/workbench/RefinePanel.tsx
Normal file
133
apps/web/components/workbench/RefinePanel.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版,
|
||||||
|
// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3),accept 才动草稿。
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,11 +12,13 @@ import {
|
|||||||
PanelRight,
|
PanelRight,
|
||||||
PenLine,
|
PenLine,
|
||||||
Square,
|
Square,
|
||||||
|
Wand2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { Drawer } from "@/components/Drawer";
|
import { Drawer } from "@/components/Drawer";
|
||||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { useToast } from "@/components/Toast";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
import { StatusNote } from "@/components/ui/StatusNote";
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
@@ -30,10 +32,12 @@ import {
|
|||||||
type ChapterEntry,
|
type ChapterEntry,
|
||||||
} from "@/lib/workbench/chapter";
|
} from "@/lib/workbench/chapter";
|
||||||
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
|
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
|
||||||
|
import { applyRefinement } from "@/lib/workbench/refineApply";
|
||||||
import { buttonClass } from "@/lib/ui/variants";
|
import { buttonClass } from "@/lib/ui/variants";
|
||||||
import { ChapterList, ChapterListContent } from "./ChapterList";
|
import { ChapterList, ChapterListContent } from "./ChapterList";
|
||||||
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
||||||
import { Editor } from "./Editor";
|
import { Editor, type EditorSelection } from "./Editor";
|
||||||
|
import { RefinePanel } from "./RefinePanel";
|
||||||
|
|
||||||
interface WorkbenchProps {
|
interface WorkbenchProps {
|
||||||
project: ProjectResponse;
|
project: ProjectResponse;
|
||||||
@@ -64,9 +68,14 @@ export function Workbench({
|
|||||||
// 本章指令(T4-b):自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
|
// 本章指令(T4-b):自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
|
||||||
const [directive, setDirective] = useState("");
|
const [directive, setDirective] = useState("");
|
||||||
const [presetIds, setPresetIds] = useState<string[]>([]);
|
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 autosave = useAutosave(project.id, chapterNo, initialText);
|
||||||
const stream = useDraftStream();
|
const stream = useDraftStream();
|
||||||
const lastStreamText = useRef("");
|
const lastStreamText = useRef("");
|
||||||
|
const canRefine = selection !== null && selection.text.trim().length > 0;
|
||||||
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
const chapterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -104,6 +113,24 @@ export function Workbench({
|
|||||||
autosave.onChange(value);
|
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 wordCount = text.replace(/\s+/g, "").length;
|
||||||
const closePanel = (): void => setMobilePanel(null);
|
const closePanel = (): void => setMobilePanel(null);
|
||||||
@@ -153,11 +180,21 @@ export function Workbench({
|
|||||||
presetIds={presetIds}
|
presetIds={presetIds}
|
||||||
onTogglePreset={togglePreset}
|
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">
|
<div className="flex-1 overflow-auto px-6 py-8">
|
||||||
<Editor
|
<Editor
|
||||||
value={text}
|
value={text}
|
||||||
onChange={onEditorChange}
|
onChange={onEditorChange}
|
||||||
streaming={stream.isStreaming}
|
streaming={stream.isStreaming}
|
||||||
|
onSelectionChange={setSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -171,6 +208,8 @@ export function Workbench({
|
|||||||
liveMessage={liveMessage}
|
liveMessage={liveMessage}
|
||||||
onWrite={onWrite}
|
onWrite={onWrite}
|
||||||
onStop={stream.stop}
|
onStop={stream.stop}
|
||||||
|
canRefine={canRefine}
|
||||||
|
onRefineSelection={() => setRefineOpen(true)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -358,6 +397,9 @@ interface ToolbarProps {
|
|||||||
liveMessage: string;
|
liveMessage: string;
|
||||||
onWrite: () => void;
|
onWrite: () => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
|
// 选区级润色:有非空选区时可用;点击打开润色/再沟通结果卡。
|
||||||
|
canRefine: boolean;
|
||||||
|
onRefineSelection: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toolbar({
|
function Toolbar({
|
||||||
@@ -371,6 +413,8 @@ function Toolbar({
|
|||||||
liveMessage,
|
liveMessage,
|
||||||
onWrite,
|
onWrite,
|
||||||
onStop,
|
onStop,
|
||||||
|
canRefine,
|
||||||
|
onRefineSelection,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
return (
|
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">
|
<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>
|
</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 ? (
|
{streaming ? (
|
||||||
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
|
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
|
||||||
// 易变字数 aria-hidden,仅供视觉,不逐 token 打扰屏读。
|
// 易变字数 aria-hidden,仅供视觉,不逐 token 打扰屏读。
|
||||||
|
|||||||
37
apps/web/lib/workbench/refineApply.test.ts
Normal file
37
apps/web/lib/workbench/refineApply.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { applyRefinement } from "./refineApply";
|
||||||
|
|
||||||
|
describe("applyRefinement", () => {
|
||||||
|
const full = "第一段。原文段落。第三段。";
|
||||||
|
const original = "原文段落。";
|
||||||
|
const refined = "润色后的段落。";
|
||||||
|
// "第一段。" = 4 字,原文段落在 [4, 4+len)
|
||||||
|
const range = { start: 4, end: 4 + original.length };
|
||||||
|
|
||||||
|
it("选区未漂移:按 range 原地替换", () => {
|
||||||
|
expect(applyRefinement(full, original, refined, range)).toBe(
|
||||||
|
"第一段。润色后的段落。第三段。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("选区漂移但原文唯一:按内容重锚替换", () => {
|
||||||
|
// range 指向错位(如流式追加导致偏移),但原文仍能唯一定位。
|
||||||
|
const drifted = { start: 0, end: 3 };
|
||||||
|
expect(applyRefinement(full, original, refined, drifted)).toBe(
|
||||||
|
"第一段。润色后的段落。第三段。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("原文已不存在(正文被大改):返回 null 让调用方提示重选", () => {
|
||||||
|
expect(applyRefinement("完全不同的正文", original, refined, range)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只替换首个匹配(重锚回退时保守取第一处)", () => {
|
||||||
|
const twice = "原文段落。中间。原文段落。";
|
||||||
|
const drifted = { start: 99, end: 100 };
|
||||||
|
expect(applyRefinement(twice, original, refined, drifted)).toBe(
|
||||||
|
"润色后的段落。中间。原文段落。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
apps/web/lib/workbench/refineApply.ts
Normal file
24
apps/web/lib/workbench/refineApply.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// 润色/再沟通 accept 回填:把选中原文替换为产出。选区偏移可能因异步/流式漂移,
|
||||||
|
// 故先按 range 校验,未命中则按原文内容重锚(indexOf)——原文已不存在则返回 null,
|
||||||
|
// 由调用方提示作者重新选择,绝不盲替换(plan §3.3 mustFix)。
|
||||||
|
|
||||||
|
export interface SelectionRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRefinement(
|
||||||
|
fullText: string,
|
||||||
|
original: string,
|
||||||
|
refined: string,
|
||||||
|
range: SelectionRange,
|
||||||
|
): string | null {
|
||||||
|
// 选区未漂移:range 处正好是原文 → 原地替换。
|
||||||
|
if (fullText.slice(range.start, range.end) === original) {
|
||||||
|
return fullText.slice(0, range.start) + refined + fullText.slice(range.end);
|
||||||
|
}
|
||||||
|
// 漂移:按内容重锚,保守取首个匹配。
|
||||||
|
const idx = fullText.indexOf(original);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
return fullText.slice(0, idx) + refined + fullText.slice(idx + original.length);
|
||||||
|
}
|
||||||
131
apps/web/lib/workbench/useRefine.test.ts
Normal file
131
apps/web/lib/workbench/useRefine.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { useRefine } from "./useRefine";
|
||||||
|
|
||||||
|
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||||||
|
const post = vi.fn();
|
||||||
|
const toast = vi.fn();
|
||||||
|
vi.mock("@/lib/api/client", () => ({
|
||||||
|
api: { POST: (...a: unknown[]) => post(...a) },
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
const REFINE_PATH = "/projects/{project_id}/chapters/{chapter_no}/refine";
|
||||||
|
|
||||||
|
describe("useRefine", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
post.mockReset();
|
||||||
|
toast.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("初始为 idle,无版本、无原文", () => {
|
||||||
|
const { result } = renderHook(() => useRefine());
|
||||||
|
expect(result.current.status).toBe("idle");
|
||||||
|
expect(result.current.versions).toEqual([]);
|
||||||
|
expect(result.current.latest).toBeNull();
|
||||||
|
expect(result.current.original).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("润色:调 /refine,压入一版,original=选中原文,status=ready", async () => {
|
||||||
|
post.mockResolvedValue({
|
||||||
|
data: { original: "原文段", refined: "润色后的段落" },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRefine());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refine("p1", 3, "原文段");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith(REFINE_PATH, {
|
||||||
|
params: { path: { project_id: "p1", chapter_no: 3 } },
|
||||||
|
body: { segment: "原文段" },
|
||||||
|
});
|
||||||
|
expect(result.current.status).toBe("ready");
|
||||||
|
expect(result.current.original).toBe("原文段");
|
||||||
|
expect(result.current.latest).toEqual({
|
||||||
|
segment: "原文段",
|
||||||
|
instruction: "",
|
||||||
|
refined: "润色后的段落",
|
||||||
|
});
|
||||||
|
expect(result.current.versions).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("润色带指令:instruction 透传后端", async () => {
|
||||||
|
post.mockResolvedValue({ data: { original: "s", refined: "r" }, error: null });
|
||||||
|
const { result } = renderHook(() => useRefine());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refine("p1", 1, "原文", "更口语一点");
|
||||||
|
});
|
||||||
|
expect(post).toHaveBeenCalledWith(REFINE_PATH, {
|
||||||
|
params: { path: { project_id: "p1", chapter_no: 1 } },
|
||||||
|
body: { segment: "原文", instruction: "更口语一点" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("再沟通:以上一版产出为输入 + 作者新意见,压入新版,original 不变", async () => {
|
||||||
|
post
|
||||||
|
.mockResolvedValueOnce({ data: { original: "原文", refined: "第一版" }, error: null })
|
||||||
|
.mockResolvedValueOnce({ data: { original: "第一版", refined: "第二版" }, error: null });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRefine());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refine("p1", 2, "原文");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.recommunicate("p1", 2, "再收紧节奏");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(post).toHaveBeenLastCalledWith(REFINE_PATH, {
|
||||||
|
params: { path: { project_id: "p1", chapter_no: 2 } },
|
||||||
|
body: { segment: "第一版", instruction: "再收紧节奏" },
|
||||||
|
});
|
||||||
|
expect(result.current.versions).toHaveLength(2);
|
||||||
|
expect(result.current.original).toBe("原文");
|
||||||
|
expect(result.current.latest?.refined).toBe("第二版");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无历史版本时再沟通为空操作(不打后端)", async () => {
|
||||||
|
const { result } = renderHook(() => useRefine());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.recommunicate("p1", 1, "改一下");
|
||||||
|
});
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("后端返回 error:status=error、弹错误 toast、版本栈不变", async () => {
|
||||||
|
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
|
||||||
|
const { result } = renderHook(() => useRefine());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refine("p1", 1, "原文");
|
||||||
|
});
|
||||||
|
expect(result.current.status).toBe("error");
|
||||||
|
expect(result.current.versions).toEqual([]);
|
||||||
|
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("请求抛异常:status=error、弹网络异常 toast", async () => {
|
||||||
|
post.mockRejectedValue(new Error("network down"));
|
||||||
|
const { result } = renderHook(() => useRefine());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refine("p1", 1, "原文");
|
||||||
|
});
|
||||||
|
expect(result.current.status).toBe("error");
|
||||||
|
expect(toast).toHaveBeenCalledWith("回炉请求异常,请检查网络。", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset 清回 idle 与空版本", async () => {
|
||||||
|
post.mockResolvedValue({ data: { original: "s", refined: "r" }, error: null });
|
||||||
|
const { result } = renderHook(() => useRefine());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refine("p1", 1, "s");
|
||||||
|
});
|
||||||
|
act(() => result.current.reset());
|
||||||
|
expect(result.current.status).toBe("idle");
|
||||||
|
expect(result.current.versions).toEqual([]);
|
||||||
|
expect(result.current.latest).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
112
apps/web/lib/workbench/useRefine.ts
Normal file
112
apps/web/lib/workbench/useRefine.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import { useToast } from "@/components/Toast";
|
||||||
|
|
||||||
|
// 编辑器内「润色 / 再沟通」核心(复用同步 /refine,只读、不写库——不变量 #3)。
|
||||||
|
// 润色:对选中段回炉一次。再沟通:以上一版产出为输入 + 作者新意见再回炉,形成版本栈,
|
||||||
|
// 作者可回看每一版、择一 accept 回填正文(accept/回填由组件负责,此处只管生成与版本历史)。
|
||||||
|
|
||||||
|
export interface RefineVersion {
|
||||||
|
// 本次回炉的输入段:首轮 = 选中原文;再沟通 = 上一版产出。
|
||||||
|
segment: string;
|
||||||
|
// 本次改写指令(再沟通时为作者的新意见);无则空串。
|
||||||
|
instruction: string;
|
||||||
|
refined: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RefineStatus = "idle" | "refining" | "ready" | "error";
|
||||||
|
|
||||||
|
export interface UseRefine {
|
||||||
|
status: RefineStatus;
|
||||||
|
versions: RefineVersion[];
|
||||||
|
// 版本栈顶(最新产出)。
|
||||||
|
latest: RefineVersion | null;
|
||||||
|
// 最初选中的原文(accept 回填的对比基线)。
|
||||||
|
original: string | null;
|
||||||
|
refine: (
|
||||||
|
projectId: string,
|
||||||
|
chapterNo: number,
|
||||||
|
segment: string,
|
||||||
|
instruction?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
// 以上一版产出为输入 + 作者新意见再回炉一次。无历史版本时为空操作。
|
||||||
|
recommunicate: (
|
||||||
|
projectId: string,
|
||||||
|
chapterNo: number,
|
||||||
|
instruction: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefine(): UseRefine {
|
||||||
|
const [status, setStatus] = useState<RefineStatus>("idle");
|
||||||
|
const [versions, setVersions] = useState<RefineVersion[]>([]);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const runRefine = useCallback(
|
||||||
|
async (
|
||||||
|
projectId: string,
|
||||||
|
chapterNo: number,
|
||||||
|
segment: string,
|
||||||
|
instruction: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
setStatus("refining");
|
||||||
|
try {
|
||||||
|
const trimmed = instruction.trim();
|
||||||
|
const { data, error } = await api.POST(
|
||||||
|
"/projects/{project_id}/chapters/{chapter_no}/refine",
|
||||||
|
{
|
||||||
|
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||||
|
body: trimmed ? { segment, instruction: trimmed } : { segment },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error || !data) {
|
||||||
|
setStatus("error");
|
||||||
|
toast("回炉失败,请重试。", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVersions((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ segment, instruction: trimmed, refined: data.refined },
|
||||||
|
]);
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
toast("回炉请求异常,请检查网络。", "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refine = useCallback<UseRefine["refine"]>(
|
||||||
|
(projectId, chapterNo, segment, instruction) =>
|
||||||
|
runRefine(projectId, chapterNo, segment, instruction ?? ""),
|
||||||
|
[runRefine],
|
||||||
|
);
|
||||||
|
|
||||||
|
const recommunicate = useCallback<UseRefine["recommunicate"]>(
|
||||||
|
(projectId, chapterNo, instruction) => {
|
||||||
|
const last = versions[versions.length - 1];
|
||||||
|
// 无历史版本 → 无从「再沟通」;空操作,不打后端。
|
||||||
|
if (!last) return Promise.resolve();
|
||||||
|
return runRefine(projectId, chapterNo, last.refined, instruction);
|
||||||
|
},
|
||||||
|
[runRefine, versions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback((): void => {
|
||||||
|
setStatus("idle");
|
||||||
|
setVersions([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const latest = versions.at(-1) ?? null;
|
||||||
|
const original = versions[0]?.segment ?? null;
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ status, versions, latest, original, refine, recommunicate, reset }),
|
||||||
|
[status, versions, latest, original, refine, recommunicate, reset],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user