feat(frontend): 润色再沟通接入 AI 反问澄清——选项芯片 + 折答案回炉(WFW-9 M1)
RefinePanel「再沟通」升级为对话式:意见含糊/极短(门控 <10 字,或点「帮我理清方向」)时先调 clarify 预检端点让 AI 反问,渲染 ChoiceChips 选项芯片(+自由输入兜底);作者选/答后 foldClarifications 把答案折进 instruction,再走既有 refine 回炉(零迁移)。清晰意见直接回炉、 不交往返税;预检失败=放行(useClarify 视 error/异常为 needClarification=false,不阻塞润色)。 新增:lib/workbench/clarify.ts(VM 类型+foldClarifications+needsClarifyGate 纯逻辑) + useClarify(SSE 外调映射 snake→VM) + ChoiceChips.tsx;gen:api 重生成客户端。 门禁绿:tsc/lint/vitest 654(+clarify/useClarify 18 例)/build/coverage 95.36%。
This commit is contained in:
106
apps/web/components/workbench/ChoiceChips.tsx
Normal file
106
apps/web/components/workbench/ChoiceChips.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState, type KeyboardEvent } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextInput } from "@/components/ui/TextInput";
|
||||
import type { ClarifyOptionVM } from "@/lib/workbench/clarify";
|
||||
|
||||
interface ChoiceChipsProps {
|
||||
// 反问的澄清问题。
|
||||
question: string;
|
||||
// 2–4 个具体选项;为空时只渲染自由输入。
|
||||
options: ClarifyOptionVM[];
|
||||
// 是否提供常驻自由输入兜底。
|
||||
allowFreeText: boolean;
|
||||
// 点选某选项:回传其 value(供上层折进 instruction)。
|
||||
onPick: (value: string) => void;
|
||||
// 提交自由输入文本(allowFreeText 且提供此回调时可用)。
|
||||
onFreeText?: (text: string) => void;
|
||||
}
|
||||
|
||||
// AI 反问澄清的选项芯片组(WFW-9 M1):渲染一个问题 + 一组选项芯片(复用 Button),
|
||||
// 作者点选一项即高亮并回传 value;可选自由输入兜底。纯展示、无副作用、无 API 调用。
|
||||
export function ChoiceChips({
|
||||
question,
|
||||
options,
|
||||
allowFreeText,
|
||||
onPick,
|
||||
onFreeText,
|
||||
}: ChoiceChipsProps) {
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [freeText, setFreeText] = useState("");
|
||||
const inputId = useId();
|
||||
const hasOptions = options.length > 0;
|
||||
const canSubmit = Boolean(onFreeText) && freeText.trim().length > 0;
|
||||
|
||||
const handlePick = (value: string): void => {
|
||||
setPicked(value);
|
||||
onPick(value);
|
||||
};
|
||||
|
||||
const handleFreeSubmit = (): void => {
|
||||
const trimmed = freeText.trim();
|
||||
if (!trimmed || !onFreeText) return;
|
||||
onFreeText(trimmed);
|
||||
setFreeText("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
handleFreeSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-ink">{question}</p>
|
||||
|
||||
{hasOptions ? (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="澄清选项">
|
||||
{options.map((option, index) => {
|
||||
const active = option.value === picked;
|
||||
return (
|
||||
<Button
|
||||
key={`${index}-${option.value}`}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => handlePick(option.value)}
|
||||
variant={active ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{allowFreeText ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor={inputId} className="sr-only">
|
||||
自由回答
|
||||
</label>
|
||||
<TextInput
|
||||
id={inputId}
|
||||
value={freeText}
|
||||
onChange={(event) => setFreeText(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
controlSize="sm"
|
||||
placeholder="或直接说你的想法…"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFreeSubmit}
|
||||
disabled={!canSubmit}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, MessageSquarePlus, RotateCcw, X } from "lucide-react";
|
||||
import { Check, MessageSquarePlus, MessagesSquare, RotateCcw, X } from "lucide-react";
|
||||
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
@@ -9,6 +9,12 @@ 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;
|
||||
@@ -30,7 +36,9 @@ export function RefinePanel({
|
||||
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);
|
||||
|
||||
// 打开即对选段回炉一次(仅一次,避免重复请求)。
|
||||
@@ -41,12 +49,38 @@ export function RefinePanel({
|
||||
}, [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 onRecommunicate = (): void => {
|
||||
const trimmed = instruction.trim();
|
||||
if (!trimmed || busy) return;
|
||||
// 折入澄清答案(或原意见)后回炉,并清理澄清态。
|
||||
const runRecommunicate = (finalInstruction: string): void => {
|
||||
clarify.reset();
|
||||
setPendingInstruction(null);
|
||||
setInstruction("");
|
||||
void recommunicate(projectId, chapterNo, trimmed);
|
||||
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 (
|
||||
@@ -101,19 +135,41 @@ export function RefinePanel({
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="再沟通:想怎么改?(如「再收紧节奏」「保留这句比喻」)"
|
||||
placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
|
||||
/>
|
||||
</label>
|
||||
{asking && firstQuestion ? (
|
||||
<div 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={onRecommunicate}
|
||||
disabled={busy || instruction.trim().length === 0}
|
||||
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}
|
||||
@@ -123,8 +179,11 @@ export function RefinePanel({
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
接受回填
|
||||
</Button>
|
||||
{busy ? (
|
||||
<ThinkingIndicator label="生成中" className="text-xs text-cinnabar" />
|
||||
{busy || clarifying ? (
|
||||
<ThinkingIndicator
|
||||
label={clarifying ? "琢磨要不要反问" : "生成中"}
|
||||
className="text-xs text-cinnabar"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user