-
- {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")}
-
-
- updateDraft((current) => ({
- ...current,
- provider: value,
- }))
- }
- >
-
-
-
-
- {webSearchDraft.providers.map((provider) => (
-
- {provider.label}
-
- ))}
-
-
-
-
-
- {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}
- />
-
- )}
-
-
- )
- },
- )}
-
-
-
- webSearchMutation.mutate(webSearchDraft)}
- disabled={webSearchMutation.isPending}
- >
- {t("pages.agent.tools.web_search.save")}
-
-
-
-
- )}
-
- {/* Header & Description */}
-
- {/* Filters Toolbar */}
-
-
-
- setSearchQuery(e.target.value)}
- />
-
-
-
-
-
-
-
- {t("pages.agent.tools.filter.all")}
-
-
- {t("pages.agent.tools.filter.enabled")}
-
-
- {t("pages.agent.tools.filter.disabled")}
-
-
- {t("pages.agent.tools.filter.blocked")}
-
-
-
-
-
-
- {/* 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) => (
+ onChange(tab.key)}
+ className={cn(
+ "hover:text-foreground relative cursor-pointer pb-4 text-[14px] font-medium transition-colors outline-none",
+ activeTab === tab.key
+ ? "text-foreground"
+ : "text-muted-foreground",
+ )}
+ >
+ {t(tab.translationKey, tab.defaultLabel)}
+ {activeTab === tab.key && (
+
+ )}
+
+ ))}
+
+
+ )
+}
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,
+ provider: value,
+ }))
+ }
+ >
+
+
+
+
+ {draft.providers.map((provider) => (
+
+ {provider.label}
+
+ ))}
+
+
+
+
+
+
+ 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 (
+
+
+
+ {label}
+
+
+ {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 (
+
+
+
onToggleExpand(providerId)}
+ >
+
+
+
+
+
+ {providerLabel}
+
+ {settings.enabled ? (
+
+ {t("pages.agent.tools.filter.enabled", "Enabled")}
+
+ ) : (
+
+ {t("pages.agent.tools.filter.disabled", "Disabled")}
+
+ )}
+
+
+
+
event.stopPropagation()}
+ >
+
+ updateSettings((current) => ({
+ ...current,
+ enabled: checked,
+ }))
+ }
+ />
+
+
+
+ {isExpanded && (
+
+ )}
+
+ )
+}
+
+function ProviderField({
+ label,
+ className,
+ children,
+}: {
+ label: string
+ className?: string
+ children: ReactNode
+}) {
+ return (
+
+
+ {label}
+
+ {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.",
+ )}
+
+
+
+
+ {t("pages.agent.tools.web_search.save", "Save Changes")}
+
+
+
+
+
+
+
+ >
+ )}
+
+ )
+}
+
+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)}
/>