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