feat: add secure agent and MCP extensions
This commit is contained in:
261
frontend/src/components/ui/context-menu.tsx
Normal file
261
frontend/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import * as React from "react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { JsonEditor, parseJsonObject } from '@/features/ai-platform/components/JsonEditor'
|
||||
@@ -28,7 +29,12 @@ import { formatDateTime } from '@/lib/format'
|
||||
import { ensureAgentDraft, getAgent, publishAgent, rollbackAgent, startAgentRun, updateAgent, updateAgentDraft } from './api'
|
||||
import type { AgentDetail, AgentVersion } from './types'
|
||||
|
||||
type EditableStep = { code: string; skill_version_id: string; mapping: string }
|
||||
type EditableStep = {
|
||||
code: string
|
||||
skill_version_id: string
|
||||
model_deployment_id: string
|
||||
mapping: string
|
||||
}
|
||||
type SkillOption = SkillVersion & { definitionName: string }
|
||||
|
||||
export function AgentDetailPage({ agentId }: { agentId: number }) {
|
||||
@@ -41,6 +47,7 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
|
||||
const [systemPrompt, setSystemPrompt] = React.useState('')
|
||||
const [temperature, setTemperature] = React.useState('')
|
||||
const [maxTokens, setMaxTokens] = React.useState('')
|
||||
const [allowTicketResolve, setAllowTicketResolve] = React.useState(false)
|
||||
const [inputSchema, setInputSchema] = React.useState('{}')
|
||||
const [exampleInput, setExampleInput] = React.useState('{}')
|
||||
const [steps, setSteps] = React.useState<EditableStep[]>([])
|
||||
@@ -54,8 +61,14 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
|
||||
setModelId(String(version.model_deployment_id)); setSystemPrompt(version.system_prompt)
|
||||
setTemperature(version.temperature_override === null ? '' : String(version.temperature_override))
|
||||
setMaxTokens(version.max_output_tokens_override === null ? '' : String(version.max_output_tokens_override))
|
||||
setAllowTicketResolve(version.allowed_actions.includes('ticket.resolve'))
|
||||
setInputSchema(JSON.stringify(version.input_schema, null, 2)); setExampleInput(JSON.stringify(version.example_input, null, 2)); setTestInput(JSON.stringify(version.example_input, null, 2))
|
||||
setSteps(version.steps.map((step) => ({ code: step.code, skill_version_id: String(step.skill_version_id), mapping: JSON.stringify(step.input_mapping, null, 2) })))
|
||||
setSteps(version.steps.map((step) => ({
|
||||
code: step.code,
|
||||
skill_version_id: String(step.skill_version_id),
|
||||
model_deployment_id: step.model_deployment_id === null ? '' : String(step.model_deployment_id),
|
||||
mapping: JSON.stringify(step.input_mapping, null, 2),
|
||||
})))
|
||||
}, [])
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
@@ -78,7 +91,7 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
|
||||
try {
|
||||
await Promise.all([
|
||||
updateAgent(agentId, meta),
|
||||
updateAgentDraft(agentId, { model_deployment_id: Number(modelId), system_prompt: systemPrompt, input_schema: parseJsonObject(inputSchema, 'Agent 输入 Schema'), example_input: parseJsonObject(exampleInput, '示例输入'), temperature_override: temperature === '' ? null : Number(temperature), max_output_tokens_override: maxTokens === '' ? null : Number(maxTokens), steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), input_mapping: parseJsonObject(step.mapping, `步骤 ${step.code} 输入映射`) })) })
|
||||
updateAgentDraft(agentId, { model_deployment_id: Number(modelId), system_prompt: systemPrompt, input_schema: parseJsonObject(inputSchema, 'Agent 输入 Schema'), example_input: parseJsonObject(exampleInput, '示例输入'), temperature_override: temperature === '' ? null : Number(temperature), max_output_tokens_override: maxTokens === '' ? null : Number(maxTokens), allowed_actions: allowTicketResolve ? ['ticket.resolve'] : [], steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), model_deployment_id: step.model_deployment_id === '' ? null : Number(step.model_deployment_id), input_mapping: parseJsonObject(step.mapping, `步骤 ${step.code} 输入映射`) })) })
|
||||
])
|
||||
toast.success('Agent 草稿已保存'); await load()
|
||||
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
|
||||
@@ -92,11 +105,11 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
|
||||
if (!detail) return <AppPage header={<AppPageHeader title="Agent 详情" />}><div className="h-64 animate-pulse rounded-md bg-muted" /></AppPage>
|
||||
return <AppPage header={<AppPageHeader title={detail.name} breadcrumbLabel={detail.code} description={`${detail.code} · 有序步骤执行,任一步失败后立即终止。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<>{draft ? <><Button variant="outline" onClick={() => void save()}><Save data-icon="inline-start" />保存草稿</Button><Button onClick={() => void publish()}><Send data-icon="inline-start" />发布 v{draft.version}</Button></> : <Button onClick={() => void createDraft()}><CopyPlus data-icon="inline-start" />创建新草稿</Button>}</>} />}>
|
||||
<Tabs defaultValue="basic"><TabsList variant="line" className="max-w-full overflow-x-auto"><TabsTrigger value="basic">基础与模型</TabsTrigger><TabsTrigger value="steps">步骤</TabsTrigger><TabsTrigger value="schema">输入 Schema</TabsTrigger><TabsTrigger value="test">测试</TabsTrigger><TabsTrigger value="versions">版本</TabsTrigger></TabsList>
|
||||
<TabsContent value="basic" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5"><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField><AppField label="模型"><Select value={modelId} onValueChange={setModelId}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select></AppField><AppField label="Temperature 覆盖" description="留空使用模型默认值"><Input type="number" min="0" max="2" step="0.1" value={temperature} onChange={(e) => setTemperature(e.target.value)} /></AppField><AppField label="最大输出 Token 覆盖" description="留空使用模型默认值"><Input type="number" min="1" max="32768" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} /></AppField></FieldGroup><AppField label="系统 Prompt"><Textarea className="min-h-40" value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} /></AppField></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
|
||||
<TabsContent value="steps" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-3">{steps.map((step, index) => <Card key={`${index}-${step.code}`}><CardHeader className="flex-row items-center justify-between"><div><CardTitle className="text-sm">{index + 1}. {step.code}</CardTitle><CardDescription>输入映射后传递给 Skill</CardDescription></div><div className="flex gap-1"><Button variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => moveStep(index, -1)}><ArrowUp /><span className="sr-only">上移</span></Button><Button variant="ghost" size="icon-sm" disabled={index === steps.length - 1} onClick={() => moveStep(index, 1)}><ArrowDown /><span className="sr-only">下移</span></Button><Button variant="ghost" size="icon-sm" disabled={steps.length === 1} onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><Trash2 /><span className="sr-only">删除</span></Button></div></CardHeader><CardContent className="grid gap-3 md:grid-cols-2"><AppField label="步骤代码"><Input value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本"><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectContent></Select></AppField><div className="md:col-span-2"><JsonEditor id={`agent-step-mapping-${index}`} label="输入映射" value={step.mapping} onChange={(value) => updateStep(index, { mapping: value })} rows={6} /></div></CardContent></Card>)}<Button variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
|
||||
<TabsContent value="basic" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5"><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField><AppField label="模型"><Select value={modelId} onValueChange={setModelId}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select></AppField><AppField label="Temperature 覆盖" description="留空使用模型默认值"><Input type="number" min="0" max="2" step="0.1" value={temperature} onChange={(e) => setTemperature(e.target.value)} /></AppField><AppField label="最大输出 Token 覆盖" description="留空使用模型默认值"><Input type="number" min="1" max="32768" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} /></AppField></FieldGroup><Card><CardHeader className="flex-row items-start justify-between gap-4"><div className="grid gap-1"><CardTitle className="text-sm">允许自动解决工单</CardTitle><CardDescription>授予已发布版本执行 ticket.resolve 的能力。实际执行仍需同时通过全局开关、发起人权限、方案范围、风险和状态机检查。</CardDescription></div><Switch checked={allowTicketResolve} onCheckedChange={setAllowTicketResolve} aria-label="允许自动解决工单" /></CardHeader></Card><AppField label="系统 Prompt"><Textarea className="min-h-40" value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} /></AppField></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
|
||||
<TabsContent value="steps" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-3">{steps.map((step, index) => <Card key={`${index}-${step.code}`}><CardHeader className="flex-row items-center justify-between"><div><CardTitle className="text-sm">{index + 1}. {step.code}</CardTitle><CardDescription>输入映射后传递给 Skill;模型未指定时继承 Agent 默认值</CardDescription></div><div className="flex gap-1"><Button variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => moveStep(index, -1)}><ArrowUp /><span className="sr-only">上移</span></Button><Button variant="ghost" size="icon-sm" disabled={index === steps.length - 1} onClick={() => moveStep(index, 1)}><ArrowDown /><span className="sr-only">下移</span></Button><Button variant="ghost" size="icon-sm" disabled={steps.length === 1} onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><Trash2 /><span className="sr-only">删除</span></Button></div></CardHeader><CardContent className="grid gap-3 md:grid-cols-2"><AppField label="步骤代码"><Input value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本"><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectContent></Select></AppField><AppField label="步骤模型" description="仅文本与视觉模型可用于 Agent 步骤"><Select value={step.model_deployment_id || 'inherit'} onValueChange={(value) => updateStep(index, { model_deployment_id: value === 'inherit' ? '' : value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="inherit">继承默认模型 · {deployments.find((item) => String(item.id) === modelId)?.display_name ?? draft.model_display_name}</SelectItem>{deployments.filter((item) => ['text_chat', 'vision_chat'].includes(item.capability)).map((item) => <SelectItem key={item.id} value={String(item.id)} disabled={!item.enabled}>{item.display_name} · {item.capability}{item.enabled ? '' : '(已停用)'}</SelectItem>)}</SelectContent></Select></AppField><div className="md:col-span-2"><JsonEditor id={`agent-step-mapping-${index}`} label="输入映射" value={step.mapping} onChange={(value) => updateStep(index, { mapping: value })} rows={6} /></div></CardContent></Card>)}<Button variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), model_deployment_id: '', mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
|
||||
<TabsContent value="schema" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5 xl:grid-cols-2"><JsonEditor id="agent-input-schema" label="Agent 输入 JSON Schema" value={inputSchema} onChange={setInputSchema} /><JsonEditor id="agent-example-input" label="示例输入" value={exampleInput} onChange={setExampleInput} rows={10} /></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
|
||||
<TabsContent value="test" className="grid gap-4 pt-4"><Alert><Play /><AlertTitle>异步沙箱与发布版本测试</AlertTitle><AlertDescription>草稿运行会标记为沙箱;正式运行只能选择已发布版本。启动后跳转到运行轨迹并自动轮询。</AlertDescription></Alert><AppField label="运行版本"><Select value={testVersion} onValueChange={(value) => { setTestVersion(value); const selected = value === 'draft' ? draft : detail.versions.find((item) => String(item.version) === value); if (selected) setTestInput(JSON.stringify(selected.example_input, null, 2)) }}><SelectTrigger className="w-full md:w-80"><SelectValue /></SelectTrigger><SelectContent>{draft ? <SelectItem value="draft">草稿 v{draft.version}(沙箱)</SelectItem> : null}{detail.versions.filter((item) => item.status === 'published').map((item) => <SelectItem key={item.id} value={String(item.version)}>已发布 v{item.version}</SelectItem>)}</SelectContent></Select></AppField><SchemaInputEditor schema={(testVersion === 'draft' ? draft?.input_schema : detail.versions.find((item) => String(item.version) === testVersion)?.input_schema) ?? {}} value={testInput} onChange={setTestInput} /><div><Button onClick={() => void run()} disabled={!testVersion}><Play data-icon="inline-start" />启动异步运行</Button></div></TabsContent>
|
||||
<TabsContent value="versions" className="grid gap-3 pt-4">{detail.versions.map((version) => <Card key={version.id}><CardHeader className="flex-row items-center justify-between"><div><CardTitle>版本 v{version.version}</CardTitle><CardDescription>{version.model_display_name} · {version.steps.length} 个步骤 · {version.published_at ? formatDateTime(version.published_at) : formatDateTime(version.updated_at)}</CardDescription></div><div className="flex items-center gap-2"><StatusBadge status={version.status} />{version.status === 'published' && detail.active_version !== version.version ? <Button variant="outline" size="sm" onClick={() => void rollback(version.version)}>回滚至此版本</Button> : null}</div></CardHeader></Card>)}</TabsContent>
|
||||
<TabsContent value="versions" className="grid gap-3 pt-4">{detail.versions.map((version) => <Card key={version.id}><CardHeader className="flex-row items-center justify-between"><div><CardTitle>版本 v{version.version}</CardTitle><CardDescription>{version.model_display_name} · {version.steps.length} 个步骤 · {version.steps.filter((step) => step.model_deployment_id !== null).length} 个独立模型绑定 · {version.published_at ? formatDateTime(version.published_at) : formatDateTime(version.updated_at)}</CardDescription></div><div className="flex items-center gap-2"><StatusBadge status={version.status} />{version.status === 'published' && detail.active_version !== version.version ? <Button variant="outline" size="sm" onClick={() => void rollback(version.version)}>回滚至此版本</Button> : null}</div></CardHeader></Card>)}</TabsContent>
|
||||
</Tabs>
|
||||
</AppPage>
|
||||
}
|
||||
|
||||
@@ -45,10 +45,11 @@ export function AgentRunDetailPage({ publicId }: { publicId: string }) {
|
||||
if (!run) return <AppPage header={<AppPageHeader title="运行记录详情" />}><div className="h-72 animate-pulse rounded-md bg-muted" /></AppPage>
|
||||
const selected = run.steps.find((step) => step.id === selectedStepId) ?? null
|
||||
return <AppPage maxWidth="7xl" header={<AppPageHeader title={run.agent_name} breadcrumbLabel={run.public_id} description={`运行 ${run.public_id} · 创建于 ${formatDateTime(run.created_at)}`} meta={<div className="flex gap-1"><StatusBadge status={run.status} />{run.sandbox ? <Badge variant="warning">草稿沙箱</Badge> : <Badge variant="secondary">发布 v{run.agent_version}</Badge>}</div>} actions={<><Button variant="outline" onClick={() => void load()}><RotateCw data-icon="inline-start" />刷新</Button>{['queued', 'running'].includes(run.status) ? <Button variant="destructive" onClick={() => void cancel()}><Ban data-icon="inline-start" />尽力取消</Button> : null}</>} />}>
|
||||
{['queued', 'running'].includes(run.status) ? <Alert><RotateCw className="animate-spin" /><AlertTitle>{run.progress_message || 'Agent 正在运行'}</AlertTitle><AlertDescription>当前步骤:{run.current_step || '等待调度'}{run.retry_count ? `;已重试 ${run.retry_count} 次` : ''}</AlertDescription></Alert> : null}
|
||||
{run.error_message ? <Alert variant="destructive"><AlertTitle>{run.error_code || '运行失败'}</AlertTitle><AlertDescription>{run.error_message}</AlertDescription></Alert> : null}
|
||||
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)_300px]">
|
||||
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">执行脊柱</CardTitle><CardDescription>{run.steps.length}/{run.total_steps} 个步骤已创建</CardDescription></CardHeader><CardContent className="max-h-[640px] overflow-auto p-2"><ol className="grid gap-1">{run.steps.map((step) => <li key={step.id}><button type="button" onClick={() => setSelectedStepId(step.id)} className={cn('grid w-full grid-cols-[28px_minmax(0,1fr)] gap-2 rounded-md p-2 text-left transition-colors hover:bg-muted', selectedStepId === step.id && 'bg-muted')}><span className={cn('flex size-7 items-center justify-center rounded-full border text-xs font-medium', step.status === 'succeeded' && 'border-success/40 bg-success/10 text-success', step.status === 'failed' && 'border-destructive/40 bg-destructive/10 text-destructive')}>{step.order}</span><span className="min-w-0"><span className="block truncate font-medium">{step.name}</span><span className="mt-1 flex items-center justify-between gap-2"><span className="font-mono text-xs text-muted-foreground">{step.code}</span><StatusBadge status={step.status} /></span></span></button>{step.order < run.steps.length ? <span className="ml-[21px] block h-3 border-l border-border" /> : null}</li>)}</ol></CardContent></Card>
|
||||
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">{selected ? `${selected.order}. ${selected.name}` : '运行输入与最终输出'}</CardTitle><CardDescription>{selected ? selected.code : '尚未生成步骤记录'}</CardDescription></CardHeader><CardContent className="p-4"><Tabs defaultValue="input"><TabsList><TabsTrigger value="input">输入</TabsTrigger><TabsTrigger value="output">输出</TabsTrigger><TabsTrigger value="attempts">调用尝试</TabsTrigger></TabsList><TabsContent value="input" className="pt-3"><JsonBlock value={selected?.input_payload ?? run.input_payload} /></TabsContent><TabsContent value="output" className="pt-3"><JsonBlock value={selected?.output_payload ?? run.final_output} /></TabsContent><TabsContent value="attempts" className="grid gap-3 pt-3">{selected?.attempts.length ? selected.attempts.map((attempt) => <Card key={attempt.id}><CardHeader><div className="flex items-center justify-between"><CardTitle className="text-sm">{attempt.attempt_type}</CardTitle><StatusBadge status={attempt.status} /></div><CardDescription>{attempt.input_tokens + attempt.output_tokens} tokens · {attempt.duration_ms} ms</CardDescription></CardHeader><CardContent className="grid gap-2">{attempt.validation_error ? <Alert variant="destructive"><AlertTitle>结构校验错误</AlertTitle><AlertDescription>{attempt.validation_error}</AlertDescription></Alert> : null}<JsonBlock value={attempt.raw_output} /></CardContent></Card>) : <AppEmptyState title="暂无调用尝试" description="排队步骤或尚未开始的步骤不会产生调用记录。" />}</TabsContent></Tabs></CardContent></Card>
|
||||
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">{selected ? `${selected.order}. ${selected.name}` : '运行输入与最终输出'}</CardTitle><CardDescription>{selected ? `${selected.code} · ${selected.model_display_name}(${selected.model_capability})` : '尚未生成步骤记录'}</CardDescription></CardHeader><CardContent className="p-4"><Tabs defaultValue="input"><TabsList><TabsTrigger value="input">输入</TabsTrigger><TabsTrigger value="output">输出</TabsTrigger><TabsTrigger value="attempts">调用尝试</TabsTrigger></TabsList><TabsContent value="input" className="pt-3"><JsonBlock value={selected?.input_payload ?? run.input_payload} /></TabsContent><TabsContent value="output" className="pt-3"><JsonBlock value={selected?.output_payload ?? run.final_output} /></TabsContent><TabsContent value="attempts" className="grid gap-3 pt-3">{selected?.attempts.length ? selected.attempts.map((attempt) => <Card key={attempt.id}><CardHeader><div className="flex items-center justify-between"><CardTitle className="text-sm">{attempt.attempt_type}</CardTitle><StatusBadge status={attempt.status} /></div><CardDescription>{attempt.input_tokens + attempt.output_tokens} tokens · {attempt.duration_ms} ms</CardDescription></CardHeader><CardContent className="grid gap-2">{attempt.validation_error ? <Alert variant="destructive"><AlertTitle>结构校验错误</AlertTitle><AlertDescription>{attempt.validation_error}</AlertDescription></Alert> : null}<JsonBlock value={attempt.raw_output} /></CardContent></Card>) : <AppEmptyState title="暂无调用尝试" description="排队步骤或尚未开始的步骤不会产生调用记录。" />}</TabsContent></Tabs></CardContent></Card>
|
||||
<div className="grid content-start gap-4"><Card><CardHeader><CardTitle className="text-sm">运行指标</CardTitle></CardHeader><CardContent className="grid gap-3 text-sm"><Metric icon={Coins} label="输入 Token" value={String(run.input_tokens)} /><Metric icon={Coins} label="输出 Token" value={String(run.output_tokens)} /><Metric icon={Clock3} label="总耗时" value={`${run.duration_ms} ms`} />{selected ? <><Separator /><Metric icon={Coins} label="步骤 Token" value={String(selected.input_tokens + selected.output_tokens)} /><Metric icon={Clock3} label="步骤耗时" value={`${selected.duration_ms} ms`} /></> : null}</CardContent></Card><Card><CardHeader><CardTitle className="text-sm">配置快照</CardTitle><CardDescription>历史记录不受后续配置修改影响</CardDescription></CardHeader><CardContent><JsonBlock value={run.config_snapshot} /></CardContent></Card>{run.final_output !== null ? <Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><Braces className="size-4" />最终结构化输出</CardTitle></CardHeader><CardContent><JsonBlock value={run.final_output} /></CardContent></Card> : null}</div>
|
||||
</div>
|
||||
</AppPage>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AgentRunListPage() {
|
||||
{ key: 'agent', header: 'Agent', cell: (item) => <div><p className="font-medium">{item.agent_name}</p><p className="font-mono text-xs text-muted-foreground">{item.public_id.slice(0, 8)}…</p></div> },
|
||||
{ key: 'version', header: '版本', cell: (item) => <div className="flex gap-1"><Badge variant="outline">v{item.agent_version}</Badge>{item.sandbox ? <Badge variant="warning">沙箱</Badge> : <Badge variant="secondary">正式</Badge>}</div> },
|
||||
{ key: 'status', header: '状态', cell: (item) => <StatusBadge status={item.status} /> },
|
||||
{ key: 'progress', header: '当前步骤', cell: (item) => <span className="text-muted-foreground">{item.current_step || '-'} · {item.total_steps} 步</span> },
|
||||
{ key: 'progress', header: '当前阶段', cell: (item) => <div><p className="text-foreground">{item.progress_message || item.current_step || '-'}</p><p className="text-xs text-muted-foreground">{item.current_step || '等待执行'} · {item.total_steps} 步{item.retry_count ? ` · 重试 ${item.retry_count}` : ''}</p></div> },
|
||||
{ key: 'usage', header: 'Token / 耗时', cell: (item) => <span className="text-muted-foreground">{item.input_tokens + item.output_tokens} · {item.duration_ms} ms</span> },
|
||||
{ key: 'created', header: '创建时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.created_at)}</span> }
|
||||
]
|
||||
|
||||
@@ -6,6 +6,9 @@ export type AgentStep = {
|
||||
skill_name: string
|
||||
skill_version: number
|
||||
skill_status: string
|
||||
model_deployment_id: number | null
|
||||
model_display_name: string
|
||||
model_capability: string
|
||||
input_mapping: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -20,6 +23,7 @@ export type AgentVersion = {
|
||||
example_input: Record<string, unknown>
|
||||
temperature_override: number | null
|
||||
max_output_tokens_override: number | null
|
||||
allowed_actions: string[]
|
||||
steps: AgentStep[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -55,6 +59,9 @@ export type AgentRunStep = {
|
||||
code: string
|
||||
name: string
|
||||
order: number
|
||||
model_deployment_id: number | null
|
||||
model_display_name: string
|
||||
model_capability: string
|
||||
status: string
|
||||
input_payload: Record<string, unknown>
|
||||
output_payload: unknown
|
||||
@@ -74,6 +81,9 @@ export type AgentRunSummary = {
|
||||
status: string
|
||||
sandbox: boolean
|
||||
current_step: string
|
||||
progress_stage: string
|
||||
progress_message: string
|
||||
retry_count: number
|
||||
total_steps: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AgentDetailPage } from '@/features/agents/AgentDetailPage'
|
||||
import { AgentRunDetailPage } from '@/features/agents/AgentRunDetailPage'
|
||||
import { ModelProviderPage } from '@/features/modelhub/ModelProviderPage'
|
||||
import { SkillDetailPage } from '@/features/skills/SkillDetailPage'
|
||||
@@ -11,7 +12,11 @@ const mocks = vi.hoisted(() => ({
|
||||
testDeployment: vi.fn(),
|
||||
getModelTest: vi.fn(),
|
||||
getSkill: vi.fn(),
|
||||
listSkills: vi.fn(),
|
||||
publishSkill: vi.fn(),
|
||||
getAgent: vi.fn(),
|
||||
updateAgent: vi.fn(),
|
||||
updateAgentDraft: vi.fn(),
|
||||
getAgentRun: vi.fn(),
|
||||
cancelAgentRun: vi.fn()
|
||||
}))
|
||||
@@ -29,6 +34,7 @@ vi.mock('@/features/modelhub/api', () => ({
|
||||
|
||||
vi.mock('@/features/skills/api', () => ({
|
||||
getSkill: mocks.getSkill,
|
||||
listSkills: mocks.listSkills,
|
||||
publishSkill: mocks.publishSkill,
|
||||
ensureSkillDraft: vi.fn(),
|
||||
updateSkill: vi.fn(),
|
||||
@@ -37,6 +43,13 @@ vi.mock('@/features/skills/api', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agents/api', () => ({
|
||||
getAgent: mocks.getAgent,
|
||||
updateAgent: mocks.updateAgent,
|
||||
updateAgentDraft: mocks.updateAgentDraft,
|
||||
ensureAgentDraft: vi.fn(),
|
||||
publishAgent: vi.fn(),
|
||||
rollbackAgent: vi.fn(),
|
||||
startAgentRun: vi.fn(),
|
||||
getAgentRun: mocks.getAgentRun,
|
||||
cancelAgentRun: mocks.cancelAgentRun
|
||||
}))
|
||||
@@ -59,7 +72,7 @@ const deployment = {
|
||||
provider_name: '企业渠道',
|
||||
display_name: '支持模型',
|
||||
model_name: 'support-model',
|
||||
capability: 'text',
|
||||
capability: 'text_chat',
|
||||
default_temperature: 0.2,
|
||||
max_output_tokens: 2048,
|
||||
enabled: true
|
||||
@@ -70,8 +83,8 @@ const queuedTest = {
|
||||
}
|
||||
|
||||
const run = {
|
||||
public_id: '11111111-1111-1111-1111-111111111111', agent_id: 1, agent_name: '技术支持分析 Agent', agent_version: 1, status: 'running', sandbox: true, current_step: 'structure', total_steps: 1, input_tokens: 3, output_tokens: 2, duration_ms: 120, error_code: '', error_message: '', created_at: '2026-07-14T00:00:00Z', started_at: '2026-07-14T00:00:00Z', finished_at: null, input_payload: { problem: '无法登录' }, final_output: null, config_snapshot: { agent: { version: 1 } }, cancellation_requested_at: null,
|
||||
steps: [{ id: 10, code: 'structure', name: '问题结构化', order: 1, status: 'running', input_payload: { problem: '无法登录' }, output_payload: null, input_tokens: 3, output_tokens: 2, duration_ms: 120, error_code: '', error_message: '', attempts: [] }]
|
||||
public_id: '11111111-1111-1111-1111-111111111111', agent_id: 1, agent_name: '技术支持分析 Agent', agent_version: 1, status: 'running', sandbox: true, current_step: 'structure', progress_stage: 'validating', progress_message: '正在校验结构化结果', retry_count: 0, total_steps: 1, input_tokens: 3, output_tokens: 2, duration_ms: 120, error_code: '', error_message: '', created_at: '2026-07-14T00:00:00Z', started_at: '2026-07-14T00:00:00Z', finished_at: null, input_payload: { problem: '无法登录' }, final_output: null, config_snapshot: { agent: { version: 1 } }, cancellation_requested_at: null,
|
||||
steps: [{ id: 10, code: 'structure', name: '问题结构化', order: 1, model_deployment_id: 2, model_display_name: '支持模型', model_capability: 'text_chat', status: 'running', input_payload: { problem: '无法登录' }, output_payload: null, input_tokens: 3, output_tokens: 2, duration_ms: 120, error_code: '', error_message: '', attempts: [] }]
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -138,9 +151,87 @@ describe('AI 平台真实页面', () => {
|
||||
render(<AgentRunDetailPage publicId={run.public_id} />)
|
||||
expect(await screen.findByText('执行脊柱')).toBeInTheDocument()
|
||||
expect(screen.getByText('问题结构化')).toBeInTheDocument()
|
||||
expect(screen.getByText('structure · 支持模型(text_chat)')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '尽力取消' }))
|
||||
|
||||
await waitFor(() => expect(mocks.cancelAgentRun).toHaveBeenCalledWith(run.public_id))
|
||||
expect(await screen.findByText('已取消')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('保存步骤级模型绑定', async () => {
|
||||
const visionDeployment = {
|
||||
...deployment,
|
||||
id: 3,
|
||||
display_name: '视觉模型',
|
||||
model_name: 'vision-model',
|
||||
capability: 'vision_chat'
|
||||
}
|
||||
const skillVersion = {
|
||||
id: 11,
|
||||
version: 1,
|
||||
status: 'published',
|
||||
prompt_template: '{{ input.problem }}',
|
||||
input_schema: { type: 'object' },
|
||||
output_schema: { type: 'object' },
|
||||
example_input: {},
|
||||
created_at: '2026-07-14T00:00:00Z',
|
||||
updated_at: '2026-07-14T00:00:00Z',
|
||||
published_at: '2026-07-14T00:00:00Z'
|
||||
}
|
||||
const agent = {
|
||||
id: 1,
|
||||
code: 'support-agent',
|
||||
name: '支持 Agent',
|
||||
description: '',
|
||||
active_version: null,
|
||||
draft_version: 1,
|
||||
updated_at: '2026-07-14T00:00:00Z',
|
||||
versions: [{
|
||||
id: 21,
|
||||
version: 1,
|
||||
status: 'draft',
|
||||
model_deployment_id: 2,
|
||||
model_display_name: '支持模型',
|
||||
system_prompt: '',
|
||||
input_schema: { type: 'object' },
|
||||
example_input: {},
|
||||
temperature_override: null,
|
||||
max_output_tokens_override: null,
|
||||
allowed_actions: [],
|
||||
steps: [{
|
||||
id: 31,
|
||||
code: 'structure',
|
||||
order: 1,
|
||||
skill_version_id: 11,
|
||||
skill_name: '问题结构化',
|
||||
skill_version: 1,
|
||||
skill_status: 'published',
|
||||
model_deployment_id: 3,
|
||||
model_display_name: '视觉模型',
|
||||
model_capability: 'vision_chat',
|
||||
input_mapping: {}
|
||||
}],
|
||||
created_at: '2026-07-14T00:00:00Z',
|
||||
updated_at: '2026-07-14T00:00:00Z',
|
||||
published_at: null
|
||||
}]
|
||||
}
|
||||
mocks.getAgent.mockResolvedValue(agent)
|
||||
mocks.listDeployments.mockResolvedValue([deployment, visionDeployment])
|
||||
mocks.listSkills.mockResolvedValue([{ id: 1 }])
|
||||
mocks.getSkill.mockResolvedValue({
|
||||
id: 1,
|
||||
code: 'structure',
|
||||
name: '问题结构化',
|
||||
versions: [skillVersion]
|
||||
})
|
||||
mocks.updateAgent.mockResolvedValue(agent)
|
||||
mocks.updateAgentDraft.mockResolvedValue(agent.versions[0])
|
||||
|
||||
render(<AgentDetailPage agentId={1} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '保存草稿' }))
|
||||
|
||||
await waitFor(() => expect(mocks.updateAgentDraft).toHaveBeenCalled())
|
||||
expect(mocks.updateAgentDraft.mock.calls[0][1].steps[0].model_deployment_id).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
draft: '草稿', published: '已发布', archived: '已归档',
|
||||
pending_review: '待审核', rejected: '已驳回', offline: '已下线',
|
||||
queued: '排队中', running: '执行中', succeeded: '成功', failed: '失败',
|
||||
cancelled: '已取消', timed_out: '已超时'
|
||||
}
|
||||
@@ -9,11 +10,11 @@ const labels: Record<string, string> = {
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const variant = status === 'succeeded' || status === 'published'
|
||||
? 'success'
|
||||
: status === 'failed' || status === 'timed_out'
|
||||
: status === 'failed' || status === 'timed_out' || status === 'rejected'
|
||||
? 'danger'
|
||||
: status === 'running'
|
||||
? 'info'
|
||||
: status === 'draft'
|
||||
: status === 'draft' || status === 'pending_review'
|
||||
? 'warning'
|
||||
: 'secondary'
|
||||
return <Badge variant={variant}>{labels[status] ?? status}</Badge>
|
||||
|
||||
@@ -168,10 +168,10 @@ export function ModelProviderPage() {
|
||||
const deploymentColumns: AppDataTableColumn<ModelDeployment>[] = [
|
||||
{ key: 'name', header: '模型', cell: (item) => <div><p className="font-medium">{item.display_name}</p><p className="text-xs text-muted-foreground">{item.model_name}</p></div> },
|
||||
{ key: 'provider', header: '渠道', cell: (item) => item.provider_name },
|
||||
{ key: 'capability', header: '能力', cell: (item) => <Badge variant="outline">{item.capability === 'vision_chat' ? '文本 + 视觉' : '纯文本'}</Badge> },
|
||||
{ key: 'capability', header: '能力', cell: (item) => <Badge variant="outline">{item.capability === 'vision_chat' ? '文本 + 视觉' : item.capability === 'embedding' ? 'Embedding' : item.capability === 'rerank' ? '重排' : '纯文本'}</Badge> },
|
||||
{ key: 'params', header: '默认参数', cell: (item) => <span className="text-muted-foreground">T {item.default_temperature} · {item.max_output_tokens} tokens</span> },
|
||||
{ key: 'status', header: '状态', cell: (item) => <Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '可用' : '停用'}</Badge> },
|
||||
{ key: 'action', header: '操作', cell: (item) => <div className="flex gap-1"><Button variant="ghost" size="sm" onClick={() => setEditingDeployment({ ...item })}><Settings2 data-icon="inline-start" />配置</Button><Button variant="outline" size="sm" onClick={() => { setTesting(item); setTestResult(null) }}><Activity data-icon="inline-start" />连通测试</Button></div> }
|
||||
{ key: 'action', header: '操作', cell: (item) => <div className="flex gap-1"><Button variant="ghost" size="sm" onClick={() => setEditingDeployment({ ...item })}><Settings2 data-icon="inline-start" />配置</Button><Button variant="outline" size="sm" disabled={item.capability === 'embedding'} title={item.capability === 'embedding' ? '请通过知识检索验证向量模型' : undefined} onClick={() => { setTesting(item); setTestResult(null) }}><Activity data-icon="inline-start" />连通测试</Button></div> }
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -254,7 +254,7 @@ export function ModelProviderPage() {
|
||||
<AppField label="实际模型名" htmlFor="deployment-model" description="填写渠道可识别的模型标识。" required>
|
||||
<Input id="deployment-model" required value={deploymentForm.model_name} onChange={(event) => setDeploymentForm({ ...deploymentForm, model_name: event.target.value })} placeholder="例如:gpt-4.1-mini" />
|
||||
</AppField>
|
||||
<AppField label="模型能力" required><Select value={deploymentForm.capability} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem></SelectContent></Select></AppField>
|
||||
<AppField label="模型能力" required><Select value={deploymentForm.capability} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem><SelectItem value="embedding">Embedding</SelectItem><SelectItem value="rerank">重排</SelectItem></SelectGroup></SelectContent></Select></AppField>
|
||||
<AppField label="Temperature" htmlFor="deployment-temperature" description="0 更稳定,数值越高结果越发散。">
|
||||
<Input id="deployment-temperature" type="number" min="0" max="2" step="0.1" value={deploymentForm.default_temperature} onChange={(event) => setDeploymentForm({ ...deploymentForm, default_temperature: event.target.value })} />
|
||||
</AppField>
|
||||
@@ -276,7 +276,7 @@ export function ModelProviderPage() {
|
||||
<Dialog open={Boolean(editingDeployment)} onOpenChange={(open) => !open && setEditingDeployment(null)}><DialogContent><DialogHeader><DialogTitle>编辑模型配置</DialogTitle></DialogHeader>{editingDeployment ? <FieldGroup>
|
||||
<AppField label="显示名称"><Input value={editingDeployment.display_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, display_name: e.target.value })} /></AppField>
|
||||
<AppField label="模型名"><Input value={editingDeployment.model_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, model_name: e.target.value })} /></AppField>
|
||||
<AppField label="模型能力"><Select value={editingDeployment.capability} onValueChange={(value) => setEditingDeployment({ ...editingDeployment, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem></SelectContent></Select></AppField>
|
||||
<AppField label="模型能力"><Select value={editingDeployment.capability} onValueChange={(value) => setEditingDeployment({ ...editingDeployment, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem><SelectItem value="embedding">Embedding</SelectItem><SelectItem value="rerank">重排</SelectItem></SelectGroup></SelectContent></Select></AppField>
|
||||
<AppField label="Temperature"><Input type="number" min="0" max="2" step="0.1" value={editingDeployment.default_temperature} onChange={(e) => setEditingDeployment({ ...editingDeployment, default_temperature: Number(e.target.value) })} /></AppField>
|
||||
<AppField label="最大输出 Token"><Input type="number" min="1" max="32768" value={editingDeployment.max_output_tokens} onChange={(e) => setEditingDeployment({ ...editingDeployment, max_output_tokens: Number(e.target.value) })} /></AppField>
|
||||
<AppField label="启用状态"><div className="flex h-8 items-center gap-2"><Switch checked={editingDeployment.enabled} onCheckedChange={(checked) => setEditingDeployment({ ...editingDeployment, enabled: checked })} /><span className="text-sm text-muted-foreground">{editingDeployment.enabled ? '已启用' : '已停用'}</span></div></AppField>
|
||||
|
||||
116
frontend/src/features/tools/ToolManagementPage.test.tsx
Normal file
116
frontend/src/features/tools/ToolManagementPage.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ToolManagementPage } from './ToolManagementPage'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listTools: vi.fn(),
|
||||
listToolProviders: vi.fn(),
|
||||
listToolCalls: vi.fn(),
|
||||
listToolAuthorizations: vi.fn(),
|
||||
confirmToolCall: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/AuthProvider', () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
display_name: '平台管理员',
|
||||
is_superuser: true,
|
||||
permissions: []
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
listTools: mocks.listTools,
|
||||
listToolProviders: mocks.listToolProviders,
|
||||
listToolCalls: mocks.listToolCalls,
|
||||
listToolAuthorizations: mocks.listToolAuthorizations,
|
||||
confirmToolCall: mocks.confirmToolCall,
|
||||
createToolCall: vi.fn(),
|
||||
createToolProvider: vi.fn(),
|
||||
saveToolAuthorization: vi.fn(),
|
||||
syncToolProvider: vi.fn(),
|
||||
updateTool: vi.fn(),
|
||||
updateToolProvider: vi.fn()
|
||||
}))
|
||||
|
||||
const highRiskTool = {
|
||||
id: 9,
|
||||
code: 'mcp.ops.restart',
|
||||
provider_id: 3,
|
||||
external_name: 'restart',
|
||||
name: '重启服务',
|
||||
description: '重启指定内部服务',
|
||||
input_schema: { type: 'object' },
|
||||
risk_level: 'high_risk',
|
||||
enabled: true,
|
||||
synced_at: '2026-07-27T08:00:00Z'
|
||||
}
|
||||
|
||||
const pendingCall = {
|
||||
public_id: '11111111-1111-1111-1111-111111111111',
|
||||
tool_code: highRiskTool.code,
|
||||
requested_by_id: 2,
|
||||
requested_by_name: '支持负责人',
|
||||
status: 'pending_confirmation',
|
||||
input_payload: { service: 'ticket-api' },
|
||||
output_payload: null,
|
||||
authorization_snapshot: { allowed: true },
|
||||
risk_snapshot: { risk_level: 'high_risk' },
|
||||
error_code: '',
|
||||
error_message: '',
|
||||
confirmation_status: 'pending',
|
||||
confirmation_reason: '',
|
||||
created_at: '2026-07-27T08:10:00Z',
|
||||
started_at: null,
|
||||
finished_at: null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
})
|
||||
mocks.listTools.mockResolvedValue([highRiskTool])
|
||||
mocks.listToolProviders.mockResolvedValue([])
|
||||
mocks.listToolCalls.mockResolvedValue([pendingCall])
|
||||
mocks.listToolAuthorizations.mockResolvedValue([])
|
||||
mocks.confirmToolCall.mockResolvedValue({
|
||||
...pendingCall,
|
||||
status: 'succeeded',
|
||||
confirmation_status: 'approved'
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('工具治理页面', () => {
|
||||
it('展示风险分层并完成高风险调用的独立确认', async () => {
|
||||
render(<ToolManagementPage />)
|
||||
|
||||
expect((await screen.findAllByText('重启服务')).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('高风险').length).toBeGreaterThan(0)
|
||||
|
||||
const callsTab = screen.getByRole('tab', { name: '调用审计' })
|
||||
fireEvent.mouseDown(callsTab, { button: 0, ctrlKey: false })
|
||||
expect(await screen.findByText('待独立确认')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('独立确认意见'), {
|
||||
target: { value: '已核对变更窗口' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '批准并执行' }))
|
||||
|
||||
await waitFor(() => expect(mocks.confirmToolCall).toHaveBeenCalledWith(
|
||||
pendingCall.public_id,
|
||||
'approve',
|
||||
'已核对变更窗口'
|
||||
))
|
||||
})
|
||||
})
|
||||
896
frontend/src/features/tools/ToolManagementPage.tsx
Normal file
896
frontend/src/features/tools/ToolManagementPage.tsx
Normal file
@@ -0,0 +1,896 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Activity,
|
||||
BadgeCheck,
|
||||
Cable,
|
||||
Check,
|
||||
CircleAlert,
|
||||
Clock3,
|
||||
KeyRound,
|
||||
LockKeyhole,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldQuestion,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { AppPage } from '@/components/app/AppPage'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@/components/ui/card'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { JsonEditor, parseJsonObject } from '@/features/ai-platform/components/JsonEditor'
|
||||
import { useAuth } from '@/features/auth/AuthProvider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
confirmToolCall,
|
||||
createToolCall,
|
||||
createToolProvider,
|
||||
listToolAuthorizations,
|
||||
listToolCalls,
|
||||
listToolProviders,
|
||||
listTools,
|
||||
saveToolAuthorization,
|
||||
syncToolProvider,
|
||||
updateTool,
|
||||
updateToolProvider
|
||||
} from './api'
|
||||
import type {
|
||||
ToolAuthorization,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
ToolPrincipalType,
|
||||
ToolProvider,
|
||||
ToolRiskLevel,
|
||||
UnifiedTool
|
||||
} from './types'
|
||||
|
||||
const RISK_META: Record<ToolRiskLevel, {
|
||||
label: string
|
||||
description: string
|
||||
badge: 'success' | 'warning' | 'danger'
|
||||
icon: typeof ShieldCheck
|
||||
}> = {
|
||||
read_only: {
|
||||
label: '只读',
|
||||
description: '查询数据,不改变外部状态',
|
||||
badge: 'success',
|
||||
icon: ShieldCheck
|
||||
},
|
||||
low_write: {
|
||||
label: '低风险写',
|
||||
description: '受审计约束的可逆写入',
|
||||
badge: 'warning',
|
||||
icon: ShieldQuestion
|
||||
},
|
||||
high_risk: {
|
||||
label: '高风险',
|
||||
description: '执行前必须由他人独立确认',
|
||||
badge: 'danger',
|
||||
icon: ShieldAlert
|
||||
}
|
||||
}
|
||||
|
||||
const CALL_STATUS: Record<ToolCallStatus, {
|
||||
label: string
|
||||
badge: 'secondary' | 'info' | 'success' | 'danger' | 'warning'
|
||||
}> = {
|
||||
pending_confirmation: { label: '待独立确认', badge: 'warning' },
|
||||
running: { label: '执行中', badge: 'info' },
|
||||
succeeded: { label: '已成功', badge: 'success' },
|
||||
failed: { label: '失败', badge: 'danger' },
|
||||
rejected: { label: '已拒绝', badge: 'secondary' }
|
||||
}
|
||||
|
||||
const PRINCIPAL_LABELS: Record<ToolPrincipalType, string> = {
|
||||
user: '用户',
|
||||
group: '用户组',
|
||||
agent: 'Agent'
|
||||
}
|
||||
|
||||
const providerInitial = {
|
||||
name: '',
|
||||
base_url: '',
|
||||
token: '',
|
||||
timeout_seconds: '30'
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return '—'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function compactJson(value: unknown) {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
export function ToolManagementPage() {
|
||||
const { user } = useAuth()
|
||||
const [tools, setTools] = React.useState<UnifiedTool[]>([])
|
||||
const [providers, setProviders] = React.useState<ToolProvider[]>([])
|
||||
const [calls, setCalls] = React.useState<ToolCall[]>([])
|
||||
const [authorizations, setAuthorizations] = React.useState<ToolAuthorization[]>([])
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [error, setError] = React.useState('')
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const [providerOpen, setProviderOpen] = React.useState(false)
|
||||
const [providerForm, setProviderForm] = React.useState(providerInitial)
|
||||
const [providerError, setProviderError] = React.useState('')
|
||||
const [callInput, setCallInput] = React.useState('{}')
|
||||
const [grantType, setGrantType] = React.useState<ToolPrincipalType>('group')
|
||||
const [grantId, setGrantId] = React.useState('')
|
||||
const [grantAllowed, setGrantAllowed] = React.useState(true)
|
||||
const [confirmationReasons, setConfirmationReasons] = React.useState<Record<string, string>>({})
|
||||
|
||||
const hasPermission = React.useCallback((permission: string) => Boolean(
|
||||
user?.is_superuser || user?.permissions.includes(permission)
|
||||
), [user])
|
||||
const canManage = hasPermission('tool.manage')
|
||||
const canInvoke = hasPermission('tool.invoke')
|
||||
const canConfirm = hasPermission('tool.confirm')
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [nextTools, nextProviders, nextCalls] = await Promise.all([
|
||||
listTools(),
|
||||
canManage ? listToolProviders() : Promise.resolve([]),
|
||||
canInvoke ? listToolCalls() : Promise.resolve([])
|
||||
])
|
||||
setTools(nextTools)
|
||||
setProviders(nextProviders)
|
||||
setCalls(nextCalls)
|
||||
setSelectedId((current) => (
|
||||
current && nextTools.some((tool) => tool.id === current)
|
||||
? current
|
||||
: nextTools[0]?.id ?? null
|
||||
))
|
||||
setError('')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '工具治理数据加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [canInvoke, canManage])
|
||||
|
||||
React.useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const selected = tools.find((tool) => tool.id === selectedId) ?? null
|
||||
|
||||
React.useEffect(() => {
|
||||
setCallInput('{}')
|
||||
setGrantId('')
|
||||
if (!selected || !canManage) {
|
||||
setAuthorizations([])
|
||||
return
|
||||
}
|
||||
listToolAuthorizations(selected.id)
|
||||
.then(setAuthorizations)
|
||||
.catch(() => setAuthorizations([]))
|
||||
}, [canManage, selected])
|
||||
|
||||
async function execute(key: string, action: () => Promise<unknown>, message: string) {
|
||||
setBusy(key)
|
||||
try {
|
||||
await action()
|
||||
toast.success(message)
|
||||
await load()
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : '操作未完成')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateProvider(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setBusy('provider-create')
|
||||
setProviderError('')
|
||||
try {
|
||||
await createToolProvider({
|
||||
name: providerForm.name,
|
||||
provider_type: 'mcp_http',
|
||||
base_url: providerForm.base_url,
|
||||
token: providerForm.token,
|
||||
timeout_seconds: Number(providerForm.timeout_seconds),
|
||||
enabled: true
|
||||
})
|
||||
setProviderForm(providerInitial)
|
||||
setProviderOpen(false)
|
||||
toast.success('MCP 服务已接入')
|
||||
await load()
|
||||
} catch (reason) {
|
||||
setProviderError(reason instanceof Error ? reason.message : 'MCP 服务创建失败')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveGrant(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!selected) return
|
||||
const principalId = Number(grantId)
|
||||
if (!Number.isInteger(principalId) || principalId <= 0) {
|
||||
toast.error('授权主体 ID 必须是正整数')
|
||||
return
|
||||
}
|
||||
setBusy('grant')
|
||||
try {
|
||||
await saveToolAuthorization(selected.id, {
|
||||
principal_type: grantType,
|
||||
principal_id: principalId,
|
||||
allowed: grantAllowed
|
||||
})
|
||||
setAuthorizations(await listToolAuthorizations(selected.id))
|
||||
setGrantId('')
|
||||
toast.success(grantAllowed ? '授权规则已保存' : '拒绝规则已保存')
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : '授权规则保存失败')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvoke() {
|
||||
if (!selected) return
|
||||
try {
|
||||
const input = parseJsonObject(callInput, '工具输入')
|
||||
const idempotencyKey = `console-${selected.id}-${crypto.randomUUID()}`
|
||||
setBusy('invoke')
|
||||
const call = await createToolCall(selected.code, input, idempotencyKey)
|
||||
setCalls((current) => [call, ...current.filter((item) => item.public_id !== call.public_id)])
|
||||
toast.success(
|
||||
call.status === 'pending_confirmation'
|
||||
? '调用已进入独立确认队列'
|
||||
: '工具调用已执行'
|
||||
)
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : '工具调用失败')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmation(call: ToolCall, decision: 'approve' | 'reject') {
|
||||
const reason = confirmationReasons[call.public_id]?.trim() ?? ''
|
||||
if (decision === 'reject' && !reason) {
|
||||
toast.error('拒绝高风险调用时必须填写原因')
|
||||
return
|
||||
}
|
||||
await execute(
|
||||
`confirm-${call.public_id}`,
|
||||
() => confirmToolCall(call.public_id, decision, reason),
|
||||
decision === 'approve' ? '高风险调用已批准并执行' : '高风险调用已拒绝'
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasPermission('tool.view')) {
|
||||
return (
|
||||
<AppPage
|
||||
maxWidth="7xl"
|
||||
header={<AppPageHeader title="工具治理" description="统一管理 Tool、MCP、授权和调用风险。" />}
|
||||
>
|
||||
<Alert variant="destructive">
|
||||
<LockKeyhole />
|
||||
<AlertTitle>没有查看权限</AlertTitle>
|
||||
<AlertDescription>请联系管理员授予“查看工具目录”权限。</AlertDescription>
|
||||
</Alert>
|
||||
</AppPage>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppPage
|
||||
maxWidth="7xl"
|
||||
contentClassName="gap-5"
|
||||
header={(
|
||||
<AppPageHeader
|
||||
title="工具治理"
|
||||
description="统一接入 HTTP MCP,按用户、用户组和 Agent 计算授权交集,并留存每次调用证据。"
|
||||
meta={<Badge variant="ai"><BadgeCheck />受控执行</Badge>}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCw data-icon="inline-start" />
|
||||
刷新
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button onClick={() => setProviderOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
接入 MCP
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{(Object.entries(RISK_META) as Array<[ToolRiskLevel, typeof RISK_META[ToolRiskLevel]]>)
|
||||
.map(([risk, meta]) => {
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<Card
|
||||
key={risk}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'border-l-4',
|
||||
risk === 'read_only' && 'border-l-success',
|
||||
risk === 'low_write' && 'border-l-warning',
|
||||
risk === 'high_risk' && 'border-l-danger'
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon />
|
||||
{meta.label}
|
||||
</CardTitle>
|
||||
<strong className="text-xl tabular-nums">
|
||||
{tools.filter((tool) => tool.risk_level === risk).length}
|
||||
</strong>
|
||||
</div>
|
||||
<CardDescription>{meta.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>工具治理数据加载失败</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3">
|
||||
<Skeleton className="h-10 w-80" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="catalog">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="catalog">工具目录</TabsTrigger>
|
||||
{canManage ? <TabsTrigger value="providers">MCP 服务</TabsTrigger> : null}
|
||||
{canInvoke ? <TabsTrigger value="calls">调用审计</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="catalog" className="mt-4">
|
||||
<div className="grid gap-4 xl:grid-cols-[340px_minmax(0,1fr)]">
|
||||
<Card className="min-h-[560px]">
|
||||
<CardHeader>
|
||||
<CardTitle>统一工具目录</CardTitle>
|
||||
<CardDescription>选择工具查看输入契约、授权边界和运行入口。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{tools.length === 0 ? (
|
||||
<AppEmptyState
|
||||
title="还没有可用工具"
|
||||
description={canManage ? '先接入 MCP 服务并同步工具目录。' : '等待管理员接入并授权工具。'}
|
||||
/>
|
||||
) : tools.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(tool.id)}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-colors hover:bg-muted/60',
|
||||
selectedId === tool.id && 'border-primary/40 bg-primary/5 ring-1 ring-primary/15'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<strong className="truncate text-sm">{tool.name}</strong>
|
||||
<Badge variant={RISK_META[tool.risk_level].badge}>
|
||||
{RISK_META[tool.risk_level].label}
|
||||
</Badge>
|
||||
</span>
|
||||
<code className="truncate text-xs text-muted-foreground">{tool.code}</code>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{tool.description || '暂无工具说明'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!selected ? (
|
||||
<Card className="min-h-[560px]">
|
||||
<CardContent className="flex min-h-[560px] items-center justify-center">
|
||||
<AppEmptyState title="选择一个工具" description="查看风险等级、授权规则和输入契约。" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={RISK_META[selected.risk_level].badge}>
|
||||
{RISK_META[selected.risk_level].label}
|
||||
</Badge>
|
||||
<Badge variant={selected.enabled ? 'success' : 'secondary'}>
|
||||
{selected.enabled ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
</span>
|
||||
<CardTitle>{selected.name}</CardTitle>
|
||||
<CardDescription>{selected.description || '暂无工具说明'}</CardDescription>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">启用</span>
|
||||
<Switch
|
||||
checked={selected.enabled}
|
||||
disabled={busy === `tool-${selected.id}`}
|
||||
onCheckedChange={(enabled) => void execute(
|
||||
`tool-${selected.id}`,
|
||||
() => updateTool(selected.id, { enabled }),
|
||||
enabled ? '工具已启用' : '工具已停用'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-muted/45 p-3">
|
||||
<span className="text-xs text-muted-foreground">统一编码</span>
|
||||
<code className="mt-1 block break-all text-xs">{selected.code}</code>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/45 p-3">
|
||||
<span className="text-xs text-muted-foreground">最近同步</span>
|
||||
<strong className="mt-1 block text-sm">{formatDateTime(selected.synced_at)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<AppField label="风险等级" description="风险等级在调用创建时固化为审计快照。">
|
||||
<Select
|
||||
value={selected.risk_level}
|
||||
onValueChange={(riskLevel: ToolRiskLevel) => void execute(
|
||||
`tool-${selected.id}`,
|
||||
() => updateTool(selected.id, { risk_level: riskLevel }),
|
||||
'工具风险等级已更新'
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(Object.keys(RISK_META) as ToolRiskLevel[]).map((risk) => (
|
||||
<SelectItem key={risk} value={risk}>
|
||||
{RISK_META[risk].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">输入契约</span>
|
||||
<pre className="max-h-72 overflow-auto rounded-lg bg-muted/55 p-3 text-xs leading-5">
|
||||
{compactJson(selected.input_schema)}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>授权交集</CardTitle>
|
||||
<CardDescription>
|
||||
用户、用户组和 Agent 规则共同生效;任一明确拒绝都会阻止调用。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{!canManage ? (
|
||||
<Alert>
|
||||
<LockKeyhole />
|
||||
<AlertTitle>授权规则受保护</AlertTitle>
|
||||
<AlertDescription>当前账号可以查看工具,但不能维护授权主体。</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{authorizations.length === 0 ? (
|
||||
<AppEmptyState
|
||||
title="尚未配置主体规则"
|
||||
description="Agent 调用还需要运行快照和显式 Agent 授权。"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{authorizations.map((authorization) => (
|
||||
<div
|
||||
key={authorization.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{PRINCIPAL_LABELS[authorization.principal_type]} #{authorization.principal_id}
|
||||
</span>
|
||||
<Badge variant={authorization.allowed ? 'success' : 'danger'}>
|
||||
{authorization.allowed ? '允许' : '明确拒绝'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSaveGrant}>
|
||||
<FieldGroup>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<AppField label="授权主体">
|
||||
<Select
|
||||
value={grantType}
|
||||
onValueChange={(value: ToolPrincipalType) => setGrantType(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(Object.keys(PRINCIPAL_LABELS) as ToolPrincipalType[]).map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{PRINCIPAL_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
<AppField label="主体 ID" htmlFor="tool-principal-id">
|
||||
<Input
|
||||
id="tool-principal-id"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
value={grantId}
|
||||
onChange={(event) => setGrantId(event.target.value)}
|
||||
placeholder="输入数据库 ID"
|
||||
/>
|
||||
</AppField>
|
||||
</div>
|
||||
<AppField label="规则结果">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={grantAllowed} onCheckedChange={setGrantAllowed} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{grantAllowed ? '允许该主体调用' : '明确拒绝该主体调用'}
|
||||
</span>
|
||||
</div>
|
||||
</AppField>
|
||||
<Button type="submit" variant="outline" disabled={busy === 'grant'}>
|
||||
<KeyRound data-icon="inline-start" />
|
||||
保存授权规则
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>受控试运行</CardTitle>
|
||||
<CardDescription>
|
||||
使用真实授权和输入校验;高风险调用只创建确认任务,不会直接执行。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{canInvoke ? (
|
||||
<JsonEditor
|
||||
id="tool-call-input"
|
||||
label="工具输入"
|
||||
value={callInput}
|
||||
onChange={setCallInput}
|
||||
description="必须符合该工具的 JSON Schema。"
|
||||
rows={10}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
<LockKeyhole />
|
||||
<AlertTitle>没有调用权限</AlertTitle>
|
||||
<AlertDescription>查看工具不代表可以执行工具。</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
{canInvoke ? (
|
||||
<CardFooter className="justify-end">
|
||||
<Button onClick={() => void handleInvoke()} disabled={busy === 'invoke' || !selected.enabled}>
|
||||
<Play data-icon="inline-start" />
|
||||
{selected.risk_level === 'high_risk' ? '提交确认' : '执行试运行'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{canManage ? (
|
||||
<TabsContent value="providers" className="mt-4">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{providers.length === 0 ? (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="py-12">
|
||||
<AppEmptyState
|
||||
title="还没有 MCP 服务"
|
||||
description="接入一个支持 tools/list 与 tools/call 的 HTTP MCP 服务。"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : providers.map((provider) => (
|
||||
<Card key={provider.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cable />
|
||||
{provider.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 break-all">{provider.base_url}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={provider.enabled ? 'success' : 'secondary'}>
|
||||
{provider.enabled ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg bg-muted/45 p-3">
|
||||
<span className="text-xs text-muted-foreground">协议</span>
|
||||
<strong className="mt-1 block text-sm">HTTP MCP</strong>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/45 p-3">
|
||||
<span className="text-xs text-muted-foreground">凭证</span>
|
||||
<strong className="mt-1 block text-sm">
|
||||
{provider.credential_set ? '已加密保存' : '未配置'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/45 p-3">
|
||||
<span className="text-xs text-muted-foreground">超时</span>
|
||||
<strong className="mt-1 block text-sm">{provider.timeout_seconds} 秒</strong>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={provider.enabled}
|
||||
onCheckedChange={(enabled) => void execute(
|
||||
`provider-${provider.id}`,
|
||||
() => updateToolProvider(provider.id, { enabled }),
|
||||
enabled ? 'MCP 服务已启用' : 'MCP 服务已停用'
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">服务开关</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void execute(
|
||||
`sync-${provider.id}`,
|
||||
() => syncToolProvider(provider.id),
|
||||
'工具目录同步完成'
|
||||
)}
|
||||
disabled={busy === `sync-${provider.id}` || !provider.enabled}
|
||||
>
|
||||
<RotateCw data-icon="inline-start" />
|
||||
同步 tools/list
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canInvoke ? (
|
||||
<TabsContent value="calls" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>调用审计</CardTitle>
|
||||
<CardDescription>
|
||||
固化工具、授权、风险、输入与输出快照;最多显示最近 100 条。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{calls.length} 条记录</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{calls.length === 0 ? (
|
||||
<AppEmptyState title="还没有工具调用" description="从工具目录发起一次受控试运行。" />
|
||||
) : calls.map((call) => {
|
||||
const status = CALL_STATUS[call.status]
|
||||
const isSelfReview = call.requested_by_id === user?.id
|
||||
return (
|
||||
<div key={call.public_id} className="rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<code className="truncate text-sm font-medium">{call.tool_code}</code>
|
||||
<Badge variant={status.badge}>{status.label}</Badge>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{call.requested_by_name || '系统运行'} · {formatDateTime(call.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{call.finished_at ? <Activity /> : <Clock3 />}
|
||||
{call.finished_at ? `完成于 ${formatDateTime(call.finished_at)}` : '尚未完成'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">输入快照</span>
|
||||
<pre className="max-h-44 overflow-auto rounded-md bg-muted/45 p-3 text-xs">
|
||||
{compactJson(call.input_payload)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">输出 / 错误</span>
|
||||
<pre className="max-h-44 overflow-auto rounded-md bg-muted/45 p-3 text-xs">
|
||||
{call.error_message || compactJson(call.output_payload)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
{call.status === 'pending_confirmation' ? (
|
||||
<div className="mt-3 rounded-lg border border-warning/30 bg-warning/5 p-3">
|
||||
<AppField
|
||||
label="独立确认意见"
|
||||
htmlFor={`confirmation-${call.public_id}`}
|
||||
description={isSelfReview ? '申请人不能确认自己的高风险调用。' : '拒绝时必须填写原因。'}
|
||||
>
|
||||
<Textarea
|
||||
id={`confirmation-${call.public_id}`}
|
||||
value={confirmationReasons[call.public_id] ?? ''}
|
||||
disabled={!canConfirm || isSelfReview}
|
||||
onChange={(event) => setConfirmationReasons((current) => ({
|
||||
...current,
|
||||
[call.public_id]: event.target.value
|
||||
}))}
|
||||
placeholder="记录核对范围、变更窗口和风险判断。"
|
||||
/>
|
||||
</AppField>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canConfirm || isSelfReview || busy === `confirm-${call.public_id}`}
|
||||
onClick={() => void handleConfirmation(call, 'reject')}
|
||||
>
|
||||
<X data-icon="inline-start" />
|
||||
拒绝
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canConfirm || isSelfReview || busy === `confirm-${call.public_id}`}
|
||||
onClick={() => void handleConfirmation(call, 'approve')}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
批准并执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{call.confirmation_reason ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
确认意见:{call.confirmation_reason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<AppCreateDialog
|
||||
open={providerOpen}
|
||||
onOpenChange={(open) => {
|
||||
setProviderOpen(open)
|
||||
if (!open) setProviderError('')
|
||||
}}
|
||||
title="接入 HTTP MCP"
|
||||
description="平台将通过 JSON-RPC 调用 tools/list 和 tools/call;访问令牌由后端加密保存。"
|
||||
submitLabel="接入服务"
|
||||
onSubmit={handleCreateProvider}
|
||||
pending={busy === 'provider-create'}
|
||||
error={providerError}
|
||||
>
|
||||
<FieldGroup>
|
||||
<AppField label="服务名称" htmlFor="tool-provider-name" required>
|
||||
<Input
|
||||
id="tool-provider-name"
|
||||
autoFocus
|
||||
required
|
||||
value={providerForm.name}
|
||||
onChange={(event) => setProviderForm({ ...providerForm, name: event.target.value })}
|
||||
placeholder="例如:内部客户查询 MCP"
|
||||
/>
|
||||
</AppField>
|
||||
<AppField
|
||||
label="JSON-RPC 地址"
|
||||
htmlFor="tool-provider-url"
|
||||
description="填写可直接接收 MCP JSON-RPC 请求的完整 HTTPS 地址。"
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="tool-provider-url"
|
||||
type="url"
|
||||
required
|
||||
value={providerForm.base_url}
|
||||
onChange={(event) => setProviderForm({ ...providerForm, base_url: event.target.value })}
|
||||
placeholder="https://mcp.internal.example/rpc"
|
||||
/>
|
||||
</AppField>
|
||||
<AppField
|
||||
label="Bearer Token"
|
||||
htmlFor="tool-provider-token"
|
||||
description="可留空;保存后不会再次返回明文。"
|
||||
>
|
||||
<Input
|
||||
id="tool-provider-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={providerForm.token}
|
||||
onChange={(event) => setProviderForm({ ...providerForm, token: event.target.value })}
|
||||
placeholder="输入服务访问令牌"
|
||||
/>
|
||||
</AppField>
|
||||
<AppField label="请求超时(秒)" htmlFor="tool-provider-timeout">
|
||||
<Input
|
||||
id="tool-provider-timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
value={providerForm.timeout_seconds}
|
||||
onChange={(event) => setProviderForm({
|
||||
...providerForm,
|
||||
timeout_seconds: event.target.value
|
||||
})}
|
||||
/>
|
||||
</AppField>
|
||||
</FieldGroup>
|
||||
</AppCreateDialog>
|
||||
</AppPage>
|
||||
)
|
||||
}
|
||||
116
frontend/src/features/tools/api.ts
Normal file
116
frontend/src/features/tools/api.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { apiRequest } from '@/lib/api'
|
||||
|
||||
import type {
|
||||
ToolAuthorization,
|
||||
ToolCall,
|
||||
ToolPrincipalType,
|
||||
ToolProvider,
|
||||
ToolRiskLevel,
|
||||
UnifiedTool
|
||||
} from './types'
|
||||
|
||||
export function listToolProviders() {
|
||||
return apiRequest<ToolProvider[]>('/tools/providers')
|
||||
}
|
||||
|
||||
export function createToolProvider(payload: {
|
||||
name: string
|
||||
provider_type: 'mcp_http'
|
||||
base_url: string
|
||||
token: string
|
||||
timeout_seconds: number
|
||||
enabled: boolean
|
||||
}) {
|
||||
return apiRequest<ToolProvider>('/tools/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function updateToolProvider(
|
||||
providerId: number,
|
||||
payload: Partial<Pick<ToolProvider, 'name' | 'base_url' | 'enabled' | 'timeout_seconds'>>
|
||||
) {
|
||||
return apiRequest<ToolProvider>(`/tools/providers/${providerId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function syncToolProvider(providerId: number) {
|
||||
return apiRequest<UnifiedTool[]>(`/tools/providers/${providerId}/sync`, {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function listTools() {
|
||||
return apiRequest<UnifiedTool[]>('/tools')
|
||||
}
|
||||
|
||||
export function updateTool(
|
||||
toolId: number,
|
||||
payload: Partial<Pick<UnifiedTool, 'name' | 'description' | 'risk_level' | 'enabled'>>
|
||||
) {
|
||||
return apiRequest<UnifiedTool>(`/tools/${toolId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function listToolAuthorizations(toolId: number) {
|
||||
return apiRequest<ToolAuthorization[]>(`/tools/${toolId}/authorizations`)
|
||||
}
|
||||
|
||||
export function saveToolAuthorization(
|
||||
toolId: number,
|
||||
payload: {
|
||||
principal_type: ToolPrincipalType
|
||||
principal_id: number
|
||||
allowed: boolean
|
||||
}
|
||||
) {
|
||||
return apiRequest<ToolAuthorization>(`/tools/${toolId}/authorizations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function listToolCalls(filters?: { status?: string; tool_code?: string }) {
|
||||
const search = new URLSearchParams()
|
||||
if (filters?.status) search.set('status', filters.status)
|
||||
if (filters?.tool_code) search.set('tool_code', filters.tool_code)
|
||||
const query = search.toString()
|
||||
return apiRequest<ToolCall[]>(`/tools/calls${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export function createToolCall(
|
||||
toolCode: string,
|
||||
input: Record<string, unknown>,
|
||||
idempotencyKey: string
|
||||
) {
|
||||
return apiRequest<ToolCall>('/tools/calls', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tool_code: toolCode,
|
||||
input,
|
||||
idempotency_key: idempotencyKey
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function confirmToolCall(
|
||||
publicId: string,
|
||||
decision: 'approve' | 'reject',
|
||||
reason: string
|
||||
) {
|
||||
return apiRequest<ToolCall>(`/tools/calls/${publicId}/confirmation`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ decision, reason })
|
||||
})
|
||||
}
|
||||
|
||||
export const TOOL_RISK_LEVELS: ToolRiskLevel[] = [
|
||||
'read_only',
|
||||
'low_write',
|
||||
'high_risk'
|
||||
]
|
||||
61
frontend/src/features/tools/types.ts
Normal file
61
frontend/src/features/tools/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type ToolRiskLevel = 'read_only' | 'low_write' | 'high_risk'
|
||||
export type ToolProviderType = 'native' | 'mcp_http'
|
||||
export type ToolPrincipalType = 'user' | 'group' | 'agent'
|
||||
export type ToolCallStatus =
|
||||
| 'pending_confirmation'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'rejected'
|
||||
|
||||
export type ToolProvider = {
|
||||
id: number
|
||||
name: string
|
||||
provider_type: ToolProviderType
|
||||
base_url: string
|
||||
credential_set: boolean
|
||||
enabled: boolean
|
||||
timeout_seconds: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type UnifiedTool = {
|
||||
id: number
|
||||
code: string
|
||||
provider_id: number | null
|
||||
external_name: string
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Record<string, unknown>
|
||||
risk_level: ToolRiskLevel
|
||||
enabled: boolean
|
||||
synced_at: string | null
|
||||
}
|
||||
|
||||
export type ToolAuthorization = {
|
||||
id: number
|
||||
tool_id: number
|
||||
principal_type: ToolPrincipalType
|
||||
principal_id: number
|
||||
allowed: boolean
|
||||
}
|
||||
|
||||
export type ToolCall = {
|
||||
public_id: string
|
||||
tool_code: string
|
||||
requested_by_id: number | null
|
||||
requested_by_name: string
|
||||
status: ToolCallStatus
|
||||
input_payload: Record<string, unknown>
|
||||
output_payload: unknown
|
||||
authorization_snapshot: Record<string, unknown>
|
||||
risk_snapshot: Record<string, unknown>
|
||||
error_code: string
|
||||
error_message: string
|
||||
confirmation_status: string | null
|
||||
confirmation_reason: string
|
||||
created_at: string
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
Reference in New Issue
Block a user