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
This commit is contained in:
wenjie
2026-04-16 17:14:35 +08:00
committed by GitHub
parent e22b4e1eee
commit 7f56ca8cc6
12 changed files with 1138 additions and 607 deletions
@@ -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 (
<div className="animate-in fade-in slide-in-from-bottom-2 space-y-12 duration-500">
<div className="flex flex-col gap-6 pt-2 sm:flex-row sm:items-end sm:justify-between">
<div className="hidden max-w-sm space-y-2 md:block">
<h1 className="text-foreground/90 text-2xl font-semibold tracking-tight">
{t("pages.agent.tools.library_title", "Tool Library")}
</h1>
<p className="text-muted-foreground/80 text-[14px] leading-relaxed">
{t(
"pages.agent.tools.library_description",
"Browse and manage the toolset available to your AI agents.",
)}
</p>
</div>
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center md:w-auto">
<div className="group relative flex-1 md:w-80">
<IconSearch className="text-muted-foreground/60 group-focus-within:text-foreground/80 absolute top-1/2 left-3.5 size-4 -translate-y-1/2 transition-colors" />
<Input
type="text"
placeholder={t(
"pages.agent.tools.search_placeholder",
"Search tools...",
)}
className="bg-muted/40 hover:bg-muted/60 focus-visible:bg-background focus-visible:border-border/80 focus-visible:ring-foreground/5 h-11 w-full rounded-xl border-transparent pl-10 shadow-none transition-all duration-300"
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
/>
</div>
<Select
value={statusFilter}
onValueChange={(value) =>
onStatusFilterChange(value as ToolStatusFilter)
}
>
<SelectTrigger className="bg-muted/40 hover:bg-muted/60 focus:ring-foreground/5 focus:border-border/80 h-11 w-full rounded-xl border-transparent shadow-none transition-all duration-300 sm:w-36">
<SelectValue
placeholder={t("pages.agent.tools.filter.all", "All Status")}
/>
</SelectTrigger>
<SelectContent className="border-border/40 rounded-xl shadow-lg">
<SelectItem value="all">
{t("pages.agent.tools.filter.all", "All Status")}
</SelectItem>
<SelectItem value="enabled">
{t("pages.agent.tools.filter.enabled", "Enabled")}
</SelectItem>
<SelectItem value="disabled">
{t("pages.agent.tools.filter.disabled", "Disabled")}
</SelectItem>
<SelectItem value="blocked">
{t("pages.agent.tools.filter.blocked", "Blocked")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{hasError ? (
<div className="py-20 text-center">
<p className="text-destructive font-medium">
{t("pages.agent.load_error", "Failed to load tools")}
</p>
</div>
) : isLoading ? (
<LibraryLoadingState />
) : totalFilteredCount === 0 ? (
<LibraryEmptyState allToolsCount={allTools.length} />
) : (
<div className="space-y-12">
{groupedTools.map(([category, items]) => (
<section key={category} className="space-y-6">
<div className="flex items-center">
<h3 className="text-foreground/90 text-lg font-semibold tracking-tight capitalize">
{t(`pages.agent.tools.categories.${category}`, category)}
</h3>
</div>
<div className="grid gap-5 lg:grid-cols-2">
{items.map((tool) => (
<ToolCard
key={tool.name}
tool={tool}
isPending={pendingToolName === tool.name}
onToggleTool={onToggleTool}
/>
))}
</div>
</section>
))}
</div>
)}
</div>
)
}
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 (
<Card
className={cn(
"group bg-card border-border/40 flex flex-col shadow-none transition-all duration-300 sm:rounded-2xl",
isBlocked
? "border-amber-500/30 bg-amber-50/20 dark:border-amber-900/40 dark:bg-amber-950/20"
: "hover:border-border/80 hover:-translate-y-[2px] hover:shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] dark:hover:shadow-[0_4px_20px_-4px_rgba(255,255,255,0.02)]",
isDisabled && "opacity-[0.80] hover:opacity-100",
)}
>
<CardContent className="flex h-full flex-col p-6">
<div className="mb-3 flex items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center gap-3">
<h4 className="text-foreground/90 min-w-0 break-all font-mono text-sm font-semibold tracking-tight">
{tool.name}
</h4>
<ToolStatusBadge status={tool.status} />
</div>
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={(checked) => onToggleTool(tool.name, checked)}
className={cn(
"shrink-0",
isEnabled && "shadow-xs ring-1 ring-emerald-500/20",
)}
/>
</div>
<p className="text-muted-foreground/80 flex-1 text-[14px] leading-relaxed">
{tool.description}
</p>
{reasonText && (
<div className="border-border/40 mt-4 border-t pt-4">
<div className="inline-flex rounded-lg border border-amber-200/50 bg-amber-50/80 px-3 py-2 text-[13px] font-medium text-amber-600 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-400">
{reasonText}
</div>
</div>
)}
</CardContent>
</Card>
)
}
function LibraryLoadingState() {
return (
<div className="space-y-12">
{[1, 2].map((groupIndex) => (
<div key={groupIndex} className="space-y-6">
<Skeleton className="h-6 w-32 rounded-md" />
<div className="grid gap-5 lg:grid-cols-2">
{[1, 2].map((itemIndex) => (
<Skeleton key={itemIndex} className="h-36 rounded-2xl" />
))}
</div>
</div>
))}
</div>
)
}
function LibraryEmptyState({ allToolsCount }: { allToolsCount: number }) {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center py-32 text-center">
<div className="bg-muted/30 ring-border/10 mb-6 rounded-full p-6 shadow-xs ring-1">
<IconSearch className="text-muted-foreground/60 size-10" />
</div>
<h3 className="text-foreground/80 mb-2 text-xl font-semibold tracking-tight">
{allToolsCount === 0
? t("pages.agent.tools.empty", "No tools found")
: t("pages.agent.tools.no_results", "No matching tools")}
</h3>
{allToolsCount !== 0 && (
<p className="text-muted-foreground text-sm">
Try adjusting your search criteria or status filters.
</p>
)}
</div>
)
}
@@ -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 (
<span
className={cn(
"shrink-0 rounded-full px-2.5 py-0.5 text-[11px] font-medium tracking-wide sm:text-[11px]",
status === "enabled" &&
"bg-emerald-500/10 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400",
status === "blocked" &&
"bg-amber-500/10 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400",
status === "disabled" &&
"bg-muted text-muted-foreground/80 dark:bg-muted-foreground/20 dark:text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`, status)}
</span>
)
}
@@ -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<WebSearchConfigResponse | null>(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<string, ToolSupportItem[]>()
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 (
<div className="bg-background flex h-full flex-col">
<PageHeader title={t("navigation.tools")} />
<PageHeader title={t("navigation.tools", "Tools")} />
<ToolsTabs activeTab={activeTab} onChange={setActiveTab} />
<div className="flex-1 overflow-auto px-6 py-6">
<div className="mx-auto w-full max-w-6xl space-y-8">
{webSearchError ? (
<Card className="border-destructive/50 bg-destructive/10 cursor-default">
<CardHeader>
<CardTitle>{t("pages.agent.tools.web_search.title")}</CardTitle>
<CardDescription>
{t("pages.agent.tools.web_search.load_error")}
</CardDescription>
</CardHeader>
</Card>
) : isWebSearchLoading || !webSearchDraft ? (
<Card className="border-border/60 shadow-none">
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-80" />
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-24 w-full lg:col-span-2" />
</CardContent>
</Card>
<div className="flex-1 overflow-auto px-6 py-6 pb-20">
<div className="mx-auto w-full max-w-6xl">
{activeTab === "library" ? (
<ToolLibraryTab
allTools={tools}
groupedTools={groupedTools}
totalFilteredCount={totalFilteredCount}
searchQuery={searchQuery}
statusFilter={statusFilter}
isLoading={isToolsLoading}
hasError={hasToolsError}
pendingToolName={pendingToolName}
onSearchQueryChange={setSearchQuery}
onStatusFilterChange={setStatusFilter}
onToggleTool={toggleTool}
/>
) : (
<Card className="border-border/60 shadow-none">
<CardHeader>
<CardTitle>{t("pages.agent.tools.web_search.title")}</CardTitle>
<CardDescription>
{t("pages.agent.tools.web_search.description")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.current_service")}
</div>
<div className="text-muted-foreground rounded-md border px-3 py-2 text-sm">
{currentProviderLabel}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.provider")}
</div>
<Select
value={webSearchDraft.provider}
onValueChange={(value) =>
updateDraft((current) => ({
...current,
provider: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{webSearchDraft.providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.proxy")}
</div>
<Input
value={webSearchDraft.proxy ?? ""}
onChange={(e) =>
updateDraft((current) => ({
...current,
proxy: e.target.value,
}))
}
placeholder="http://127.0.0.1:7890"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md border px-4 py-3">
<div>
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.prefer_native")}
</div>
<div className="text-muted-foreground text-xs">
{t("pages.agent.tools.web_search.prefer_native_hint")}
</div>
</div>
<Switch
checked={webSearchDraft.prefer_native}
onCheckedChange={(checked) =>
updateDraft((current) => ({
...current,
prefer_native: checked,
}))
}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{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 (
<Card
key={providerId}
className="border-border/60 shadow-none"
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base">
{providerLabel}
</CardTitle>
<CardDescription className="mt-1 text-xs">
{t(
"pages.agent.tools.web_search.provider_hint",
)}
</CardDescription>
</div>
<Switch
checked={settings.enabled}
onCheckedChange={(checked) =>
updateDraft((current) => ({
...current,
settings: {
...current.settings,
[providerId]: {
...current.settings[providerId],
enabled: checked,
},
},
}))
}
/>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.max_results")}
</div>
<Input
type="number"
min={1}
max={10}
value={settings.max_results || 5}
onChange={(e) =>
updateDraft((current) => ({
...current,
settings: {
...current.settings,
[providerId]: {
...current.settings[providerId],
max_results:
Number(e.target.value) || 0,
},
},
}))
}
/>
</div>
{(providerId === "tavily" ||
providerId === "searxng" ||
providerId === "glm_search" ||
providerId === "baidu_search") && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.base_url")}
</div>
<Input
value={settings.base_url ?? ""}
onChange={(e) =>
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",
)}
/>
</div>
)}
{(providerId === "brave" ||
providerId === "tavily" ||
providerId === "perplexity" ||
providerId === "glm_search" ||
providerId === "baidu_search") && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.api_key")}
</div>
<KeyInput
value={settings.api_key ?? ""}
onChange={(value) =>
updateDraft((current) => ({
...current,
settings: {
...current.settings,
[providerId]: {
...current.settings[providerId],
api_key: value,
},
},
}))
}
placeholder={apiKeyPlaceholder}
/>
</div>
)}
</CardContent>
</Card>
)
},
)}
</div>
<div className="flex justify-end">
<Button
onClick={() => webSearchMutation.mutate(webSearchDraft)}
disabled={webSearchMutation.isPending}
>
{t("pages.agent.tools.web_search.save")}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Header & Description */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-end">
{/* Filters Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative">
<IconSearch className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
type="text"
placeholder={t("pages.agent.tools.search_placeholder")}
className="w-full pl-9 sm:w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue
placeholder={t("pages.agent.tools.filter.all")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("pages.agent.tools.filter.all")}
</SelectItem>
<SelectItem value="enabled">
{t("pages.agent.tools.filter.enabled")}
</SelectItem>
<SelectItem value="disabled">
{t("pages.agent.tools.filter.disabled")}
</SelectItem>
<SelectItem value="blocked">
{t("pages.agent.tools.filter.blocked")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Content Area */}
{error ? (
<Card className="border-destructive/50 bg-destructive/10 cursor-default">
<CardContent className="py-10 text-center">
<p className="text-destructive font-medium">
{t("pages.agent.load_error")}
</p>
</CardContent>
</Card>
) : isLoading ? (
// Skeleton Loading State
<div className="space-y-8">
{[1, 2].map((groupIndex) => (
<div key={groupIndex} className="space-y-4">
<Skeleton className="h-5 w-32" />
<div className="grid gap-4 lg:grid-cols-2">
{[1, 2, 3, 4].map((itemIndex) => (
<Card
key={itemIndex}
className="border-border/60 shadow-none"
>
<CardHeader className="pb-3">
<Skeleton className="mb-2 h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardHeader>
<CardContent>
<Skeleton className="mt-2 h-8 w-full rounded-md" />
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
) : totalFilteredCount === 0 ? (
// Empty State
<Card className="bg-muted/30 cursor-default border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center text-sm">
<div className="bg-muted mb-4 rounded-full p-4">
<IconSearch className="text-muted-foreground size-8" />
</div>
<h3 className="mb-1 text-lg font-medium">
{data?.tools.length === 0
? t("pages.agent.tools.empty")
: t("pages.agent.tools.no_results")}
</h3>
{data?.tools.length !== 0 && (
<p className="text-muted-foreground">
Try adjusting your search criteria or status filters.
</p>
)}
</CardContent>
</Card>
) : (
// Tool Categories list
<div className="space-y-8">
{groupedTools.map(([category, items]) => (
<div key={category} className="space-y-4">
<h3 className="text-foreground text-sm font-semibold tracking-wide uppercase">
{t(`pages.agent.tools.categories.${category}`)}
</h3>
<div className="grid gap-4 lg:grid-cols-2">
{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 (
<Card
key={tool.name}
className={cn(
"group cursor-default transition-colors",
isBlocked
? "border-amber-200/80 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/60",
isDisabled && "opacity-80",
)}
>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CardTitle className="font-mono text-sm font-semibold break-all">
{tool.name}
</CardTitle>
<ToolStatusBadge status={tool.status} />
</div>
<CardDescription className="text-muted-foreground/80 mt-2 text-xs leading-relaxed break-words sm:text-sm">
{tool.description}
</CardDescription>
</div>
<div className="flex shrink-0 items-center pt-1 pl-2 sm:pt-0">
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={(checked) =>
toggleMutation.mutate({
name: tool.name,
enabled: checked,
})
}
/>
</div>
</div>
</CardHeader>
{reasonText && (
<CardContent className="pt-0 pb-4">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">
{reasonText}
</div>
</CardContent>
)}
</Card>
)
})}
</div>
</div>
))}
</div>
<WebSearchTab
draft={webSearchDraft}
currentProviderLabel={currentProviderLabel}
providerLabelMap={providerLabelMap}
expandedProvider={expandedProvider}
isLoading={isWebSearchLoading}
hasError={hasWebSearchError}
isSaving={isWebSearchSaving}
onSave={saveWebSearchConfig}
onToggleProviderExpand={toggleExpandedProvider}
onUpdateDraft={updateWebSearchDraft}
/>
)}
</div>
</div>
</div>
)
}
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
const { t } = useTranslation()
return (
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide sm:text-[11px]",
status === "enabled" &&
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
status === "blocked" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
status === "disabled" && "bg-muted text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`)}
</span>
)
}
@@ -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 (
<div className="border-border/60 border-b px-6 pt-2">
<div className="flex gap-8">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => 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 && (
<span className="bg-primary absolute inset-x-0 bottom-0 h-[2px] rounded-t-full" />
)}
</button>
))}
</div>
</div>
)
}
@@ -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
@@ -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<ToolsPageTab>("library")
const [searchQuery, setSearchQuery] = useState("")
const deferredSearchQuery = useDeferredValue(searchQuery)
const [statusFilter, setStatusFilter] = useState<ToolStatusFilter>("all")
const [expandedProvider, setExpandedProvider] = useState<string | null>(null)
const [webSearchDraftOverride, setWebSearchDraftOverride] =
useState<WebSearchConfigResponse | null>(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<string, typeof tools>()
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,
}
}
@@ -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 (
<div className="space-y-4">
<h3 className="text-foreground/80 text-[13px] font-bold tracking-widest uppercase">
{t("pages.agent.tools.web_search.global_settings", "General")}
</h3>
<div className="bg-card border-border/40 divide-border/40 divide-y overflow-hidden rounded-2xl border shadow-sm">
<SettingRow
label={t("pages.agent.tools.web_search.provider", "Primary Provider")}
description={t(
"pages.agent.tools.web_search.provider_description",
"Select the default search engine that agents will fallback to.",
)}
>
<Select
value={draft.provider}
onValueChange={(value) =>
onUpdateDraft((current) => ({
...current,
provider: value,
}))
}
>
<SelectTrigger className="bg-muted/40 hover:bg-muted/60 focus:ring-foreground/5 focus:border-border/80 w-full rounded-xl border-transparent shadow-none transition-all sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-border/40 rounded-xl shadow-lg">
{draft.providers.map((provider) => (
<SelectItem
key={provider.id}
value={provider.id}
className="rounded-lg"
>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t(
"pages.agent.tools.web_search.proxy",
"Proxy Configuration",
)}
description={t(
"pages.agent.tools.web_search.proxy_description",
"Optional global HTTP/S proxy for underlying web requests.",
)}
>
<Input
className="bg-muted/40 hover:bg-muted/60 focus-visible:bg-background focus-visible:border-border/80 focus-visible:ring-foreground/5 w-full rounded-xl border-transparent shadow-none transition-all duration-300 sm:w-64"
value={draft.proxy ?? ""}
onChange={(event) =>
onUpdateDraft((current) => ({
...current,
proxy: event.target.value,
}))
}
placeholder="http://127.0.0.1:7890"
/>
</SettingRow>
<SettingRow
label={t(
"pages.agent.tools.web_search.prefer_native",
"Prefer Native Search",
)}
description={t(
"pages.agent.tools.web_search.prefer_native_hint",
"Bypass external providers if the agent inherently supports web search tools.",
)}
>
<Switch
checked={draft.prefer_native}
onCheckedChange={(checked) =>
onUpdateDraft((current) => ({
...current,
prefer_native: checked,
}))
}
className="data-[state=checked]:shadow-xs"
/>
</SettingRow>
</div>
</div>
)
}
function SettingRow({
label,
description,
children,
}: {
label: string
description: string
children: ReactNode
}) {
return (
<div className="hover:bg-muted/10 flex flex-col justify-between gap-4 p-5 transition-colors sm:flex-row sm:items-center">
<div className="w-full space-y-1 sm:max-w-md">
<label className="text-foreground/90 text-[15px] font-semibold tracking-tight">
{label}
</label>
<p className="text-muted-foreground/80 text-[13px] leading-relaxed">
{description}
</p>
</div>
{children}
</div>
)
}
@@ -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<string, string>
settings: Record<string, WebSearchProviderConfig>
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 (
<div className="space-y-4">
<h3 className="text-foreground/80 text-[13px] font-bold tracking-widest uppercase">
{t("pages.agent.tools.web_search.providers_config", "Integrations")}
</h3>
<div className="bg-card border-border/40 divide-border/40 divide-y overflow-hidden rounded-2xl border shadow-sm">
{Object.entries(settings).map(([providerId, providerSettings]) => (
<ProviderCard
key={providerId}
providerId={providerId}
providerLabel={providerLabelMap.get(providerId) ?? providerId}
settings={providerSettings}
isExpanded={expandedProvider === providerId}
onToggleExpand={onToggleProviderExpand}
onUpdateDraft={onUpdateDraft}
/>
))}
</div>
</div>
)
}
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 (
<div
className={cn(
"group flex flex-col transition-colors",
isExpanded ? "bg-muted/5" : "hover:bg-muted/20",
)}
>
<div className="flex items-center justify-between gap-4 p-5">
<button
type="button"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-4 text-left select-none"
aria-expanded={isExpanded}
aria-controls={`web-search-provider-${providerId}`}
onClick={() => onToggleExpand(providerId)}
>
<div
className={cn(
"text-muted-foreground flex items-center justify-center transition-transform duration-300",
isExpanded && "rotate-180",
)}
>
<IconChevronDown className="size-[18px]" />
</div>
<div className="flex items-center gap-3">
<span className="text-foreground/90 text-[15px] font-semibold tracking-tight">
{providerLabel}
</span>
{settings.enabled ? (
<span className="inline-block rounded-md bg-emerald-500/10 px-2 py-0.5 text-[10px] font-bold tracking-wider text-emerald-600 uppercase dark:text-emerald-400">
{t("pages.agent.tools.filter.enabled", "Enabled")}
</span>
) : (
<span className="bg-muted text-muted-foreground/70 inline-block rounded-md px-2 py-0.5 text-[10px] font-bold tracking-wider uppercase">
{t("pages.agent.tools.filter.disabled", "Disabled")}
</span>
)}
</div>
</button>
<div
className="flex items-center gap-4"
onClick={(event) => event.stopPropagation()}
>
<Switch
checked={settings.enabled}
onCheckedChange={(checked) =>
updateSettings((current) => ({
...current,
enabled: checked,
}))
}
/>
</div>
</div>
{isExpanded && (
<div
id={`web-search-provider-${providerId}`}
className="animate-in fade-in slide-in-from-top-1 border-border/10 border-t px-6 pt-1 pb-6 duration-200"
>
<div className="ml-8 flex max-w-xl flex-col gap-5">
<ProviderField
label={t("pages.agent.tools.web_search.max_results", "Max Results")}
>
<Input
type="number"
min={1}
max={10}
value={settings.max_results || 5}
onChange={(event) =>
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"
/>
</ProviderField>
{baseUrlProviders.has(providerId) && (
<ProviderField
label={t("pages.agent.tools.web_search.base_url", "Base URL")}
>
<Input
value={settings.base_url ?? ""}
onChange={(event) =>
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"
/>
</ProviderField>
)}
{apiKeyProviders.has(providerId) && (
<ProviderField
label={t(
"pages.agent.tools.web_search.api_key",
"API Key / Token",
)}
className="pt-1"
>
<KeyInput
value={settings.api_key ?? ""}
onChange={(value) =>
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"
/>
</ProviderField>
)}
</div>
</div>
)}
</div>
)
}
function ProviderField({
label,
className,
children,
}: {
label: string
className?: string
children: ReactNode
}) {
return (
<div className={cn("space-y-1.5", className)}>
<label className="text-foreground/80 text-[13px] font-semibold">
{label}
</label>
{children}
</div>
)
}
@@ -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<string, string>
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 (
<div className="animate-in fade-in slide-in-from-bottom-2 space-y-12 pt-2 duration-500">
{hasError ? (
<div className="py-20 text-center">
<p className="text-destructive font-medium">
{t(
"pages.agent.tools.web_search.load_error",
"Failed to load web search configuration",
)}
</p>
</div>
) : isLoading || !draft ? (
<LoadingState />
) : (
<>
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-xl space-y-3">
<div className="flex items-center gap-3">
<h1 className="text-foreground/90 text-2xl font-semibold tracking-tight">
{t(
"pages.agent.tools.web_search.title",
"Web Search Configuration",
)}
</h1>
<div className="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-semibold tracking-wide text-emerald-600 uppercase dark:text-emerald-400">
{currentProviderLabel}
</div>
</div>
<p className="text-muted-foreground/80 text-[14px] leading-relaxed">
{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.",
)}
</p>
</div>
<Button
onClick={onSave}
disabled={isSaving}
className="h-10 shrink-0 rounded-xl px-6 shadow-sm transition-all active:scale-95"
>
{t("pages.agent.tools.web_search.save", "Save Changes")}
</Button>
</div>
<div className="space-y-10">
<WebSearchGeneralSettings
draft={draft}
onUpdateDraft={onUpdateDraft}
/>
<WebSearchProviderSettings
providerLabelMap={providerLabelMap}
settings={draft.settings}
expandedProvider={expandedProvider}
onToggleProviderExpand={onToggleProviderExpand}
onUpdateDraft={onUpdateDraft}
/>
</div>
</>
)}
</div>
)
}
function LoadingState() {
return (
<div className="space-y-8">
<Skeleton className="h-24 rounded-2xl" />
<Skeleton className="h-64 rounded-2xl" />
</div>
)
}
+3 -2
View File
@@ -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)}
/>
<button
type="button"
+22 -15
View File
@@ -525,32 +525,39 @@
"no_results": "No tools match your criteria.",
"filter": {
"all": "All Status",
"enabled": "Enabled only",
"disabled": "Disabled only",
"blocked": "Blocked only"
"enabled": "Enabled",
"disabled": "Disabled",
"blocked": "Blocked"
},
"empty": "No tools are available.",
"enable_success": "Tool enabled.",
"disable_success": "Tool disabled.",
"toggle_error": "Failed to update tool state.",
"library_title": "Tool Library",
"library_description": "Browse and manage the toolset available to your AI agents.",
"web_search": {
"title": "Web Search Service",
"description": "Choose the default web search backend and configure supported providers.",
"title": "Web Search",
"description": "Provide web search capability for agents to find the latest real-world info. Automatically routes to the optimal active provider.",
"global_settings": "General",
"providers_config": "Integrations",
"load_error": "Failed to load web search configuration.",
"save": "Save Web Search Settings",
"save_success": "Web search configuration updated.",
"save_error": "Failed to update web search configuration.",
"save": "Save Changes",
"save_success": "Settings saved successfully.",
"save_error": "Failed to save settings.",
"current_active": "Active: ",
"current_service": "Current Service",
"provider": "Preferred Provider",
"proxy": "Proxy",
"prefer_native": "Prefer Provider Native Search",
"prefer_native_hint": "When the active model supports built-in web search, prefer that capability over the client-side tool.",
"provider": "Primary Provider",
"provider_description": "Select the default search engine that agents will fallback to.",
"proxy": "HTTPS Proxy",
"proxy_description": "Optional global HTTP/S proxy for underlying web requests.",
"prefer_native": "Prefer Native Search",
"prefer_native_hint": "Bypass external providers if the agent inherently supports web search tools.",
"provider_hint": "Enable this provider and fill any required connection settings.",
"max_results": "Max Results",
"base_url": "Base URL",
"base_url_placeholder": "https://api.example.com/search",
"api_key": "API Key",
"api_key_placeholder": "Leave blank to keep the existing key",
"base_url_placeholder": "Optional endpoint override",
"api_key": "API Key / Token",
"api_key_placeholder": "Enter API key, leave it blank to keep the original key",
"none": "Unavailable"
},
"status": {
+21 -14
View File
@@ -533,25 +533,32 @@
"enable_success": "工具已启用。",
"disable_success": "工具已禁用。",
"toggle_error": "更新工具状态失败。",
"library_title": "工具库",
"library_description": "浏览并管理由您的 AI 智能体支持的集成工具。",
"web_search": {
"title": "Web Search 服务",
"description": "选择默认网页搜索后端,并配置已支持的搜索服务。",
"title": "网页搜索",
"description": "为智能体提供网页搜索能力。自动路由到当前处于激活状态的最佳服务。",
"global_settings": "常规",
"providers_config": "集成",
"load_error": "加载 Web Search 配置失败。",
"save": "保存 Web Search 配置",
"save_success": "Web Search 配置已更新。",
"save_error": "更新 Web Search 配置失败。",
"save": "保存更改",
"save_success": "设置保存成功。",
"save_error": "保存设置失败。",
"current_active": "活动: ",
"current_service": "当前服务",
"provider": "首选服务",
"proxy": "代理",
"prefer_native": "优先使用模型原生搜索",
"prefer_native_hint": "如果当前模型支持内建网页搜索,优先使用模型原生能力而不是客户端工具。",
"provider_description": "选择智能体在默认情况下进行网络搜索的回退引擎。",
"proxy": "HTTPS 代理",
"proxy_description": "用于底层网页请求的可选全局代理配置。",
"prefer_native": "优先使用模型搜索",
"prefer_native_hint": "如果当前模型本身支持联网功能,则直接使用模型自带的搜索能力",
"provider_hint": "启用该服务后,可继续填写所需的连接参数。",
"max_results": "最大结果数",
"base_url": "基础 URL",
"base_url_placeholder": "https://api.example.com/search",
"api_key": "API Key",
"api_key_placeholder": "留空则保留现有密钥",
"none": "不可用"
"max_results": "最大获取结果数",
"base_url": "API 请求地址",
"base_url_placeholder": "可选,如果需要代理请覆盖终端地址",
"api_key": "API 密钥 (Token)",
"api_key_placeholder": "请输入密钥,留空则保持原密钥不变",
"none": "未配置"
},
"status": {
"enabled": "已启用",