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:
parent
e22b4e1eee
commit
7f56ca8cc6
12 changed files with 1138 additions and 607 deletions
245
web/frontend/src/components/agent/tools/tool-library-tab.tsx
Normal file
245
web/frontend/src/components/agent/tools/tool-library-tab.tsx
Normal file
|
|
@ -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 { 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 { PageHeader } from "@/components/page-header"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
|
||||||
import { KeyInput } from "@/components/shared-form"
|
import { ToolLibraryTab } from "./tool-library-tab"
|
||||||
import { Button } from "@/components/ui/button"
|
import { ToolsTabs } from "./tools-tabs"
|
||||||
import {
|
import { useToolsPage } from "./use-tools-page"
|
||||||
Card,
|
import { WebSearchTab } from "./web-search-tab"
|
||||||
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() {
|
export function ToolsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: ["tools"],
|
|
||||||
queryFn: getTools,
|
|
||||||
})
|
|
||||||
const {
|
const {
|
||||||
data: webSearchData,
|
activeTab,
|
||||||
isLoading: isWebSearchLoading,
|
currentProviderLabel,
|
||||||
error: webSearchError,
|
expandedProvider,
|
||||||
} = useQuery({
|
groupedTools,
|
||||||
queryKey: ["tools", "web-search-config"],
|
pendingToolName,
|
||||||
queryFn: getWebSearchConfig,
|
providerLabelMap,
|
||||||
})
|
searchQuery,
|
||||||
|
statusFilter,
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
tools,
|
||||||
const [statusFilter, setStatusFilter] = useState("all")
|
totalFilteredCount,
|
||||||
const [webSearchDraftOverride, setWebSearchDraftOverride] =
|
webSearchDraft,
|
||||||
useState<WebSearchConfigResponse | null>(null)
|
hasToolsError,
|
||||||
const webSearchDraft = webSearchDraftOverride ?? webSearchData ?? null
|
hasWebSearchError,
|
||||||
|
isToolsLoading,
|
||||||
const toggleMutation = useMutation({
|
isWebSearchLoading,
|
||||||
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
|
isWebSearchSaving,
|
||||||
setToolEnabled(name, enabled),
|
setActiveTab,
|
||||||
onSuccess: (_, variables) => {
|
setSearchQuery,
|
||||||
toast.success(
|
setStatusFilter,
|
||||||
variables.enabled
|
saveWebSearchConfig,
|
||||||
? t("pages.agent.tools.enable_success")
|
toggleExpandedProvider,
|
||||||
: t("pages.agent.tools.disable_success"),
|
toggleTool,
|
||||||
)
|
updateWebSearchDraft,
|
||||||
void queryClient.invalidateQueries({ queryKey: ["tools"] })
|
} = useToolsPage()
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex h-full flex-col">
|
<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="flex-1 overflow-auto px-6 py-6 pb-20">
|
||||||
<div className="mx-auto w-full max-w-6xl space-y-8">
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
{webSearchError ? (
|
{activeTab === "library" ? (
|
||||||
<Card className="border-destructive/50 bg-destructive/10 cursor-default">
|
<ToolLibraryTab
|
||||||
<CardHeader>
|
allTools={tools}
|
||||||
<CardTitle>{t("pages.agent.tools.web_search.title")}</CardTitle>
|
groupedTools={groupedTools}
|
||||||
<CardDescription>
|
totalFilteredCount={totalFilteredCount}
|
||||||
{t("pages.agent.tools.web_search.load_error")}
|
searchQuery={searchQuery}
|
||||||
</CardDescription>
|
statusFilter={statusFilter}
|
||||||
</CardHeader>
|
isLoading={isToolsLoading}
|
||||||
</Card>
|
hasError={hasToolsError}
|
||||||
) : isWebSearchLoading || !webSearchDraft ? (
|
pendingToolName={pendingToolName}
|
||||||
<Card className="border-border/60 shadow-none">
|
onSearchQueryChange={setSearchQuery}
|
||||||
<CardHeader>
|
onStatusFilterChange={setStatusFilter}
|
||||||
<Skeleton className="h-5 w-48" />
|
onToggleTool={toggleTool}
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-border/60 shadow-none">
|
<WebSearchTab
|
||||||
<CardHeader>
|
draft={webSearchDraft}
|
||||||
<CardTitle>{t("pages.agent.tools.web_search.title")}</CardTitle>
|
currentProviderLabel={currentProviderLabel}
|
||||||
<CardDescription>
|
providerLabelMap={providerLabelMap}
|
||||||
{t("pages.agent.tools.web_search.description")}
|
expandedProvider={expandedProvider}
|
||||||
</CardDescription>
|
isLoading={isWebSearchLoading}
|
||||||
</CardHeader>
|
hasError={hasWebSearchError}
|
||||||
<CardContent className="space-y-6">
|
isSaving={isWebSearchSaving}
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
onSave={saveWebSearchConfig}
|
||||||
<div className="space-y-2">
|
onToggleProviderExpand={toggleExpandedProvider}
|
||||||
<div className="text-sm font-medium">
|
onUpdateDraft={updateWebSearchDraft}
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
56
web/frontend/src/components/agent/tools/tools-tabs.tsx
Normal file
56
web/frontend/src/components/agent/tools/tools-tabs.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
web/frontend/src/components/agent/tools/types.ts
Normal file
9
web/frontend/src/components/agent/tools/types.ts
Normal file
|
|
@ -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
|
||||||
194
web/frontend/src/components/agent/tools/use-tools-page.ts
Normal file
194
web/frontend/src/components/agent/tools/use-tools-page.ts
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
web/frontend/src/components/agent/tools/web-search-tab.tsx
Normal file
109
web/frontend/src/components/agent/tools/web-search-tab.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -90,9 +90,10 @@ interface KeyInputProps {
|
||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyInput({ value, onChange, placeholder }: KeyInputProps) {
|
export function KeyInput({ value, onChange, placeholder, className }: KeyInputProps) {
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -102,7 +103,7 @@ export function KeyInput({ value, onChange, placeholder }: KeyInputProps) {
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="pr-10"
|
className={cn("pr-10", className)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -525,32 +525,39 @@
|
||||||
"no_results": "No tools match your criteria.",
|
"no_results": "No tools match your criteria.",
|
||||||
"filter": {
|
"filter": {
|
||||||
"all": "All Status",
|
"all": "All Status",
|
||||||
"enabled": "Enabled only",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled only",
|
"disabled": "Disabled",
|
||||||
"blocked": "Blocked only"
|
"blocked": "Blocked"
|
||||||
},
|
},
|
||||||
"empty": "No tools are available.",
|
"empty": "No tools are available.",
|
||||||
"enable_success": "Tool enabled.",
|
"enable_success": "Tool enabled.",
|
||||||
"disable_success": "Tool disabled.",
|
"disable_success": "Tool disabled.",
|
||||||
"toggle_error": "Failed to update tool state.",
|
"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": {
|
"web_search": {
|
||||||
"title": "Web Search Service",
|
"title": "Web Search",
|
||||||
"description": "Choose the default web search backend and configure supported providers.",
|
"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.",
|
"load_error": "Failed to load web search configuration.",
|
||||||
"save": "Save Web Search Settings",
|
"save": "Save Changes",
|
||||||
"save_success": "Web search configuration updated.",
|
"save_success": "Settings saved successfully.",
|
||||||
"save_error": "Failed to update web search configuration.",
|
"save_error": "Failed to save settings.",
|
||||||
|
"current_active": "Active: ",
|
||||||
"current_service": "Current Service",
|
"current_service": "Current Service",
|
||||||
"provider": "Preferred Provider",
|
"provider": "Primary Provider",
|
||||||
"proxy": "Proxy",
|
"provider_description": "Select the default search engine that agents will fallback to.",
|
||||||
"prefer_native": "Prefer Provider Native Search",
|
"proxy": "HTTPS Proxy",
|
||||||
"prefer_native_hint": "When the active model supports built-in web search, prefer that capability over the client-side tool.",
|
"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.",
|
"provider_hint": "Enable this provider and fill any required connection settings.",
|
||||||
"max_results": "Max Results",
|
"max_results": "Max Results",
|
||||||
"base_url": "Base URL",
|
"base_url": "Base URL",
|
||||||
"base_url_placeholder": "https://api.example.com/search",
|
"base_url_placeholder": "Optional endpoint override",
|
||||||
"api_key": "API Key",
|
"api_key": "API Key / Token",
|
||||||
"api_key_placeholder": "Leave blank to keep the existing key",
|
"api_key_placeholder": "Enter API key, leave it blank to keep the original key",
|
||||||
"none": "Unavailable"
|
"none": "Unavailable"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
|
|
||||||
|
|
@ -533,25 +533,32 @@
|
||||||
"enable_success": "工具已启用。",
|
"enable_success": "工具已启用。",
|
||||||
"disable_success": "工具已禁用。",
|
"disable_success": "工具已禁用。",
|
||||||
"toggle_error": "更新工具状态失败。",
|
"toggle_error": "更新工具状态失败。",
|
||||||
|
"library_title": "工具库",
|
||||||
|
"library_description": "浏览并管理由您的 AI 智能体支持的集成工具。",
|
||||||
"web_search": {
|
"web_search": {
|
||||||
"title": "Web Search 服务",
|
"title": "网页搜索",
|
||||||
"description": "选择默认网页搜索后端,并配置已支持的搜索服务。",
|
"description": "为智能体提供网页搜索能力。自动路由到当前处于激活状态的最佳服务。",
|
||||||
|
"global_settings": "常规",
|
||||||
|
"providers_config": "集成",
|
||||||
"load_error": "加载 Web Search 配置失败。",
|
"load_error": "加载 Web Search 配置失败。",
|
||||||
"save": "保存 Web Search 配置",
|
"save": "保存更改",
|
||||||
"save_success": "Web Search 配置已更新。",
|
"save_success": "设置保存成功。",
|
||||||
"save_error": "更新 Web Search 配置失败。",
|
"save_error": "保存设置失败。",
|
||||||
|
"current_active": "活动: ",
|
||||||
"current_service": "当前服务",
|
"current_service": "当前服务",
|
||||||
"provider": "首选服务",
|
"provider": "首选服务",
|
||||||
"proxy": "代理",
|
"provider_description": "选择智能体在默认情况下进行网络搜索的回退引擎。",
|
||||||
"prefer_native": "优先使用模型原生搜索",
|
"proxy": "HTTPS 代理",
|
||||||
"prefer_native_hint": "如果当前模型支持内建网页搜索,优先使用模型原生能力而不是客户端工具。",
|
"proxy_description": "用于底层网页请求的可选全局代理配置。",
|
||||||
|
"prefer_native": "优先使用模型搜索",
|
||||||
|
"prefer_native_hint": "如果当前模型本身支持联网功能,则直接使用模型自带的搜索能力",
|
||||||
"provider_hint": "启用该服务后,可继续填写所需的连接参数。",
|
"provider_hint": "启用该服务后,可继续填写所需的连接参数。",
|
||||||
"max_results": "最大结果数",
|
"max_results": "最大获取结果数",
|
||||||
"base_url": "基础 URL",
|
"base_url": "API 请求地址",
|
||||||
"base_url_placeholder": "https://api.example.com/search",
|
"base_url_placeholder": "可选,如果需要代理请覆盖终端地址",
|
||||||
"api_key": "API Key",
|
"api_key": "API 密钥 (Token)",
|
||||||
"api_key_placeholder": "留空则保留现有密钥",
|
"api_key_placeholder": "请输入密钥,留空则保持原密钥不变",
|
||||||
"none": "不可用"
|
"none": "未配置"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue