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:
wenjie
2026-03-11 18:37:00 +08:00
committed by GitHub
parent 8a398988d7
commit dea06c391c
45 changed files with 4266 additions and 327 deletions
@@ -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",
+14 -5
View File
@@ -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>
)
}