feat: add secure agent and MCP extensions

This commit is contained in:
hailin
2026-07-27 19:02:37 +08:00
parent 07c9ea1cc4
commit d93cb75c10
29 changed files with 3186 additions and 21 deletions

View 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,
}

View File

@@ -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>
}

View File

@@ -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>

View File

@@ -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> }
]

View File

@@ -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

View File

@@ -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)
})
})

View File

@@ -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>

View File

@@ -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>

View 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',
'已核对变更窗口'
))
})
})

View 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>
)
}

View 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'
]

View 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
}