Files
writer-work-flow/apps/web/components/workbench/ChoiceChips.tsx
Yaojia Wang e30c933c76 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%。
2026-07-08 08:47:28 +02:00

107 lines
3.2 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 { 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;
// 24 个具体选项;为空时只渲染自由输入。
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>
);
}