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