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" 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 }) } return (
{webSearchError ? ( {t("pages.agent.tools.web_search.title")} {t("pages.agent.tools.web_search.load_error")} ) : isWebSearchLoading || !webSearchDraft ? ( ) : ( {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}`)} ) }