feat(ui): web_search chain editor + TTS settings form

Add dedicated UI forms for web_search and TTS builtin tool settings,
replacing the raw JSON editor for these tools.

- web-search-chain-form: drag-drop provider order (Exa/Tavily/Brave),
  DuckDuckGo locked as always-on fallback, color-coded rails,
  per-provider max_results config
- tts-provider-form: primary provider selector + auto mode dropdown
  with inline descriptions (off/inbound/tagged/always)
- Wire both into builtin-tool-settings-dialog dispatch
- i18n strings for en/vi/zh
This commit is contained in:
viettranx committed 2026-04-12 11:41:16 +07:00
1 parent 3a8470b8da
commit c07aa5cf4b
6 files changed
+481 -2

No files matched your search

+32
View File
@@ -143,6 +143,38 @@
"save": "Save",
"saving": "Saving..."
},
"searchChain": {
"title": "Web Search Provider Chain",
"description": "Drag to reorder search providers. First enabled provider with a valid API key is tried first.",
"maxResults": "Max results",
"locked": "Always-on fallback",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"providers": {
"exa": "Exa",
"tavily": "Tavily",
"brave": "Brave Search",
"duckduckgo": "DuckDuckGo"
}
},
"ttsForm": {
"title": "TTS Settings",
"description": "Configure text-to-speech provider and auto-apply mode.",
"primary": "Primary provider",
"auto": "Auto mode",
"autoOff": "Off",
"autoInbound": "Voice inbound",
"autoTagged": "Tagged",
"autoAlways": "Always",
"autoOffDesc": "Only when agent explicitly uses tts tool",
"autoInboundDesc": "Auto-apply when user sends voice/audio",
"autoTaggedDesc": "Apply when text contains [[tts]] directive",
"autoAlwaysDesc": "Always convert final reply to speech",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving..."
},
"extractorChain": {
"title": "Web Fetch — Extractor Chain",
"description": "Ordered list of content extractors. Each URL is tried top-to-bottom until one returns quality content.",
+32
View File
@@ -143,6 +143,38 @@
"save": "Lưu",
"saving": "Đang lưu..."
},
"searchChain": {
"title": "Chuỗi nhà cung cấp tìm kiếm web",
"description": "Kéo để sắp xếp lại nhà cung cấp tìm kiếm. Nhà cung cấp đầu tiên được bật với API key hợp lệ sẽ được thử trước.",
"maxResults": "Số kết quả tối đa",
"locked": "Dự phòng luôn bật",
"cancel": "Hủy",
"save": "Lưu",
"saving": "Đang lưu...",
"providers": {
"exa": "Exa",
"tavily": "Tavily",
"brave": "Brave Search",
"duckduckgo": "DuckDuckGo"
}
},
"ttsForm": {
"title": "Cài đặt TTS",
"description": "Cấu hình nhà cung cấp chuyển văn bản thành giọng nói và chế độ tự động áp dụng.",
"primary": "Nhà cung cấp chính",
"auto": "Chế độ tự động",
"autoOff": "Tắt",
"autoInbound": "Giọng nói đến",
"autoTagged": "Có thẻ",
"autoAlways": "Luôn luôn",
"autoOffDesc": "Chỉ khi agent tường minh sử dụng công cụ tts",
"autoInboundDesc": "Tự động áp dụng khi người dùng gửi giọng nói/âm thanh",
"autoTaggedDesc": "Áp dụng khi văn bản chứa chỉ thị [[tts]]",
"autoAlwaysDesc": "Luôn chuyển đổi câu trả lời cuối thành giọng nói",
"cancel": "Hủy",
"save": "Lưu",
"saving": "Đang lưu..."
},
"extractorChain": {
"title": "Web Fetch — Chuỗi bộ trích xuất",
"description": "Danh sách bộ trích xuất nội dung theo thứ tự. Mỗi URL được thử từ trên xuống cho đến khi một bộ trả về nội dung chất lượng.",
+32
View File
@@ -57,6 +57,38 @@
"saving": "保存中...",
"title": "知识图谱设置"
},
"searchChain": {
"title": "网页搜索 Provider 链",
"description": "拖动以重新排序搜索 Provider。第一个已启用且具有有效 API Key 的 Provider 将被优先尝试。",
"maxResults": "最大结果数",
"locked": "始终启用的回退",
"cancel": "取消",
"save": "保存",
"saving": "保存中...",
"providers": {
"exa": "Exa",
"tavily": "Tavily",
"brave": "Brave Search",
"duckduckgo": "DuckDuckGo"
}
},
"ttsForm": {
"title": "TTS 设置",
"description": "配置文字转语音 Provider 和自动应用模式。",
"primary": "主要 Provider",
"auto": "自动模式",
"autoOff": "关闭",
"autoInbound": "语音入站",
"autoTagged": "标记触发",
"autoAlways": "始终",
"autoOffDesc": "仅当 Agent 明确使用 tts 工具时",
"autoInboundDesc": "当用户发送语音/音频时自动应用",
"autoTaggedDesc": "当文本包含 [[tts]] 指令时应用",
"autoAlwaysDesc": "始终将最终回复转换为语音",
"cancel": "取消",
"save": "保存",
"saving": "保存中..."
},
"extractorChain": {
"title": "Web Fetch — 提取器链",
"description": "有序内容提取器列表。每个 URL 从上到下依次尝试,直到某个提取器返回高质量内容。",
@@ -16,9 +16,13 @@ import { MEDIA_TOOLS } from "./media-provider-params-schema";
import { MediaProviderChainForm } from "./media-provider-chain-form";
import { KGSettingsForm } from "./kg-settings-form";
import { WebFetchExtractorChainForm } from "./web-fetch-extractor-chain-form";
import { WebSearchChainForm } from "./web-search-chain-form";
import { TtsProviderForm } from "./tts-provider-form";
const KG_TOOL = "knowledge_graph_search";
const WEB_FETCH_TOOL = "web_fetch";
const WEB_SEARCH_TOOL = "web_search";
const TTS_TOOL = "tts";
interface Props {
tool: BuiltinToolData | null;
@@ -55,7 +59,9 @@ export function BuiltinToolSettingsDialog({
const isMedia = tool ? MEDIA_TOOLS.has(tool.name) : false;
const isKG = tool?.name === KG_TOOL;
const isWebFetch = tool?.name === WEB_FETCH_TOOL;
const wide = isMedia || isKG || isWebFetch;
const isWebSearch = tool?.name === WEB_SEARCH_TOOL;
const isTts = tool?.name === TTS_TOOL;
const wide = isMedia || isKG || isWebFetch || isWebSearch;
// Tenant-scope overlay: prefer the tenant override when present; fall back
// to the global default so the form opens pre-populated with something
@@ -77,7 +83,19 @@ export function BuiltinToolSettingsDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={wide ? "sm:max-w-2xl" : "sm:max-w-md"}>
{modeBadge}
{isWebFetch && tool ? (
{isWebSearch && tool ? (
<WebSearchChainForm
initialSettings={initialSettings}
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
onCancel={() => onOpenChange(false)}
/>
) : isTts && tool ? (
<TtsProviderForm
initialSettings={initialSettings}
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
onCancel={() => onOpenChange(false)}
/>
) : isWebFetch && tool ? (
<WebFetchExtractorChainForm
initialSettings={initialSettings}
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
@@ -0,0 +1,125 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
type TtsProvider = "openai" | "elevenlabs" | "edge" | "minimax";
type AutoMode = "off" | "inbound" | "tagged" | "always";
interface Props {
initialSettings: Record<string, unknown>;
onSave: (settings: Record<string, unknown>) => Promise<void>;
onCancel: () => void;
}
const TTS_PROVIDERS: TtsProvider[] = ["openai", "elevenlabs", "edge", "minimax"];
const AUTO_MODES: AutoMode[] = ["off", "inbound", "tagged", "always"];
const AUTO_MODE_DESC_KEYS: Record<AutoMode, string> = {
off: "builtin.ttsForm.autoOffDesc",
inbound: "builtin.ttsForm.autoInboundDesc",
tagged: "builtin.ttsForm.autoTaggedDesc",
always: "builtin.ttsForm.autoAlwaysDesc",
};
const AUTO_MODE_LABEL_KEYS: Record<AutoMode, string> = {
off: "builtin.ttsForm.autoOff",
inbound: "builtin.ttsForm.autoInbound",
tagged: "builtin.ttsForm.autoTagged",
always: "builtin.ttsForm.autoAlways",
};
export function TtsProviderForm({ initialSettings, onSave, onCancel }: Props) {
const { t } = useTranslation("tools");
const [primary, setPrimary] = useState<TtsProvider>(
(initialSettings.primary as TtsProvider) ?? "openai",
);
const [auto, setAuto] = useState<AutoMode>(
(initialSettings.auto as AutoMode) ?? "inbound",
);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await onSave({ primary, auto });
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
};
return (
<div>
<DialogHeader>
<DialogTitle>{t("builtin.ttsForm.title")}</DialogTitle>
<DialogDescription>{t("builtin.ttsForm.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-5 my-4">
{/* Primary provider */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.ttsForm.primary")}</Label>
<Select value={primary} onValueChange={(v) => setPrimary(v as TtsProvider)}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TTS_PROVIDERS.map((p) => (
<SelectItem key={p} value={p} className="text-base md:text-sm">
{p.charAt(0).toUpperCase() + p.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Auto mode */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.ttsForm.auto")}</Label>
<Select value={auto} onValueChange={(v) => setAuto(v as AutoMode)}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTO_MODES.map((mode) => (
<SelectItem key={mode} value={mode} className="text-base md:text-sm">
<div>
<div className="font-medium">{t(AUTO_MODE_LABEL_KEYS[mode])}</div>
<div className="text-xs text-muted-foreground">{t(AUTO_MODE_DESC_KEYS[mode])}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{t("builtin.ttsForm.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? t("builtin.ttsForm.saving") : t("builtin.ttsForm.save")}
</Button>
</DialogFooter>
</div>
);
}
@@ -0,0 +1,240 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Lock, Loader2 } from "lucide-react";
import { uniqueId } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
type ProviderKey = "exa" | "tavily" | "brave" | "duckduckgo";
interface ProviderEntry {
id: string;
name: ProviderKey;
enabled: boolean;
max_results?: number;
}
interface Props {
initialSettings: Record<string, unknown>;
onSave: (settings: Record<string, unknown>) => Promise<void>;
onCancel: () => void;
}
const SORTABLE_PROVIDERS: ProviderKey[] = ["exa", "tavily", "brave"];
const LOCKED_PROVIDER: ProviderKey = "duckduckgo";
const DEFAULT_ORDER: ProviderKey[] = ["exa", "tavily", "brave"];
const RAIL_COLOR: Record<ProviderKey, string> = {
exa: "bg-blue-600",
tavily: "bg-cyan-500",
brave: "bg-orange-500",
duckduckgo: "bg-slate-500",
};
function parseInitialEntries(settings: Record<string, unknown>): ProviderEntry[] {
const rawOrder = Array.isArray(settings.provider_order)
? (settings.provider_order as string[]).filter((p): p is ProviderKey =>
SORTABLE_PROVIDERS.includes(p as ProviderKey),
)
: DEFAULT_ORDER;
const sortable: ProviderEntry[] = rawOrder.map((name) => {
const cfg = (settings[name] ?? {}) as Record<string, unknown>;
return {
id: uniqueId(),
name,
enabled: Boolean(cfg.enabled ?? true),
max_results: cfg.max_results != null ? Number(cfg.max_results) : undefined,
};
});
return sortable;
}
interface SortableCardProps {
entry: ProviderEntry;
index: number;
onUpdate: (id: string, patch: Partial<ProviderEntry>) => void;
}
function SortableProviderCard({ entry, index, onUpdate }: SortableCardProps) {
const { t } = useTranslation("tools");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: entry.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const displayName = t(`builtin.searchChain.providers.${entry.name}`, { defaultValue: entry.name });
return (
<div
ref={setNodeRef}
style={style}
className={`border rounded-lg bg-card flex overflow-hidden ${!entry.enabled ? "opacity-60" : ""}`}
>
<div className={`w-1 shrink-0 ${RAIL_COLOR[entry.name]}`} />
<div className="flex-1 px-3 py-3">
<div className="flex items-center gap-2">
<button
type="button"
className="cursor-grab text-muted-foreground hover:text-foreground shrink-0"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
<span className="text-xs text-muted-foreground font-mono shrink-0">#{index + 1}</span>
<Switch
size="sm"
checked={entry.enabled}
onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })}
/>
<span className="text-sm font-medium flex-1">{displayName}</span>
</div>
<div className="flex items-center gap-1.5 mt-2 pl-10">
<Label className="text-xs text-muted-foreground whitespace-nowrap">
{t("builtin.searchChain.maxResults")}
</Label>
<Input
type="number"
min={1}
max={10}
value={entry.max_results ?? 5}
onChange={(e) => onUpdate(entry.id, { max_results: Number(e.target.value) })}
className="h-7 w-16 text-base md:text-sm"
/>
</div>
</div>
</div>
);
}
function LockedDuckDuckGoCard({ settings }: { settings: Record<string, unknown> }) {
const { t } = useTranslation("tools");
const cfg = (settings[LOCKED_PROVIDER] ?? {}) as Record<string, unknown>;
const enabled = Boolean(cfg.enabled ?? true);
return (
<div className={`border rounded-lg bg-card flex overflow-hidden ${!enabled ? "opacity-60" : ""}`}>
<div className={`w-1 shrink-0 ${RAIL_COLOR.duckduckgo}`} />
<div className="flex-1 px-3 py-3">
<div className="flex items-center gap-2">
<Lock className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-mono shrink-0">#4</span>
<Switch size="sm" checked disabled />
<span className="text-sm font-medium flex-1">
{t("builtin.searchChain.providers.duckduckgo")}
</span>
<span className="text-xs text-muted-foreground">{t("builtin.searchChain.locked")}</span>
</div>
</div>
</div>
);
}
export function WebSearchChainForm({ initialSettings, onSave, onCancel }: Props) {
const { t } = useTranslation("tools");
const [entries, setEntries] = useState<ProviderEntry[]>(() =>
parseInitialEntries(initialSettings),
);
const [saving, setSaving] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setEntries((prev) => {
const oldIndex = prev.findIndex((e) => e.id === active.id);
const newIndex = prev.findIndex((e) => e.id === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
}
}, []);
const handleUpdate = useCallback((id: string, patch: Partial<ProviderEntry>) => {
setEntries((prev) => prev.map((e) => (e.id === id ? { ...e, ...patch } : e)));
}, []);
const handleSave = async () => {
setSaving(true);
try {
const providerOrder = entries.map((e) => e.name);
const settings: Record<string, unknown> = { provider_order: providerOrder };
for (const entry of entries) {
const cfg: Record<string, unknown> = { enabled: entry.enabled };
if (entry.max_results != null) cfg.max_results = entry.max_results;
settings[entry.name] = cfg;
}
settings[LOCKED_PROVIDER] = { enabled: true };
await onSave(settings);
} catch {
// toast shown by hook
} finally {
setSaving(false);
}
};
return (
<div>
<DialogHeader>
<DialogTitle>{t("builtin.searchChain.title")}</DialogTitle>
<DialogDescription>{t("builtin.searchChain.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-2 max-h-[60vh] overflow-y-auto pr-1 my-4">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={entries.map((e) => e.id)} strategy={verticalListSortingStrategy}>
{entries.map((entry, index) => (
<SortableProviderCard
key={entry.id}
entry={entry}
index={index}
onUpdate={handleUpdate}
/>
))}
</SortableContext>
</DndContext>
<LockedDuckDuckGoCard settings={initialSettings} />
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{t("builtin.searchChain.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? t("builtin.searchChain.saving") : t("builtin.searchChain.save")}
</Button>
</DialogFooter>
</div>
);
}