- RefinePanel/ChapterRewritePanel:结束后 append [author(意见/答复), ai(产出)];
clarify buffer-until-concluded——澄清 Q&A(原意见→反问→答复)缓冲,随最终产出批
一起落库同 thread;纯放弃不记。refine ai 行 meta 存 version_no+原选段(每版重锚)。
- ContinuePanel:ai-only(续写无作者输入),每候选一条 ai 气泡 meta{candidate_index}。
- GeneratorRunner:成功后 append [author(字段摘要, meta{tool_key,input_fields}),
ai(渲染预览, meta{tool_key,output_kind})];空产物不记。lib/toolbox/aiSummary.ts 纯助手+单测。
- InlineToolbox→GeneratorRunner 透传 append+本章号;ToolboxPage 用 silent 直投项目级(chapter_no=null)。
- Workbench:挂 useAiConversation 单实例 + AiConversationDrawer + 底栏「AI 对话」入口(flag 灰度),
三个 DRAFT-only 再接受回调(替换整章/插入正文/回填选段),成功关抽屉+toast、漂移不盲替换。
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { Check, MessageSquarePlus, MessagesSquare, 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";
|
||
import { useClarify } from "@/lib/workbench/useClarify";
|
||
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
|
||
import type {
|
||
AiTurnInput,
|
||
AiTurnMessage,
|
||
} from "@/lib/workbench/useAiConversation";
|
||
import { ChoiceChips } from "./ChoiceChips";
|
||
|
||
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
|
||
const CLARIFY_MIN_CHARS = 10;
|
||
|
||
interface RefinePanelProps {
|
||
projectId: string;
|
||
chapterNo: number;
|
||
// 选中原文;打开即对其回炉一次。
|
||
original: string;
|
||
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。
|
||
onAccept: (refined: string) => void;
|
||
// 留痕:把本轮往复(含缓冲的 clarify Q&A)作为一批 append(fail-soft,不阻塞回炉)。
|
||
appendMessages: (turn: AiTurnInput) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版,
|
||
// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3),accept 才动草稿。
|
||
export function RefinePanel({
|
||
projectId,
|
||
chapterNo,
|
||
original,
|
||
onAccept,
|
||
appendMessages,
|
||
onClose,
|
||
}: RefinePanelProps) {
|
||
const { status, versions, latest, refine, recommunicate } = useRefine();
|
||
const clarify = useClarify();
|
||
const [instruction, setInstruction] = useState("");
|
||
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
|
||
const ranRef = useRef(false);
|
||
// 本面板一次会话 = 一个 thread;所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。
|
||
const threadId = useRef(crypto.randomUUID()).current;
|
||
const versionNoRef = useRef(0);
|
||
|
||
// 一版 ai 产出气泡:content=产出,meta 存版本号 + 原选段(每版都存,供任意版 re-accept 重锚)。
|
||
const aiRow = (refined: string): AiTurnMessage => {
|
||
versionNoRef.current += 1;
|
||
return {
|
||
role: "ai",
|
||
content: refined,
|
||
meta: { version_no: versionNoRef.current, segment: original },
|
||
};
|
||
};
|
||
|
||
// 打开即对选段回炉一次(仅一次,避免重复请求);成功后留痕 [author(原选段), ai(润色)]。
|
||
useEffect(() => {
|
||
if (ranRef.current) return;
|
||
ranRef.current = true;
|
||
void (async () => {
|
||
const v = await refine(projectId, chapterNo, original);
|
||
if (v) {
|
||
appendMessages({
|
||
threadId,
|
||
kind: "refine",
|
||
chapterNo,
|
||
messages: [{ role: "author", content: original }, aiRow(v.refined)],
|
||
});
|
||
}
|
||
})();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [projectId, chapterNo, original, refine]);
|
||
|
||
const busy = status === "refining";
|
||
const clarifying = clarify.status === "checking";
|
||
const firstQuestion = clarify.decision?.questions[0] ?? null;
|
||
const asking = clarify.status === "asking" && firstQuestion !== null;
|
||
|
||
// 折入澄清答案(或原意见)后回炉,结束后留痕;clarifyTurns 非空时把缓冲的 Q&A 一并落库。
|
||
const runRecommunicate = (
|
||
finalInstruction: string,
|
||
clarifyTurns: AiTurnMessage[] | null,
|
||
): void => {
|
||
clarify.reset();
|
||
setPendingInstruction(null);
|
||
setInstruction("");
|
||
void (async () => {
|
||
const v = await recommunicate(projectId, chapterNo, finalInstruction);
|
||
if (!v) return;
|
||
// clarify 分支:作者答复 IS 本轮 author 输入,不再另加 author(意见) 气泡;否则记 author(意见)。
|
||
const messages = clarifyTurns
|
||
? [...clarifyTurns, aiRow(v.refined)]
|
||
: [{ role: "author" as const, content: finalInstruction }, aiRow(v.refined)];
|
||
appendMessages({ threadId, kind: "refine", chapterNo, messages });
|
||
})();
|
||
};
|
||
|
||
// 「再改一版」:意见含糊/极短(或 force)先预检反问;否则直接回炉。
|
||
const onRecommunicate = async (force: boolean): Promise<void> => {
|
||
const trimmed = instruction.trim();
|
||
if (!trimmed || busy || clarifying) return;
|
||
if (force || needsClarifyGate(trimmed, CLARIFY_MIN_CHARS)) {
|
||
const segment = latest?.refined ?? original;
|
||
const vm = await clarify.check(projectId, chapterNo, segment, trimmed);
|
||
if (vm.needClarification && vm.questions.length > 0) {
|
||
setPendingInstruction(trimmed); // 记住原意见,等作者答完折进去
|
||
return;
|
||
}
|
||
}
|
||
runRecommunicate(trimmed, null);
|
||
};
|
||
|
||
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A(原意见→反问→答复)+ 折进意见 → 回炉。
|
||
const onClarifyAnswer = (answer: string): void => {
|
||
const q = clarify.decision?.questions[0];
|
||
const question = q?.question ?? "";
|
||
const base = pendingInstruction ?? instruction.trim();
|
||
// 缓冲三条:原含糊意见(author) → AI 反问(ai, 选项进 meta) → 作者答复(author),随本轮产出一起落库。
|
||
const clarifyTurns: AiTurnMessage[] = [
|
||
{ role: "author", content: base },
|
||
{
|
||
role: "ai",
|
||
content: question,
|
||
meta: {
|
||
parent_kind: "refine",
|
||
options: (q?.options ?? []).map((o) => ({
|
||
label: o.label,
|
||
value: o.value,
|
||
})),
|
||
allow_free_text: q?.allowFreeText ?? false,
|
||
verification: clarify.decision?.verification ?? "",
|
||
},
|
||
},
|
||
{ role: "author", content: answer },
|
||
];
|
||
runRecommunicate(foldClarifications(base, [{ question, answer }]), clarifyTurns);
|
||
};
|
||
|
||
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="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
|
||
/>
|
||
</label>
|
||
{asking && firstQuestion ? (
|
||
// role="status":AI 反问动态出现时向读屏播报(WCAG 4.1.3 Status Messages)。
|
||
<div role="status" className="rounded border border-cinnabar/40 bg-bg p-3">
|
||
<p className="mb-2 text-2xs text-ink-soft">AI 想先跟你确认一下:</p>
|
||
<ChoiceChips
|
||
question={firstQuestion.question}
|
||
options={firstQuestion.options}
|
||
allowFreeText={firstQuestion.allowFreeText}
|
||
onPick={onClarifyAnswer}
|
||
onFreeText={onClarifyAnswer}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
onClick={() => void onRecommunicate(false)}
|
||
disabled={busy || clarifying || instruction.trim().length === 0}
|
||
variant="secondary"
|
||
size="sm"
|
||
>
|
||
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
|
||
再改一版
|
||
</Button>
|
||
<Button
|
||
onClick={() => void onRecommunicate(true)}
|
||
disabled={busy || clarifying || instruction.trim().length === 0}
|
||
variant="ghost"
|
||
size="sm"
|
||
title="让 AI 先反问、给方向选项再改"
|
||
>
|
||
<MessagesSquare 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 || clarifying ? (
|
||
<ThinkingIndicator
|
||
label={clarifying ? "琢磨要不要反问" : "生成中"}
|
||
className="text-xs text-cinnabar"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|