mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): add agent management UI and improve launcher integration (#1358)
* Improve the web launcher and gateway integration across backend and frontend. - add runtime model availability checks for local and OAuth-backed models - support launcher-driven gateway host overrides and websocket URL resolution - add gateway log clearing and keep incremental log sync consistent after resets - migrate session history APIs to JSONL metadata-backed storage with legacy fallback - expose session titles and improve chat history loading and error handling - move shared backend runtime helpers into the web utils package - avoid blocking web startup when automatic onboard initialization fails - add backend tests covering gateway readiness, host resolution, models, logs, and sessions * feat(agent): add skills and tools management APIs and UI - add backend APIs to list, view, import, and delete skills - add tool status and toggle endpoints with dependency-aware config updates - add agent skills/tools pages, routes, sidebar entries, and i18n strings - add backend tests for the new skills and tools flows * chore(frontend): upgrade shadcn to 4.0.5 and refresh lockfile * chore(web): keep backend dist placeholder tracked
This commit is contained in:
@@ -7,6 +7,8 @@ import {
|
||||
IconListDetails,
|
||||
IconMessageCircle,
|
||||
IconSettings,
|
||||
IconSparkles,
|
||||
IconTools,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link, useRouterState } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
@@ -53,6 +55,10 @@ const baseNavGroups: Omit<NavGroup, "items">[] = [
|
||||
label: "navigation.model_group",
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
label: "navigation.agent_group",
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
label: "navigation.services",
|
||||
defaultOpen: true,
|
||||
@@ -113,6 +119,23 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
},
|
||||
{
|
||||
...baseNavGroups[2],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.skills",
|
||||
url: "/agent/skills",
|
||||
icon: IconSparkles,
|
||||
translateTitle: true,
|
||||
},
|
||||
{
|
||||
title: "navigation.tools",
|
||||
url: "/agent/tools",
|
||||
icon: IconTools,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...baseNavGroups[3],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.config",
|
||||
|
||||
@@ -43,11 +43,18 @@ export function ChatPage() {
|
||||
handleSetDefault,
|
||||
} = useChatModels({ isConnected })
|
||||
|
||||
const { sessions, hasMore, observerRef, loadSessions, handleDeleteSession } =
|
||||
useSessionHistory({
|
||||
activeSessionId,
|
||||
onDeletedActiveSession: newChat,
|
||||
})
|
||||
const {
|
||||
sessions,
|
||||
hasMore,
|
||||
loadError,
|
||||
loadErrorMessage,
|
||||
observerRef,
|
||||
loadSessions,
|
||||
handleDeleteSession,
|
||||
} = useSessionHistory({
|
||||
activeSessionId,
|
||||
onDeletedActiveSession: newChat,
|
||||
})
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
|
||||
@@ -96,6 +103,8 @@ export function ChatPage() {
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
hasMore={hasMore}
|
||||
loadError={loadError}
|
||||
loadErrorMessage={loadErrorMessage}
|
||||
observerRef={observerRef}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
|
||||
@@ -17,6 +17,8 @@ interface SessionHistoryMenuProps {
|
||||
sessions: SessionSummary[]
|
||||
activeSessionId: string
|
||||
hasMore: boolean
|
||||
loadError: boolean
|
||||
loadErrorMessage: string
|
||||
observerRef: RefObject<HTMLDivElement | null>
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSwitchSession: (sessionId: string) => void
|
||||
@@ -27,6 +29,8 @@ export function SessionHistoryMenu({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
hasMore,
|
||||
loadError,
|
||||
loadErrorMessage,
|
||||
observerRef,
|
||||
onOpenChange,
|
||||
onSwitchSession,
|
||||
@@ -44,7 +48,14 @@ export function SessionHistoryMenu({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
{sessions.length === 0 ? (
|
||||
{loadError && (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-destructive text-xs">
|
||||
{loadErrorMessage}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{sessions.length === 0 && !loadError ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.noHistory")}
|
||||
@@ -60,7 +71,7 @@ export function SessionHistoryMenu({
|
||||
onClick={() => onSwitchSession(session.id)}
|
||||
>
|
||||
<span className="line-clamp-1 text-sm font-medium">
|
||||
{session.preview}
|
||||
{session.title || session.preview}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.messagesCount", {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
IconFileInfo,
|
||||
IconLoader2,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { type ChangeEvent, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
type SkillSupportItem,
|
||||
deleteSkill,
|
||||
getSkill,
|
||||
getSkills,
|
||||
importSkill,
|
||||
} from "@/api/skills"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
export function SkillsPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillSupportItem | null>(
|
||||
null,
|
||||
)
|
||||
const [skillPendingDelete, setSkillPendingDelete] =
|
||||
useState<SkillSupportItem | null>(null)
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills"],
|
||||
queryFn: getSkills,
|
||||
})
|
||||
const {
|
||||
data: selectedSkillDetail,
|
||||
isLoading: isSkillDetailLoading,
|
||||
error: skillDetailError,
|
||||
} = useQuery({
|
||||
queryKey: ["skills", selectedSkill?.name],
|
||||
queryFn: () => getSkill(selectedSkill!.name),
|
||||
enabled: selectedSkill !== null,
|
||||
})
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (file: File) => importSkill(file),
|
||||
onSuccess: () => {
|
||||
toast.success(t("pages.agent.skills.import_success"))
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("pages.agent.skills.import_error"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (name: string) => deleteSkill(name),
|
||||
onSuccess: (_, deletedName) => {
|
||||
toast.success(t("pages.agent.skills.delete_success"))
|
||||
setSkillPendingDelete(null)
|
||||
if (
|
||||
selectedSkill?.name === deletedName &&
|
||||
selectedSkill.source === "workspace"
|
||||
) {
|
||||
setSelectedSkill(null)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("pages.agent.skills.delete_error"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const handleImportClick = () => {
|
||||
importInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleImportFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
importMutation.mutate(file)
|
||||
event.target.value = ""
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={t("navigation.skills")}
|
||||
children={
|
||||
<>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={handleImportFileChange}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleImportClick}
|
||||
disabled={importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<IconPlus className="size-4" />
|
||||
)}
|
||||
{t("pages.agent.skills.import")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-3">
|
||||
<div className="w-full max-w-6xl space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-6 text-sm">
|
||||
{t("labels.loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{t("pages.agent.load_error")}
|
||||
</div>
|
||||
) : (
|
||||
<section className="space-y-5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("pages.agent.skills.description")}
|
||||
</p>
|
||||
|
||||
{data?.skills.length ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{data.skills.map((skill) => (
|
||||
<Card
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
className="border-border/60 gap-4 bg-white/80"
|
||||
size="sm"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="font-semibold">
|
||||
{skill.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-3">
|
||||
{skill.description ||
|
||||
t("pages.agent.skills.no_description")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSelectedSkill(skill)}
|
||||
title={t("pages.agent.skills.view")}
|
||||
>
|
||||
<IconFileInfo className="size-4" />
|
||||
</Button>
|
||||
{skill.source === "workspace" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setSkillPendingDelete(skill)}
|
||||
title={t("pages.agent.skills.delete")}
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
|
||||
{t("pages.agent.skills.path")}
|
||||
</div>
|
||||
<div className="bg-muted/60 overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed">
|
||||
{skill.path}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t("pages.agent.skills.empty")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
open={selectedSkill !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedSkill(null)
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-full gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
|
||||
>
|
||||
<SheetHeader className="border-b px-6 py-5">
|
||||
<SheetTitle>
|
||||
{selectedSkill?.name || t("pages.agent.skills.viewer_title")}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selectedSkill?.description ||
|
||||
t("pages.agent.skills.viewer_description")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-5">
|
||||
{isSkillDetailLoading ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("pages.agent.skills.loading_detail")}
|
||||
</div>
|
||||
) : skillDetailError ? (
|
||||
<div className="text-destructive text-sm">
|
||||
{t("pages.agent.skills.load_detail_error")}
|
||||
</div>
|
||||
) : selectedSkillDetail ? (
|
||||
<div className="space-y-5">
|
||||
<div className="prose prose-sm dark:prose-invert prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{selectedSkillDetail.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={skillPendingDelete !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSkillPendingDelete(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("pages.agent.skills.delete_title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("pages.agent.skills.delete_description", {
|
||||
name: skillPendingDelete?.name,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending || !skillPendingDelete}
|
||||
onClick={() => {
|
||||
if (skillPendingDelete)
|
||||
deleteMutation.mutate(skillPendingDelete.name)
|
||||
}}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<IconTrash className="size-4" />
|
||||
)}
|
||||
{t("pages.agent.skills.delete_confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function ToolsPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["tools"],
|
||||
queryFn: getTools,
|
||||
})
|
||||
|
||||
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"] })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("pages.agent.tools.toggle_error"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const groupedTools = (() => {
|
||||
if (!data) return [] as Array<[string, ToolSupportItem[]]>
|
||||
const buckets = new Map<string, ToolSupportItem[]>()
|
||||
for (const item of data.tools) {
|
||||
const list = buckets.get(item.category) ?? []
|
||||
list.push(item)
|
||||
buckets.set(item.category, list)
|
||||
}
|
||||
return Array.from(buckets.entries())
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title={t("navigation.tools")} />
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-3">
|
||||
<div className="w-full max-w-6xl space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-6 text-sm">
|
||||
{t("labels.loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{t("pages.agent.load_error")}
|
||||
</div>
|
||||
) : (
|
||||
<section className="space-y-5">
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t("pages.agent.tools.description")}
|
||||
</p>
|
||||
|
||||
{data?.tools.length ? (
|
||||
groupedTools.map(([category, items]) => (
|
||||
<div key={category} className="space-y-3">
|
||||
<div className="text-foreground/85 text-sm font-semibold tracking-wide">
|
||||
{t(`pages.agent.tools.categories.${category}`)}
|
||||
</div>
|
||||
<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 nextEnabled = tool.status !== "enabled"
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={tool.name}
|
||||
className={cn(
|
||||
"gap-4 border transition-colors",
|
||||
tool.status === "enabled" &&
|
||||
"border-emerald-200/70 bg-emerald-50/50",
|
||||
tool.status === "blocked" &&
|
||||
"border-amber-200/80 bg-amber-50/60",
|
||||
tool.status === "disabled" &&
|
||||
"border-border/60 bg-card/70",
|
||||
)}
|
||||
size="sm"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="font-mono text-sm break-all">
|
||||
{tool.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 break-words">
|
||||
{tool.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 self-start">
|
||||
<ToolStatusBadge status={tool.status} />
|
||||
<Button
|
||||
variant={
|
||||
nextEnabled ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
toggleMutation.mutate({
|
||||
name: tool.name,
|
||||
enabled: nextEnabled,
|
||||
})
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{nextEnabled
|
||||
? t("pages.agent.tools.enable")
|
||||
: t("pages.agent.tools.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("pages.agent.tools.config_key", {
|
||||
key: tool.config_key,
|
||||
})}
|
||||
</div>
|
||||
{reasonText ? (
|
||||
<div className="text-sm text-amber-800">
|
||||
{reasonText}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t("pages.agent.tools.empty")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold",
|
||||
status === "enabled" && "bg-emerald-100 text-emerald-700",
|
||||
status === "blocked" && "bg-amber-100 text-amber-700",
|
||||
status === "disabled" && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`pages.agent.tools.status.${status}`)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user