From c07aa5cf4b6fe6c343862b713070cccbaa882f4d Mon Sep 17 00:00:00 2001 From: viettranx Date: Sun, 12 Apr 2026 11:41:16 +0700 Subject: [PATCH] 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 --- ui/web/src/i18n/locales/en/tools.json | 32 +++ ui/web/src/i18n/locales/vi/tools.json | 32 +++ ui/web/src/i18n/locales/zh/tools.json | 32 +++ .../builtin-tool-settings-dialog.tsx | 22 +- .../pages/builtin-tools/tts-provider-form.tsx | 125 +++++++++ .../builtin-tools/web-search-chain-form.tsx | 240 ++++++++++++++++++ 6 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 ui/web/src/pages/builtin-tools/tts-provider-form.tsx create mode 100644 ui/web/src/pages/builtin-tools/web-search-chain-form.tsx diff --git a/ui/web/src/i18n/locales/en/tools.json b/ui/web/src/i18n/locales/en/tools.json index cff29a66..8fad822d 100644 --- a/ui/web/src/i18n/locales/en/tools.json +++ b/ui/web/src/i18n/locales/en/tools.json @@ -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.", diff --git a/ui/web/src/i18n/locales/vi/tools.json b/ui/web/src/i18n/locales/vi/tools.json index a6bb3f2d..f7aed6fe 100644 --- a/ui/web/src/i18n/locales/vi/tools.json +++ b/ui/web/src/i18n/locales/vi/tools.json @@ -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.", diff --git a/ui/web/src/i18n/locales/zh/tools.json b/ui/web/src/i18n/locales/zh/tools.json index 06c2003b..fa8f126f 100644 --- a/ui/web/src/i18n/locales/zh/tools.json +++ b/ui/web/src/i18n/locales/zh/tools.json @@ -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 从上到下依次尝试,直到某个提取器返回高质量内容。", diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx index 3b318971..e24bfef0 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx @@ -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({ {modeBadge} - {isWebFetch && tool ? ( + {isWebSearch && tool ? ( + onSave(tool.name, settings).then(() => onOpenChange(false))} + onCancel={() => onOpenChange(false)} + /> + ) : isTts && tool ? ( + onSave(tool.name, settings).then(() => onOpenChange(false))} + onCancel={() => onOpenChange(false)} + /> + ) : isWebFetch && tool ? ( onSave(tool.name, settings).then(() => onOpenChange(false))} diff --git a/ui/web/src/pages/builtin-tools/tts-provider-form.tsx b/ui/web/src/pages/builtin-tools/tts-provider-form.tsx new file mode 100644 index 00000000..50793d86 --- /dev/null +++ b/ui/web/src/pages/builtin-tools/tts-provider-form.tsx @@ -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; + onSave: (settings: Record) => Promise; + onCancel: () => void; +} + +const TTS_PROVIDERS: TtsProvider[] = ["openai", "elevenlabs", "edge", "minimax"]; +const AUTO_MODES: AutoMode[] = ["off", "inbound", "tagged", "always"]; + +const AUTO_MODE_DESC_KEYS: Record = { + off: "builtin.ttsForm.autoOffDesc", + inbound: "builtin.ttsForm.autoInboundDesc", + tagged: "builtin.ttsForm.autoTaggedDesc", + always: "builtin.ttsForm.autoAlwaysDesc", +}; + +const AUTO_MODE_LABEL_KEYS: Record = { + 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( + (initialSettings.primary as TtsProvider) ?? "openai", + ); + const [auto, setAuto] = useState( + (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 ( +
+ + {t("builtin.ttsForm.title")} + {t("builtin.ttsForm.description")} + + +
+ {/* Primary provider */} +
+ + +
+ + {/* Auto mode */} +
+ + +
+
+ + + + + +
+ ); +} diff --git a/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx b/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx new file mode 100644 index 00000000..15b4ac69 --- /dev/null +++ b/ui/web/src/pages/builtin-tools/web-search-chain-form.tsx @@ -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; + onSave: (settings: Record) => Promise; + 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 = { + exa: "bg-blue-600", + tavily: "bg-cyan-500", + brave: "bg-orange-500", + duckduckgo: "bg-slate-500", +}; + +function parseInitialEntries(settings: Record): 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; + 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) => 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 ( +
+
+
+
+ + #{index + 1} + onUpdate(entry.id, { enabled: v })} + /> + {displayName} +
+
+ + onUpdate(entry.id, { max_results: Number(e.target.value) })} + className="h-7 w-16 text-base md:text-sm" + /> +
+
+
+ ); +} + +function LockedDuckDuckGoCard({ settings }: { settings: Record }) { + const { t } = useTranslation("tools"); + const cfg = (settings[LOCKED_PROVIDER] ?? {}) as Record; + const enabled = Boolean(cfg.enabled ?? true); + + return ( +
+
+
+
+ + #4 + + + {t("builtin.searchChain.providers.duckduckgo")} + + {t("builtin.searchChain.locked")} +
+
+
+ ); +} + +export function WebSearchChainForm({ initialSettings, onSave, onCancel }: Props) { + const { t } = useTranslation("tools"); + const [entries, setEntries] = useState(() => + 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) => { + 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 = { provider_order: providerOrder }; + for (const entry of entries) { + const cfg: Record = { 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 ( +
+ + {t("builtin.searchChain.title")} + {t("builtin.searchChain.description")} + + +
+ + e.id)} strategy={verticalListSortingStrategy}> + {entries.map((entry, index) => ( + + ))} + + + +
+ + + + + +
+ ); +}