mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
575 lines
23 KiB
TypeScript
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>
|
|
)
|
|
}
|