Files
picoclaw/web/frontend/src/components/agent/tools/tools-page.tsx
T
2026-04-14 22:58:07 +08:00

575 lines
23 KiB
TypeScript

import { IconSearch } from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
getTools,
getWebSearchConfig,
setToolEnabled,
type ToolSupportItem,
type WebSearchConfigResponse,
updateWebSearchConfig,
} from "@/api/tools"
import { PageHeader } from "@/components/page-header"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { KeyInput } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { refreshGatewayState } from "@/store/gateway"
export function ToolsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const {
data: webSearchData,
isLoading: isWebSearchLoading,
error: webSearchError,
} = useQuery({
queryKey: ["tools", "web-search-config"],
queryFn: getWebSearchConfig,
})
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [webSearchDraft, setWebSearchDraft] =
useState<WebSearchConfigResponse | null>(null)
useEffect(() => {
if (webSearchData) {
setWebSearchDraft(webSearchData)
}
}, [webSearchData])
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) => {
setWebSearchDraft(updated)
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,
) => {
setWebSearchDraft((current) => (current ? updater(current) : current))
}
return (
<div className="bg-background flex h-full flex-col">
<PageHeader title={t("navigation.tools")} />
<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>
) : (
<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>
)}
</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>
)
}