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%。
107 lines
3.2 KiB
TypeScript
107 lines
3.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|