feat(web): add "部署 Sub Agent" menu + page (pick resources + template -> deploy)

Customer-facing, plain Chinese (no env/technical jargon). New sidebar item
"部署 Sub Agent" -> /deploy-agent. The page:
- Step 1: pick which bound resources the agent may use (multi-select cards from
  /api/resources).
- Step 2: pick an agent template (cards show Chinese name + description from
  /api/heicode/agent-templates).
- Deploy -> POST /api/heicode/agents {template_id, binding_ids}.
- "我的 Agent" list (/api/heicode/agents) with status, copy-address, stop, delete.

Frontend builds clean (tsc + rsbuild; routeTree regenerated). zh label added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 00:25:12 +08:00
co-authored by Claude Opus 4.8
parent 4605cf4f62
commit 8823bf5e5d
5 changed files with 400 additions and 0 deletions
@@ -0,0 +1,365 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Bot,
CheckCircle2,
Copy,
Database,
GitBranch,
RefreshCw,
Rocket,
Server,
Square,
Trash2,
} from 'lucide-react'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
type ResourceItem = {
id: number
name: string
resource_type: string
provider: string
external_id: string
}
type TemplateItem = {
template_id: string
name: string
description: string
model: string
}
type AgentItem = {
agent_id: string
template_id: string
subdomain: string
access_token: string
binding_ids: number[]
status: string
created_at: string
}
const RESOURCE_ICON: Record<string, typeof GitBranch> = {
git: GitBranch,
vm: Server,
database: Database,
blob: Database,
}
function resourceTypeLabel(t: string): string {
return (
{ git: '代码仓库', vm: '虚拟机', database: '数据库', blob: '对象存储' }[t] ??
t
)
}
function statusLabel(s: string): { text: string; cls: string } {
const v = (s || '').toLowerCase()
if (['running', 'active', 'ready'].includes(v))
return { text: '运行中', cls: 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30' }
if (['stopped'].includes(v))
return { text: '已停止', cls: 'bg-muted/40 text-muted-foreground ring-border' }
if (['failed', 'error', 'unhealthy', 'crashed'].includes(v))
return { text: '异常', cls: 'bg-rose-500/15 text-rose-300 ring-rose-500/30' }
return { text: s || '—', cls: 'bg-amber-500/15 text-amber-300 ring-amber-500/30' }
}
async function listResources(): Promise<ResourceItem[]> {
const res = await api.get<{ data?: { items?: ResourceItem[] } }>(
'/api/resources/?status=active'
)
return res.data?.data?.items ?? []
}
async function listTemplates(): Promise<TemplateItem[]> {
const res = await api.get<{ data?: { items?: TemplateItem[] } }>(
'/api/heicode/agent-templates'
)
return res.data?.data?.items ?? []
}
async function listAgents(): Promise<AgentItem[]> {
const res = await api.get<{ data?: { items?: AgentItem[] } }>(
'/api/heicode/agents'
)
return res.data?.data?.items ?? []
}
export function DeploySubAgentPage() {
const qc = useQueryClient()
const [selectedBindings, setSelectedBindings] = useState<number[]>([])
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
const resourcesQ = useQuery({
queryKey: ['resources', 'active'],
queryFn: listResources,
retry: false,
})
const templatesQ = useQuery({
queryKey: ['agent-templates'],
queryFn: listTemplates,
retry: false,
})
const agentsQ = useQuery({
queryKey: ['heicode-agents'],
queryFn: listAgents,
refetchInterval: 30_000,
retry: false,
})
const deploy = useMutation({
mutationFn: () =>
api.post('/api/heicode/agents', {
template_id: selectedTemplate,
binding_ids: selectedBindings,
}),
onSuccess: () => {
toast.success('Agent 已开始部署')
setSelectedBindings([])
setSelectedTemplate('')
void qc.invalidateQueries({ queryKey: ['heicode-agents'] })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : '部署失败,请稍后重试'),
})
const toggleBinding = (id: number) =>
setSelectedBindings((s) =>
s.includes(id) ? s.filter((x) => x !== id) : [...s, id]
)
const resources = resourcesQ.data ?? []
const templates = templatesQ.data ?? []
const canDeploy = Boolean(selectedTemplate) && !deploy.isPending
return (
<div className='space-y-6'>
{/* Header */}
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
Heicode Manager
</p>
<h2 className='mt-1 flex items-center gap-2 text-xl font-semibold'>
<Bot className='text-primary h-5 w-5' /> 部署 Sub Agent
</h2>
<p className='text-muted-foreground mt-1 text-sm'>
选择要让 Agent 使用的资源,挑一个 Agent 模板,一键部署到云端。部署后即可在桌面客户端里使用。
</p>
</div>
{/* Step 1: pick resources */}
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5'>
<h3 className='text-base font-semibold'>1. 选择资源</h3>
<p className='text-muted-foreground mt-0.5 text-xs'>
勾选这个 Agent 可以使用的已绑定资源(可多选,也可不选)。没绑定过的资源请先去「资源绑定」添加。
</p>
{resourcesQ.isLoading ? (
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
{[0, 1].map((i) => (
<Skeleton key={i} className='h-16 rounded-xl' />
))}
</div>
) : resources.length === 0 ? (
<p className='text-muted-foreground mt-4 rounded-xl border border-dashed p-6 text-center text-sm'>
还没有绑定任何资源。
</p>
) : (
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
{resources.map((r) => {
const Icon = RESOURCE_ICON[r.resource_type] ?? Database
const on = selectedBindings.includes(r.id)
return (
<button
type='button'
key={r.id}
onClick={() => toggleBinding(r.id)}
className={cn(
'flex items-center gap-3 rounded-xl border p-3 text-left transition',
on
? 'border-primary/60 bg-[color-mix(in_oklch,var(--primary)_12%,transparent)]'
: 'border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] hover:border-primary/40'
)}
>
<span
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
on ? 'bg-primary/20 text-primary' : 'bg-muted/50 text-muted-foreground'
)}
>
<Icon className='h-4 w-4' />
</span>
<span className='min-w-0 flex-1'>
<span className='block truncate text-sm font-medium'>{r.name}</span>
<span className='text-muted-foreground block truncate text-[11px]'>
{resourceTypeLabel(r.resource_type)} · {r.provider}
</span>
</span>
{on && <CheckCircle2 className='text-primary h-4 w-4 shrink-0' />}
</button>
)
})}
</div>
)}
</section>
{/* Step 2: pick template */}
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5'>
<h3 className='text-base font-semibold'>2. 选择 Agent 模板</h3>
<p className='text-muted-foreground mt-0.5 text-xs'>选择一个 Agent 的角色与能力。</p>
{templatesQ.isLoading ? (
<div className='mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
{[0, 1, 2].map((i) => (
<Skeleton key={i} className='h-20 rounded-xl' />
))}
</div>
) : (
<div className='mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
{templates.map((tpl) => {
const on = selectedTemplate === tpl.template_id
return (
<button
type='button'
key={tpl.template_id}
onClick={() => setSelectedTemplate(tpl.template_id)}
className={cn(
'flex flex-col gap-1 rounded-xl border p-4 text-left transition',
on
? 'border-primary/60 bg-[color-mix(in_oklch,var(--primary)_12%,transparent)]'
: 'border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] hover:border-primary/40'
)}
>
<span className='flex items-center justify-between'>
<span className='text-sm font-semibold'>{tpl.name}</span>
{on && <CheckCircle2 className='text-primary h-4 w-4' />}
</span>
<span className='text-muted-foreground text-xs leading-relaxed'>
{tpl.description}
</span>
</button>
)
})}
</div>
)}
</section>
{/* Deploy bar */}
<div className='flex items-center justify-between rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-4'>
<p className='text-muted-foreground text-sm'>
已选 <span className='text-foreground font-semibold'>{selectedBindings.length}</span> 个资源
{selectedTemplate ? ',模板已选择' : ',未选择模板'}
</p>
<Button onClick={() => deploy.mutate()} disabled={!canDeploy} className='gap-1.5'>
<Rocket className='h-4 w-4' />
{deploy.isPending ? '部署中…' : '部署 Agent'}
</Button>
</div>
{/* My agents */}
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5'>
<div className='flex items-center justify-between'>
<h3 className='text-base font-semibold'>我的 Agent</h3>
<Button
variant='ghost'
size='sm'
className='gap-1 text-xs'
onClick={() => void qc.invalidateQueries({ queryKey: ['heicode-agents'] })}
>
<RefreshCw className='h-3.5 w-3.5' /> 刷新
</Button>
</div>
{agentsQ.isLoading ? (
<div className='mt-4 space-y-3'>
{[0, 1].map((i) => (
<Skeleton key={i} className='h-16 rounded-xl' />
))}
</div>
) : (agentsQ.data ?? []).length === 0 ? (
<p className='text-muted-foreground mt-4 rounded-xl border border-dashed p-6 text-center text-sm'>
还没有部署过 Agent。
</p>
) : (
<div className='mt-4 space-y-3'>
{(agentsQ.data ?? []).map((a) => (
<AgentRow key={a.agent_id} agent={a} />
))}
</div>
)}
</section>
</div>
)
}
function AgentRow({ agent }: { agent: AgentItem }) {
const qc = useQueryClient()
const st = statusLabel(agent.status)
const stop = useMutation({
mutationFn: () => api.post(`/api/heicode/agents/${agent.agent_id}/stop`),
onSuccess: () => {
toast.success('已停止')
void qc.invalidateQueries({ queryKey: ['heicode-agents'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : '操作失败'),
})
const del = useMutation({
mutationFn: () => api.delete(`/api/heicode/agents/${agent.agent_id}`),
onSuccess: () => {
toast.success('已删除')
void qc.invalidateQueries({ queryKey: ['heicode-agents'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : '操作失败'),
})
const copy = () => {
if (!agent.subdomain) return
void navigator.clipboard.writeText(agent.subdomain)
toast.success('已复制访问地址')
}
return (
<article className='flex flex-col gap-2 rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<span className='truncate text-sm font-medium'>{agent.template_id}</span>
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset',
st.cls
)}
>
{st.text}
</span>
</div>
<p className='text-muted-foreground mt-0.5 truncate font-mono text-[11px]'>
{agent.subdomain || '地址生成中…'}
</p>
</div>
<div className='flex shrink-0 items-center gap-1'>
<Button variant='outline' size='sm' className='h-8 gap-1 text-xs' onClick={copy} disabled={!agent.subdomain}>
<Copy className='h-3.5 w-3.5' /> 复制地址
</Button>
<Button
variant='ghost'
size='sm'
className='h-8 gap-1 text-xs'
onClick={() => stop.mutate()}
disabled={stop.isPending}
>
<Square className='h-3.5 w-3.5' /> 停止
</Button>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 text-rose-400'
onClick={() => del.mutate()}
disabled={del.isPending}
title='删除'
>
<Trash2 className='h-3.5 w-3.5' />
</Button>
</div>
</article>
)
}
+6
View File
@@ -1,4 +1,5 @@
import {
Bot,
Command,
Download,
GitBranch,
@@ -52,6 +53,11 @@ export function useSidebarData(): SidebarData {
url: '/resources',
icon: GitBranch,
},
{
title: t('Deploy sub agent'),
url: '/deploy-agent',
icon: Bot,
},
{
title: t('Agent runs'),
url: '/deployments',
+1
View File
@@ -212,6 +212,7 @@
"All Status": "所有状态",
"All statuses": "全部状态",
"Agent runs": "运行总览",
"Deploy sub agent": "部署 Sub Agent",
"Resource binding": "资源绑定",
"Bind your git repos / VMs / databases / blob. Credentials go straight to Azure Key Vault — only a secret_ref is stored.": "绑定你的 git 仓库 / 虚拟机 / 数据库 / 对象存储。凭据直接写入 Azure Key Vault,本地只保存 secret_ref。",
"No resources bound yet": "还没有绑定任何资源",
+22
View File
@@ -46,6 +46,7 @@ import { Route as AuthenticatedKeysIndexRouteImport } from './routes/_authentica
import { Route as AuthenticatedDevicesIndexRouteImport } from './routes/_authenticated/devices/index'
import { Route as AuthenticatedDesktopClientIndexRouteImport } from './routes/_authenticated/desktop-client/index'
import { Route as AuthenticatedDeploymentsIndexRouteImport } from './routes/_authenticated/deployments/index'
import { Route as AuthenticatedDeployAgentIndexRouteImport } from './routes/_authenticated/deploy-agent/index'
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index'
import { Route as AuthenticatedChannelsIndexRouteImport } from './routes/_authenticated/channels/index'
import { Route as AuthenticatedAvailableModelsIndexRouteImport } from './routes/_authenticated/available-models/index'
@@ -267,6 +268,12 @@ const AuthenticatedDeploymentsIndexRoute =
path: '/deployments/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedDeployAgentIndexRoute =
AuthenticatedDeployAgentIndexRouteImport.update({
id: '/deploy-agent/',
path: '/deploy-agent/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedDashboardIndexRoute =
AuthenticatedDashboardIndexRouteImport.update({
id: '/dashboard/',
@@ -440,6 +447,7 @@ export interface FileRoutesByFullPath {
'/available-models/': typeof AuthenticatedAvailableModelsIndexRoute
'/channels/': typeof AuthenticatedChannelsIndexRoute
'/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/deploy-agent/': typeof AuthenticatedDeployAgentIndexRoute
'/deployments/': typeof AuthenticatedDeploymentsIndexRoute
'/desktop-client/': typeof AuthenticatedDesktopClientIndexRoute
'/devices/': typeof AuthenticatedDevicesIndexRoute
@@ -500,6 +508,7 @@ export interface FileRoutesByTo {
'/available-models': typeof AuthenticatedAvailableModelsIndexRoute
'/channels': typeof AuthenticatedChannelsIndexRoute
'/dashboard': typeof AuthenticatedDashboardIndexRoute
'/deploy-agent': typeof AuthenticatedDeployAgentIndexRoute
'/deployments': typeof AuthenticatedDeploymentsIndexRoute
'/desktop-client': typeof AuthenticatedDesktopClientIndexRoute
'/devices': typeof AuthenticatedDevicesIndexRoute
@@ -564,6 +573,7 @@ export interface FileRoutesById {
'/_authenticated/available-models/': typeof AuthenticatedAvailableModelsIndexRoute
'/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute
'/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/_authenticated/deploy-agent/': typeof AuthenticatedDeployAgentIndexRoute
'/_authenticated/deployments/': typeof AuthenticatedDeploymentsIndexRoute
'/_authenticated/desktop-client/': typeof AuthenticatedDesktopClientIndexRoute
'/_authenticated/devices/': typeof AuthenticatedDevicesIndexRoute
@@ -627,6 +637,7 @@ export interface FileRouteTypes {
| '/available-models/'
| '/channels/'
| '/dashboard/'
| '/deploy-agent/'
| '/deployments/'
| '/desktop-client/'
| '/devices/'
@@ -687,6 +698,7 @@ export interface FileRouteTypes {
| '/available-models'
| '/channels'
| '/dashboard'
| '/deploy-agent'
| '/deployments'
| '/desktop-client'
| '/devices'
@@ -750,6 +762,7 @@ export interface FileRouteTypes {
| '/_authenticated/available-models/'
| '/_authenticated/channels/'
| '/_authenticated/dashboard/'
| '/_authenticated/deploy-agent/'
| '/_authenticated/deployments/'
| '/_authenticated/desktop-client/'
| '/_authenticated/devices/'
@@ -1060,6 +1073,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedDeploymentsIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/deploy-agent/': {
id: '/_authenticated/deploy-agent/'
path: '/deploy-agent'
fullPath: '/deploy-agent/'
preLoaderRoute: typeof AuthenticatedDeployAgentIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/dashboard/': {
id: '/_authenticated/dashboard/'
path: '/dashboard'
@@ -1324,6 +1344,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedAvailableModelsIndexRoute: typeof AuthenticatedAvailableModelsIndexRoute
AuthenticatedChannelsIndexRoute: typeof AuthenticatedChannelsIndexRoute
AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute
AuthenticatedDeployAgentIndexRoute: typeof AuthenticatedDeployAgentIndexRoute
AuthenticatedDeploymentsIndexRoute: typeof AuthenticatedDeploymentsIndexRoute
AuthenticatedDesktopClientIndexRoute: typeof AuthenticatedDesktopClientIndexRoute
AuthenticatedDevicesIndexRoute: typeof AuthenticatedDevicesIndexRoute
@@ -1353,6 +1374,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedAvailableModelsIndexRoute,
AuthenticatedChannelsIndexRoute: AuthenticatedChannelsIndexRoute,
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
AuthenticatedDeployAgentIndexRoute: AuthenticatedDeployAgentIndexRoute,
AuthenticatedDeploymentsIndexRoute: AuthenticatedDeploymentsIndexRoute,
AuthenticatedDesktopClientIndexRoute: AuthenticatedDesktopClientIndexRoute,
AuthenticatedDevicesIndexRoute: AuthenticatedDevicesIndexRoute,
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { DeploySubAgentPage } from '@/features/deploy-agent/deploy-agent-page'
export const Route = createFileRoute('/_authenticated/deploy-agent/')({
component: DeploySubAgentPage,
})