Files
writer-work-flow/apps/web/components/workbench/RefinePanel.tsx

194 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { ChoiceChips } from "./ChoiceChips";
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10;
interface RefinePanelProps {
projectId: string;
chapterNo: number;
// 选中原文;打开即对其回炉一次。
original: string;
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。
onAccept: (refined: string) => void;
onClose: () => void;
}
// 润色 / 再沟通结果卡(内联,非模态):打开即润色选段;作者可「再沟通」追加意见迭代出新版,
// 择版接受回填正文,或放弃。原文只读、生成不写库(不变量 #3accept 才动草稿。
export function RefinePanel({
projectId,
chapterNo,
original,
onAccept,
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);
// 打开即对选段回炉一次(仅一次,避免重复请求)。
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
void refine(projectId, chapterNo, original);
}, [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;
// 折入澄清答案(或原意见)后回炉,并清理澄清态。
const runRecommunicate = (finalInstruction: string): void => {
clarify.reset();
setPendingInstruction(null);
setInstruction("");
void recommunicate(projectId, chapterNo, finalInstruction);
};
// 「再改一版」:意见含糊/极短(或 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);
};
// 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 回炉。
const onClarifyAnswer = (answer: string): void => {
const question = clarify.decision?.questions[0]?.question ?? "";
const base = pendingInstruction ?? instruction.trim();
runRecommunicate(foldClarifications(base, [{ question, answer }]));
};
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>
);
}