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

@@ -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],
);
}