From 7f56ca8cc6e7393c5f11b24bb6998e38e3684906 Mon Sep 17 00:00:00 2001 From: wenjie Date: Thu, 16 Apr 2026 17:14:35 +0800 Subject: [PATCH] feat(web): refactor tools page into tabbed library and web search settings (#2539) - split the tools page into focused components and a shared hook - add separate Tool Library and Web Search tabs - refresh web search settings layout and localized copy - make provider expansion keyboard accessible - restore wrapping for long tool names in library cards - allow custom styling for KeyInput --- .../agent/tools/tool-library-tab.tsx | 245 +++++++ .../agent/tools/tool-status-badge.tsx | 28 + .../src/components/agent/tools/tools-page.tsx | 635 ++---------------- .../src/components/agent/tools/tools-tabs.tsx | 56 ++ .../src/components/agent/tools/types.ts | 9 + .../components/agent/tools/use-tools-page.ts | 194 ++++++ .../tools/web-search-general-settings.tsx | 139 ++++ .../tools/web-search-provider-settings.tsx | 253 +++++++ .../components/agent/tools/web-search-tab.tsx | 109 +++ web/frontend/src/components/shared-form.tsx | 5 +- web/frontend/src/i18n/locales/en.json | 37 +- web/frontend/src/i18n/locales/zh.json | 35 +- 12 files changed, 1138 insertions(+), 607 deletions(-) create mode 100644 web/frontend/src/components/agent/tools/tool-library-tab.tsx create mode 100644 web/frontend/src/components/agent/tools/tool-status-badge.tsx create mode 100644 web/frontend/src/components/agent/tools/tools-tabs.tsx create mode 100644 web/frontend/src/components/agent/tools/types.ts create mode 100644 web/frontend/src/components/agent/tools/use-tools-page.ts create mode 100644 web/frontend/src/components/agent/tools/web-search-general-settings.tsx create mode 100644 web/frontend/src/components/agent/tools/web-search-provider-settings.tsx create mode 100644 web/frontend/src/components/agent/tools/web-search-tab.tsx diff --git a/web/frontend/src/components/agent/tools/tool-library-tab.tsx b/web/frontend/src/components/agent/tools/tool-library-tab.tsx new file mode 100644 index 000000000..638a7be23 --- /dev/null +++ b/web/frontend/src/components/agent/tools/tool-library-tab.tsx @@ -0,0 +1,245 @@ +import { IconSearch } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { ToolSupportItem } from "@/api/tools" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +import { ToolStatusBadge } from "./tool-status-badge" +import type { GroupedTools, ToolStatusFilter } from "./types" + +interface ToolLibraryTabProps { + allTools: ToolSupportItem[] + groupedTools: GroupedTools + totalFilteredCount: number + searchQuery: string + statusFilter: ToolStatusFilter + isLoading: boolean + hasError: boolean + pendingToolName: string | null + onSearchQueryChange: (value: string) => void + onStatusFilterChange: (value: ToolStatusFilter) => void + onToggleTool: (name: string, enabled: boolean) => void +} + +export function ToolLibraryTab({ + allTools, + groupedTools, + totalFilteredCount, + searchQuery, + statusFilter, + isLoading, + hasError, + pendingToolName, + onSearchQueryChange, + onStatusFilterChange, + onToggleTool, +}: ToolLibraryTabProps) { + const { t } = useTranslation() + + return ( +
+
+
+

+ {t("pages.agent.tools.library_title", "Tool Library")} +

+

+ {t( + "pages.agent.tools.library_description", + "Browse and manage the toolset available to your AI agents.", + )} +

+
+ +
+
+ + onSearchQueryChange(event.target.value)} + /> +
+ + +
+
+ + {hasError ? ( +
+

+ {t("pages.agent.load_error", "Failed to load tools")} +

+
+ ) : isLoading ? ( + + ) : totalFilteredCount === 0 ? ( + + ) : ( +
+ {groupedTools.map(([category, items]) => ( +
+
+

+ {t(`pages.agent.tools.categories.${category}`, category)} +

+
+
+ {items.map((tool) => ( + + ))} +
+
+ ))} +
+ )} +
+ ) +} + +function ToolCard({ + tool, + isPending, + onToggleTool, +}: { + tool: ToolSupportItem + isPending: boolean + onToggleTool: (name: string, enabled: boolean) => void +}) { + const { t } = useTranslation() + const reasonText = tool.reason_code + ? t(`pages.agent.tools.reasons.${tool.reason_code}`) + : "" + const isEnabled = tool.status === "enabled" + const isDisabled = tool.status === "disabled" + const isBlocked = tool.status === "blocked" + + return ( + + +
+
+

+ {tool.name} +

+ +
+ onToggleTool(tool.name, checked)} + className={cn( + "shrink-0", + isEnabled && "shadow-xs ring-1 ring-emerald-500/20", + )} + /> +
+ +

+ {tool.description} +

+ + {reasonText && ( +
+
+ {reasonText} +
+
+ )} +
+
+ ) +} + +function LibraryLoadingState() { + return ( +
+ {[1, 2].map((groupIndex) => ( +
+ +
+ {[1, 2].map((itemIndex) => ( + + ))} +
+
+ ))} +
+ ) +} + +function LibraryEmptyState({ allToolsCount }: { allToolsCount: number }) { + const { t } = useTranslation() + + return ( +
+
+ +
+

+ {allToolsCount === 0 + ? t("pages.agent.tools.empty", "No tools found") + : t("pages.agent.tools.no_results", "No matching tools")} +

+ {allToolsCount !== 0 && ( +

+ Try adjusting your search criteria or status filters. +

+ )} +
+ ) +} diff --git a/web/frontend/src/components/agent/tools/tool-status-badge.tsx b/web/frontend/src/components/agent/tools/tool-status-badge.tsx new file mode 100644 index 000000000..017d167b2 --- /dev/null +++ b/web/frontend/src/components/agent/tools/tool-status-badge.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next" + +import type { ToolSupportItem } from "@/api/tools" +import { cn } from "@/lib/utils" + +interface ToolStatusBadgeProps { + status: ToolSupportItem["status"] +} + +export function ToolStatusBadge({ status }: ToolStatusBadgeProps) { + const { t } = useTranslation() + + return ( + + {t(`pages.agent.tools.status.${status}`, status)} + + ) +} diff --git a/web/frontend/src/components/agent/tools/tools-page.tsx b/web/frontend/src/components/agent/tools/tools-page.tsx index 927a5645e..c490c46ad 100644 --- a/web/frontend/src/components/agent/tools/tools-page.tsx +++ b/web/frontend/src/components/agent/tools/tools-page.tsx @@ -1,593 +1,76 @@ -import { IconSearch } from "@tabler/icons-react" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { toast } from "sonner" - -import { - type ToolSupportItem, - type WebSearchConfigResponse, - getTools, - getWebSearchConfig, - setToolEnabled, - updateWebSearchConfig, -} from "@/api/tools" import { PageHeader } from "@/components/page-header" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" -import { KeyInput } from "@/components/shared-form" -import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Skeleton } from "@/components/ui/skeleton" -import { Switch } from "@/components/ui/switch" -import { cn } from "@/lib/utils" -import { refreshGatewayState } from "@/store/gateway" + +import { ToolLibraryTab } from "./tool-library-tab" +import { ToolsTabs } from "./tools-tabs" +import { useToolsPage } from "./use-tools-page" +import { WebSearchTab } from "./web-search-tab" export function ToolsPage() { const { t } = useTranslation() - const queryClient = useQueryClient() - const { data, isLoading, error } = useQuery({ - queryKey: ["tools"], - queryFn: getTools, - }) const { - data: webSearchData, - isLoading: isWebSearchLoading, - error: webSearchError, - } = useQuery({ - queryKey: ["tools", "web-search-config"], - queryFn: getWebSearchConfig, - }) - - const [searchQuery, setSearchQuery] = useState("") - const [statusFilter, setStatusFilter] = useState("all") - const [webSearchDraftOverride, setWebSearchDraftOverride] = - useState(null) - const webSearchDraft = webSearchDraftOverride ?? webSearchData ?? null - - const toggleMutation = useMutation({ - mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => - setToolEnabled(name, enabled), - onSuccess: (_, variables) => { - toast.success( - variables.enabled - ? t("pages.agent.tools.enable_success") - : t("pages.agent.tools.disable_success"), - ) - void queryClient.invalidateQueries({ queryKey: ["tools"] }) - void refreshGatewayState({ force: true }) - }, - onError: (err) => { - toast.error( - err instanceof Error - ? err.message - : t("pages.agent.tools.toggle_error"), - ) - }, - }) - - const webSearchMutation = useMutation({ - mutationFn: updateWebSearchConfig, - onSuccess: (updated) => { - queryClient.setQueryData(["tools", "web-search-config"], updated) - setWebSearchDraftOverride(null) - toast.success(t("pages.agent.tools.web_search.save_success")) - void queryClient.invalidateQueries({ - queryKey: ["tools", "web-search-config"], - }) - void queryClient.invalidateQueries({ queryKey: ["tools"] }) - void refreshGatewayState({ force: true }) - }, - onError: (err) => { - toast.error( - err instanceof Error - ? err.message - : t("pages.agent.tools.web_search.save_error"), - ) - }, - }) - - // Filter and group tools - const { groupedTools, totalFilteredCount } = useMemo(() => { - if (!data) return { groupedTools: [], totalFilteredCount: 0 } - - let count = 0 - const buckets = new Map() - - for (const item of data.tools) { - // Apply status filter - if (statusFilter !== "all" && item.status !== statusFilter) continue - - // Apply search query - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase() - const matchesName = item.name.toLowerCase().includes(query) - const matchesDesc = (item.description || "") - .toLowerCase() - .includes(query) - if (!matchesName && !matchesDesc) continue - } - - count++ - const list = buckets.get(item.category) ?? [] - list.push(item) - buckets.set(item.category, list) - } - - return { - groupedTools: Array.from(buckets.entries()), - totalFilteredCount: count, - } - }, [data, searchQuery, statusFilter]) - - const providerLabelMap = useMemo(() => { - const entries = webSearchDraft?.providers ?? [] - return new Map(entries.map((item) => [item.id, item.label])) - }, [webSearchDraft]) - - const currentProviderLabel = webSearchDraft?.current_service - ? (providerLabelMap.get(webSearchDraft.current_service) ?? - webSearchDraft.current_service) - : t("pages.agent.tools.web_search.none") - - const updateDraft = ( - updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse, - ) => { - setWebSearchDraftOverride((current) => { - const draft = current ?? webSearchData - return draft ? updater(draft) : current - }) - } + activeTab, + currentProviderLabel, + expandedProvider, + groupedTools, + pendingToolName, + providerLabelMap, + searchQuery, + statusFilter, + tools, + totalFilteredCount, + webSearchDraft, + hasToolsError, + hasWebSearchError, + isToolsLoading, + isWebSearchLoading, + isWebSearchSaving, + setActiveTab, + setSearchQuery, + setStatusFilter, + saveWebSearchConfig, + toggleExpandedProvider, + toggleTool, + updateWebSearchDraft, + } = useToolsPage() return (
- + + -
-
- {webSearchError ? ( - - - {t("pages.agent.tools.web_search.title")} - - {t("pages.agent.tools.web_search.load_error")} - - - - ) : isWebSearchLoading || !webSearchDraft ? ( - - - - - - - - - - - +
+
+ {activeTab === "library" ? ( + ) : ( - - - {t("pages.agent.tools.web_search.title")} - - {t("pages.agent.tools.web_search.description")} - - - -
-
-
- {t("pages.agent.tools.web_search.current_service")} -
-
- {currentProviderLabel} -
-
-
-
- {t("pages.agent.tools.web_search.provider")} -
- -
-
-
- {t("pages.agent.tools.web_search.proxy")} -
- - updateDraft((current) => ({ - ...current, - proxy: e.target.value, - })) - } - placeholder="http://127.0.0.1:7890" - /> -
-
- -
-
-
- {t("pages.agent.tools.web_search.prefer_native")} -
-
- {t("pages.agent.tools.web_search.prefer_native_hint")} -
-
- - updateDraft((current) => ({ - ...current, - prefer_native: checked, - })) - } - /> -
- -
- {Object.entries(webSearchDraft.settings).map( - ([providerId, settings]) => { - const providerLabel = - providerLabelMap.get(providerId) ?? providerId - const apiKeyPlaceholder = maskedSecretPlaceholder( - settings.api_key_set ? `${providerId}-configured` : "", - t("pages.agent.tools.web_search.api_key_placeholder"), - ) - - return ( - - -
-
- - {providerLabel} - - - {t( - "pages.agent.tools.web_search.provider_hint", - )} - -
- - updateDraft((current) => ({ - ...current, - settings: { - ...current.settings, - [providerId]: { - ...current.settings[providerId], - enabled: checked, - }, - }, - })) - } - /> -
-
- -
-
- {t("pages.agent.tools.web_search.max_results")} -
- - updateDraft((current) => ({ - ...current, - settings: { - ...current.settings, - [providerId]: { - ...current.settings[providerId], - max_results: - Number(e.target.value) || 0, - }, - }, - })) - } - /> -
- {(providerId === "tavily" || - providerId === "searxng" || - providerId === "glm_search" || - providerId === "baidu_search") && ( -
-
- {t("pages.agent.tools.web_search.base_url")} -
- - updateDraft((current) => ({ - ...current, - settings: { - ...current.settings, - [providerId]: { - ...current.settings[providerId], - base_url: e.target.value, - }, - }, - })) - } - placeholder={t( - "pages.agent.tools.web_search.base_url_placeholder", - )} - /> -
- )} - {(providerId === "brave" || - providerId === "tavily" || - providerId === "perplexity" || - providerId === "glm_search" || - providerId === "baidu_search") && ( -
-
- {t("pages.agent.tools.web_search.api_key")} -
- - updateDraft((current) => ({ - ...current, - settings: { - ...current.settings, - [providerId]: { - ...current.settings[providerId], - api_key: value, - }, - }, - })) - } - placeholder={apiKeyPlaceholder} - /> -
- )} -
-
- ) - }, - )} -
- -
- -
-
-
- )} - - {/* Header & Description */} -
- {/* Filters Toolbar */} -
-
- - setSearchQuery(e.target.value)} - /> -
- -
-
- - {/* Content Area */} - {error ? ( - - -

- {t("pages.agent.load_error")} -

-
-
- ) : isLoading ? ( - // Skeleton Loading State -
- {[1, 2].map((groupIndex) => ( -
- -
- {[1, 2, 3, 4].map((itemIndex) => ( - - - - - - - - - - - ))} -
-
- ))} -
- ) : totalFilteredCount === 0 ? ( - // Empty State - - -
- -
-

- {data?.tools.length === 0 - ? t("pages.agent.tools.empty") - : t("pages.agent.tools.no_results")} -

- {data?.tools.length !== 0 && ( -

- Try adjusting your search criteria or status filters. -

- )} -
-
- ) : ( - // Tool Categories list -
- {groupedTools.map(([category, items]) => ( -
-

- {t(`pages.agent.tools.categories.${category}`)} -

-
- {items.map((tool) => { - const reasonText = tool.reason_code - ? t(`pages.agent.tools.reasons.${tool.reason_code}`) - : "" - const isPending = - toggleMutation.isPending && - toggleMutation.variables?.name === tool.name - const isEnabled = tool.status === "enabled" - const isDisabled = tool.status === "disabled" - const isBlocked = tool.status === "blocked" - - return ( - - -
-
-
- - {tool.name} - - -
- - {tool.description} - -
-
- - toggleMutation.mutate({ - name: tool.name, - enabled: checked, - }) - } - /> -
-
-
- {reasonText && ( - -
- {reasonText} -
-
- )} -
- ) - })} -
-
- ))} -
+ )}
) } - -function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) { - const { t } = useTranslation() - - return ( - - {t(`pages.agent.tools.status.${status}`)} - - ) -} diff --git a/web/frontend/src/components/agent/tools/tools-tabs.tsx b/web/frontend/src/components/agent/tools/tools-tabs.tsx new file mode 100644 index 000000000..a5898ccdc --- /dev/null +++ b/web/frontend/src/components/agent/tools/tools-tabs.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from "react-i18next" + +import { cn } from "@/lib/utils" + +import type { ToolsPageTab } from "./types" + +interface ToolsTabsProps { + activeTab: ToolsPageTab + onChange: (tab: ToolsPageTab) => void +} + +const tabs: Array<{ + defaultLabel: string + key: ToolsPageTab + translationKey: string +}> = [ + { + key: "library", + translationKey: "pages.agent.tools.library_title", + defaultLabel: "Tool Library", + }, + { + key: "web-search", + translationKey: "pages.agent.tools.web_search.title", + defaultLabel: "Web Search", + }, +] + +export function ToolsTabs({ activeTab, onChange }: ToolsTabsProps) { + const { t } = useTranslation() + + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ ) +} diff --git a/web/frontend/src/components/agent/tools/types.ts b/web/frontend/src/components/agent/tools/types.ts new file mode 100644 index 000000000..1aec90931 --- /dev/null +++ b/web/frontend/src/components/agent/tools/types.ts @@ -0,0 +1,9 @@ +import type { ToolSupportItem, WebSearchConfigResponse } from "@/api/tools" + +export type ToolsPageTab = "library" | "web-search" +export type ToolStatusFilter = "all" | ToolSupportItem["status"] +export type GroupedTools = Array<[string, ToolSupportItem[]]> + +export type WebSearchDraftUpdater = ( + updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse, +) => void diff --git a/web/frontend/src/components/agent/tools/use-tools-page.ts b/web/frontend/src/components/agent/tools/use-tools-page.ts new file mode 100644 index 000000000..ce47d914c --- /dev/null +++ b/web/frontend/src/components/agent/tools/use-tools-page.ts @@ -0,0 +1,194 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useDeferredValue, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + getTools, + getWebSearchConfig, + setToolEnabled, + updateWebSearchConfig, + type WebSearchConfigResponse, +} from "@/api/tools" +import { refreshGatewayState } from "@/store/gateway" + +import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types" + +export function useToolsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const [activeTab, setActiveTab] = useState("library") + const [searchQuery, setSearchQuery] = useState("") + const deferredSearchQuery = useDeferredValue(searchQuery) + const [statusFilter, setStatusFilter] = useState("all") + const [expandedProvider, setExpandedProvider] = useState(null) + const [webSearchDraftOverride, setWebSearchDraftOverride] = + useState(null) + + const toolsQuery = useQuery({ + queryKey: ["tools"], + queryFn: getTools, + }) + const webSearchQuery = useQuery({ + queryKey: ["tools", "web-search-config"], + queryFn: getWebSearchConfig, + }) + + const tools = useMemo(() => toolsQuery.data?.tools ?? [], [toolsQuery.data?.tools]) + const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase() + const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null + + const toggleToolMutation = useMutation({ + mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => + setToolEnabled(name, enabled), + onSuccess: (_, variables) => { + toast.success( + variables.enabled + ? t("pages.agent.tools.enable_success", "Tool enabled successfully") + : t( + "pages.agent.tools.disable_success", + "Tool disabled successfully", + ), + ) + void queryClient.invalidateQueries({ queryKey: ["tools"] }) + void refreshGatewayState({ force: true }) + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t("pages.agent.tools.toggle_error", "Failed to toggle tool"), + ) + }, + }) + + const saveWebSearchMutation = useMutation({ + mutationFn: updateWebSearchConfig, + onSuccess: (updatedConfig) => { + queryClient.setQueryData(["tools", "web-search-config"], updatedConfig) + setWebSearchDraftOverride(null) + toast.success( + t( + "pages.agent.tools.web_search.save_success", + "Settings saved successfully", + ), + ) + void queryClient.invalidateQueries({ + queryKey: ["tools", "web-search-config"], + }) + void queryClient.invalidateQueries({ queryKey: ["tools"] }) + void refreshGatewayState({ force: true }) + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t( + "pages.agent.tools.web_search.save_error", + "Failed to save settings", + ), + ) + }, + }) + + const groupedTools = useMemo<{ + groupedTools: GroupedTools + totalFilteredCount: number + }>(() => { + let totalFilteredCount = 0 + const grouped = new Map() + + for (const tool of tools) { + if (statusFilter !== "all" && tool.status !== statusFilter) { + continue + } + + if (normalizedSearchQuery) { + const matchesName = tool.name.toLowerCase().includes(normalizedSearchQuery) + const matchesDescription = (tool.description || "") + .toLowerCase() + .includes(normalizedSearchQuery) + + if (!matchesName && !matchesDescription) { + continue + } + } + + totalFilteredCount += 1 + const items = grouped.get(tool.category) ?? [] + items.push(tool) + grouped.set(tool.category, items) + } + + return { + groupedTools: Array.from(grouped.entries()), + totalFilteredCount, + } + }, [normalizedSearchQuery, statusFilter, tools]) + + const providerLabelMap = useMemo(() => { + const providers = webSearchDraft?.providers ?? [] + return new Map(providers.map((provider) => [provider.id, provider.label])) + }, [webSearchDraft]) + + const currentProviderLabel = webSearchDraft?.current_service + ? (providerLabelMap.get(webSearchDraft.current_service) ?? + webSearchDraft.current_service) + : t("pages.agent.tools.web_search.none", "None") + + const pendingToolName = toggleToolMutation.isPending + ? (toggleToolMutation.variables?.name ?? null) + : null + + const updateWebSearchDraft = ( + updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse, + ) => { + setWebSearchDraftOverride((current) => { + const draft = current ?? webSearchQuery.data + return draft ? updater(draft) : current + }) + } + + const toggleTool = (name: string, enabled: boolean) => { + toggleToolMutation.mutate({ name, enabled }) + } + + const saveWebSearchConfig = () => { + if (webSearchDraft) { + saveWebSearchMutation.mutate(webSearchDraft) + } + } + + const toggleExpandedProvider = (providerId: string) => { + setExpandedProvider((current) => + current === providerId ? null : providerId, + ) + } + + return { + activeTab, + currentProviderLabel, + expandedProvider, + groupedTools: groupedTools.groupedTools, + pendingToolName, + providerLabelMap, + searchQuery, + statusFilter, + tools, + totalFilteredCount: groupedTools.totalFilteredCount, + webSearchDraft, + hasToolsError: toolsQuery.error != null, + hasWebSearchError: webSearchQuery.error != null, + isToolsLoading: toolsQuery.isLoading, + isWebSearchLoading: webSearchQuery.isLoading, + isWebSearchSaving: saveWebSearchMutation.isPending, + setActiveTab, + setSearchQuery, + setStatusFilter, + saveWebSearchConfig, + toggleExpandedProvider, + toggleTool, + updateWebSearchDraft, + } +} diff --git a/web/frontend/src/components/agent/tools/web-search-general-settings.tsx b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx new file mode 100644 index 000000000..33d6572cf --- /dev/null +++ b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" + +import type { WebSearchConfigResponse } from "@/api/tools" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" + +import type { WebSearchDraftUpdater } from "./types" + +interface WebSearchGeneralSettingsProps { + draft: WebSearchConfigResponse + onUpdateDraft: WebSearchDraftUpdater +} + +export function WebSearchGeneralSettings({ + draft, + onUpdateDraft, +}: WebSearchGeneralSettingsProps) { + const { t } = useTranslation() + + return ( +
+

+ {t("pages.agent.tools.web_search.global_settings", "General")} +

+ +
+ + + + + + + onUpdateDraft((current) => ({ + ...current, + proxy: event.target.value, + })) + } + placeholder="http://127.0.0.1:7890" + /> + + + + + onUpdateDraft((current) => ({ + ...current, + prefer_native: checked, + })) + } + className="data-[state=checked]:shadow-xs" + /> + +
+
+ ) +} + +function SettingRow({ + label, + description, + children, +}: { + label: string + description: string + children: ReactNode +}) { + return ( +
+
+ +

+ {description} +

+
+ {children} +
+ ) +} diff --git a/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx new file mode 100644 index 000000000..9ba8d6ac6 --- /dev/null +++ b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx @@ -0,0 +1,253 @@ +import { IconChevronDown } from "@tabler/icons-react" +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" + +import type { WebSearchProviderConfig } from "@/api/tools" +import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { KeyInput } from "@/components/shared-form" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +import type { WebSearchDraftUpdater } from "./types" + +interface WebSearchProviderSettingsProps { + providerLabelMap: Map + settings: Record + expandedProvider: string | null + onToggleProviderExpand: (providerId: string) => void + onUpdateDraft: WebSearchDraftUpdater +} + +const baseUrlProviders = new Set([ + "tavily", + "searxng", + "glm_search", + "baidu_search", +]) + +const apiKeyProviders = new Set([ + "brave", + "tavily", + "perplexity", + "glm_search", + "baidu_search", +]) + +export function WebSearchProviderSettings({ + providerLabelMap, + settings, + expandedProvider, + onToggleProviderExpand, + onUpdateDraft, +}: WebSearchProviderSettingsProps) { + const { t } = useTranslation() + + return ( +
+

+ {t("pages.agent.tools.web_search.providers_config", "Integrations")} +

+ +
+ {Object.entries(settings).map(([providerId, providerSettings]) => ( + + ))} +
+
+ ) +} + +function ProviderCard({ + providerId, + providerLabel, + settings, + isExpanded, + onToggleExpand, + onUpdateDraft, +}: { + providerId: string + providerLabel: string + settings: WebSearchProviderConfig + isExpanded: boolean + onToggleExpand: (providerId: string) => void + onUpdateDraft: WebSearchDraftUpdater +}) { + const { t } = useTranslation() + const apiKeyPlaceholder = maskedSecretPlaceholder( + settings.api_key_set ? `${providerId}-configured` : "", + t( + "pages.agent.tools.web_search.api_key_placeholder", + "Enter API key...", + ), + ) + + const updateSettings = ( + updater: (current: WebSearchProviderConfig) => WebSearchProviderConfig, + ) => { + onUpdateDraft((current) => { + const nextSettings = current.settings[providerId] ?? settings + return { + ...current, + settings: { + ...current.settings, + [providerId]: updater(nextSettings), + }, + } + }) + } + + return ( +
+
+ + +
event.stopPropagation()} + > + + updateSettings((current) => ({ + ...current, + enabled: checked, + })) + } + /> +
+
+ + {isExpanded && ( +
+
+ + + updateSettings((current) => ({ + ...current, + max_results: Number(event.target.value) || 0, + })) + } + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors" + /> + + + {baseUrlProviders.has(providerId) && ( + + + updateSettings((current) => ({ + ...current, + base_url: event.target.value, + })) + } + placeholder={t( + "pages.agent.tools.web_search.base_url_placeholder", + "Optional endpoint override", + )} + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors" + /> + + )} + + {apiKeyProviders.has(providerId) && ( + + + updateSettings((current) => ({ + ...current, + api_key: value, + })) + } + placeholder={apiKeyPlaceholder} + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent transition-colors" + /> + + )} +
+
+ )} +
+ ) +} + +function ProviderField({ + label, + className, + children, +}: { + label: string + className?: string + children: ReactNode +}) { + return ( +
+ + {children} +
+ ) +} diff --git a/web/frontend/src/components/agent/tools/web-search-tab.tsx b/web/frontend/src/components/agent/tools/web-search-tab.tsx new file mode 100644 index 000000000..05c060e0d --- /dev/null +++ b/web/frontend/src/components/agent/tools/web-search-tab.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from "react-i18next" + +import type { WebSearchConfigResponse } from "@/api/tools" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" + +import type { WebSearchDraftUpdater } from "./types" +import { WebSearchGeneralSettings } from "./web-search-general-settings" +import { WebSearchProviderSettings } from "./web-search-provider-settings" + +interface WebSearchTabProps { + draft: WebSearchConfigResponse | null + currentProviderLabel: string + providerLabelMap: Map + expandedProvider: string | null + isLoading: boolean + hasError: boolean + isSaving: boolean + onSave: () => void + onToggleProviderExpand: (providerId: string) => void + onUpdateDraft: WebSearchDraftUpdater +} + +export function WebSearchTab({ + draft, + currentProviderLabel, + providerLabelMap, + expandedProvider, + isLoading, + hasError, + isSaving, + onSave, + onToggleProviderExpand, + onUpdateDraft, +}: WebSearchTabProps) { + const { t } = useTranslation() + + return ( +
+ {hasError ? ( +
+

+ {t( + "pages.agent.tools.web_search.load_error", + "Failed to load web search configuration", + )} +

+
+ ) : isLoading || !draft ? ( + + ) : ( + <> +
+
+
+

+ {t( + "pages.agent.tools.web_search.title", + "Web Search Configuration", + )} +

+
+ {currentProviderLabel} +
+
+

+ {t( + "pages.agent.tools.web_search.description", + "Provide web search capability for agents to find the latest real-world info. Automatically routes to the optimal active provider.", + )} +

+
+ + +
+ +
+ + +
+ + )} +
+ ) +} + +function LoadingState() { + return ( +
+ + +
+ ) +} diff --git a/web/frontend/src/components/shared-form.tsx b/web/frontend/src/components/shared-form.tsx index e6dd2cee9..c661af360 100644 --- a/web/frontend/src/components/shared-form.tsx +++ b/web/frontend/src/components/shared-form.tsx @@ -90,9 +90,10 @@ interface KeyInputProps { value: string onChange: (v: string) => void placeholder?: string + className?: string } -export function KeyInput({ value, onChange, placeholder }: KeyInputProps) { +export function KeyInput({ value, onChange, placeholder, className }: KeyInputProps) { const [show, setShow] = useState(false) return ( @@ -102,7 +103,7 @@ export function KeyInput({ value, onChange, placeholder }: KeyInputProps) { value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} - className="pr-10" + className={cn("pr-10", className)} />