0.11.4: 旗舰版第五轮完善(智能体节点任务下发/个人智能体安全策略)

- 节点任务:管理端向节点池下发(Prompt/HTTP/MCP/Skill/数字员工/自定义),
  指定节点或池路由,认领 SKIP LOCKED + 15 分钟租约,认领令牌防重放上报,
  失败 30s×次数退避重入队,达上限 failed,支持取消/重试,完成与失败站内信。
- 个人智能体安全策略:auto_approve_tools 跳过个人调用审批门;
  rate_limit_multiplier 按 (tool,user) 独立窗口放宽个人限流(全局额度不受影响)。
- 修复存量缺陷:/v1/agent/nodes/ 未挂 publicMux,节点心跳/认领端点在部署
  拓扑下不可达。
- 迁移 000046;任务全链路集成测试连真实库通过,HTTP 端到端验证
  (下发→认领→伪造令牌拒绝→上报→succeeded,列表不泄露认领令牌);
  25 包测试通过,前后端构建通过。
This commit is contained in:
LLMGuardX Dev
2026-08-13 14:30:37 +08:00
parent 87c2b04174
commit d534865b33
16 changed files with 1082 additions and 15 deletions
+13
View File
@@ -67,3 +67,16 @@ export function rotateAgentNodeToken(id: string) {
export function previewAgentNodeRoute(data: AgentNodeRoutePreviewInput) {
return request.post<AgentNodeRoutePreview>({ url: '/api/v1/admin/agent-nodes/route-preview', params: data })
}
// --- 节点任务下发 ---
export interface AgentTask {
id: string; task_type: string; payload: Record<string, unknown>
pool_type: string; pool_code: string; node_id?: string; node_code: string
status: string; attempts: number; max_attempts: number
result?: Record<string, unknown>; error: string
created_at: string; claimed_at?: string; finished_at?: string; available_at: string
}
export const fetchAgentTasks=(status='')=>request.get<AgentTask[]>({url:'/api/v1/admin/agent-tasks',params:{status}})
export const createAgentTask=(params:{task_type:string;payload:Record<string,unknown>;pool_type?:string;pool_code?:string;node_id?:string;max_attempts?:number})=>request.post<AgentTask>({url:'/api/v1/admin/agent-tasks',params})
export const cancelAgentTask=(id:string)=>request.post<{cancelled:boolean}>({url:`/api/v1/admin/agent-tasks/${id}/cancel`})
export const retryAgentTask=(id:string)=>request.post<{retried:boolean}>({url:`/api/v1/admin/agent-tasks/${id}/retry`})
@@ -0,0 +1,162 @@
<template>
<div class="page-content">
<div class="mb-5 flex items-center justify-between gap-4">
<div>
<h2 class="text-xl font-semibold">节点任务</h2>
<p class="text-g-500 mt-1 text-sm">向节点池下发任务节点按池认领执行并上报结果失败自动退避重试</p>
</div>
<div class="flex gap-2">
<ElSelect v-model="statusFilter" class="w-36" @change="load">
<ElOption label="全部状态" value="" />
<ElOption label="排队中" value="queued" />
<ElOption label="已认领" value="claimed" />
<ElOption label="进行中" value="running" />
<ElOption label="成功" value="succeeded" />
<ElOption label="失败" value="failed" />
<ElOption label="已取消" value="cancelled" />
</ElSelect>
<ElButton type="primary" @click="openDispatch">下发任务</ElButton>
</div>
</div>
<ElTable v-loading="loading" :data="items" row-key="id">
<ElTableColumn label="状态" width="95">
<template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusLabel(row.status) }}</ElTag></template>
</ElTableColumn>
<ElTableColumn label="类型" width="120"><template #default="{ row }">{{ typeLabel(row.task_type) }}</template></ElTableColumn>
<ElTableColumn label="目标" min-width="160">
<template #default="{ row }">{{ row.node_code || `${row.pool_type === 'public' ? '公有' : '私有'}池 / ${row.pool_code}` }}</template>
</ElTableColumn>
<ElTableColumn label="Payload" min-width="220">
<template #default="{ row }"><code class="text-xs">{{ JSON.stringify(row.payload || {}).slice(0, 80) }}</code></template>
</ElTableColumn>
<ElTableColumn label="尝试" width="90"><template #default="{ row }">{{ row.attempts }} / {{ row.max_attempts }}</template></ElTableColumn>
<ElTableColumn label="结果 / 错误" min-width="200">
<template #default="{ row }">
<span v-if="row.error" class="text-danger text-xs">{{ row.error.slice(0, 80) }}</span>
<code v-else-if="row.result" class="text-xs">{{ JSON.stringify(row.result).slice(0, 80) }}</code>
<span v-else class="text-g-400 text-xs"></span>
</template>
</ElTableColumn>
<ElTableColumn prop="created_at" label="创建时间" width="175" />
<ElTableColumn label="操作" width="140" fixed="right">
<template #default="{ row }">
<ElButton v-if="row.status === 'queued'" link type="danger" @click="cancel(row)">取消</ElButton>
<ElButton v-if="row.status === 'failed'" link type="primary" @click="retry(row)">重试</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElDialog v-model="visible" title="下发节点任务" width="620px">
<ElForm label-width="110px">
<ElFormItem label="任务类型" required>
<ElSelect v-model="form.task_type" class="w-full">
<ElOption label="Prompt 执行" value="prompt" />
<ElOption label="HTTP 调用" value="http" />
<ElOption label="MCP 调用" value="mcp_invoke" />
<ElOption label="Skill 运行" value="skill_run" />
<ElOption label="数字员工" value="digital_employee" />
<ElOption label="自定义" value="custom" />
</ElSelect>
</ElFormItem>
<div class="grid grid-cols-2 gap-4">
<ElFormItem label="节点池">
<ElSelect v-model="form.pool_type" class="w-full">
<ElOption label="私有池" value="private" />
<ElOption label="公有池" value="public" />
</ElSelect>
</ElFormItem>
<ElFormItem label="池编码">
<ElInput v-model="form.pool_code" maxlength="64" />
</ElFormItem>
</div>
<ElFormItem label="指定节点">
<ElSelect v-model="form.node_id" clearable filterable class="w-full" placeholder="留空 = 池内任意节点认领">
<ElOption v-for="node in nodes" :key="node.id" :label="`${node.name} (${node.code})`" :value="node.id" />
</ElSelect>
</ElFormItem>
<ElFormItem label="Payload JSON" required>
<ElInput v-model="payloadJSON" type="textarea" :rows="7" placeholder='{"model":"gpt-4o-mini","messages":[{"role":"user","content":"你好"}]}' />
</ElFormItem>
<ElFormItem label="最大尝试">
<ElInputNumber v-model="form.max_attempts" :min="1" :max="10" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="visible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="submit">下发</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { AgentNode, fetchAgentNodes } from '@/api/agent-nodes'
import { AgentTask, cancelAgentTask, createAgentTask, fetchAgentTasks, retryAgentTask } from '@/api/agent-nodes'
const loading = ref(false)
const saving = ref(false)
const visible = ref(false)
const statusFilter = ref('')
const items = ref<AgentTask[]>([])
const nodes = ref<AgentNode[]>([])
const payloadJSON = ref('{}')
const form = reactive({ task_type: 'prompt', pool_type: 'private', pool_code: 'default', node_id: undefined as string | undefined, max_attempts: 3 })
const statusText: Record<string, string> = { queued: '排队中', claimed: '已认领', running: '进行中', succeeded: '成功', failed: '失败', cancelled: '已取消' }
const statusLabel = (v: string) => statusText[v] || v
const statusType = (v: string) => (({ succeeded: 'success', failed: 'danger', cancelled: 'info', queued: 'warning' } as Record<string, string>)[v] || 'primary') as 'success' | 'danger' | 'info' | 'warning' | 'primary'
const typeLabel = (v: string) => ({ prompt: 'Prompt', http: 'HTTP', mcp_invoke: 'MCP', skill_run: 'Skill', digital_employee: '数字员工', custom: '自定义' }[v] || v)
async function load() {
loading.value = true
try {
items.value = await fetchAgentTasks(statusFilter.value)
} finally {
loading.value = false
}
}
async function openDispatch() {
nodes.value = await fetchAgentNodes()
payloadJSON.value = '{}'
Object.assign(form, { task_type: 'prompt', pool_type: 'private', pool_code: 'default', node_id: undefined, max_attempts: 3 })
visible.value = true
}
async function submit() {
let payload: Record<string, unknown>
try {
payload = JSON.parse(payloadJSON.value)
} catch {
ElMessage.error('Payload 不是合法 JSON')
return
}
saving.value = true
try {
await createAgentTask({ task_type: form.task_type, payload, pool_type: form.pool_type, pool_code: form.pool_code, node_id: form.node_id, max_attempts: form.max_attempts })
ElMessage.success('任务已下发')
visible.value = false
await load()
} catch (error) {
ElMessage.error((error as Error)?.message || '下发失败')
} finally {
saving.value = false
}
}
async function cancel(row: AgentTask) {
await ElMessageBox.confirm('取消后节点将无法认领该任务,确定取消?', '取消任务', { type: 'warning' })
await cancelAgentTask(row.id)
row.status = 'cancelled'
ElMessage.success('已取消')
}
async function retry(row: AgentTask) {
await retryAgentTask(row.id)
ElMessage.success('已重新入队')
await load()
}
onMounted(load)
</script>
+5
View File
@@ -109,3 +109,8 @@ export interface EmployeeRun { employee_code:string;employee_name:string;status:
export const fetchDigitalEmployees=()=>request.get<DigitalEmployee[]>({url:'/api/v1/portal/digital-employees'})
export const runDigitalEmployee=(code:string,message:string)=>request.post<Record<string,unknown>>({url:`/api/v1/portal/digital-employees/${code}/chat`,params:{message}})
export const fetchMyEmployeeRuns=(limit=20)=>request.get<EmployeeRun[]>({url:'/api/v1/portal/digital-employees/runs',params:{limit}})
// --- 个人智能体安全策略 ---
export interface AgentPolicy { auto_approve_tools:boolean;rate_limit_multiplier:number }
export const fetchAgentPolicy=()=>request.get<AgentPolicy>({url:'/api/v1/portal/agent-policy'})
export const setAgentPolicy=(params:AgentPolicy)=>request.put<{saved:boolean}>({url:'/api/v1/portal/agent-policy',params})
@@ -38,6 +38,26 @@
</div>
</ElCard>
<ElCard shadow="never" class="mb-5">
<template #header><div class="font-medium">智能体安全策略</div></template>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<div class="text-sm">个人工具自动审批</div>
<p class="text-g-500 mt-1 text-sm">开启后你通过个人凭据调用需审批的工具时不再要求管理员审批平台级审批标记仍适用于其他调用</p>
</div>
<ElSwitch v-model="policy.auto_approve_tools" :loading="savingPolicy" @change="savePolicy" />
</div>
<div class="flex items-center justify-between">
<div>
<div class="text-sm">个人限流倍数</div>
<p class="text-g-500 mt-1 text-sm">你的个人调用按此倍数放宽工具限流例如工具限流 10 rpm倍数 2 时个人可调用 20 rpm</p>
</div>
<ElInputNumber v-model="policy.rate_limit_multiplier" :min="1" :max="100" class="w-32" :disabled="savingPolicy" @change="savePolicy" />
</div>
</div>
</ElCard>
<ElCard shadow="never">
<template #header>
<div class="flex items-center justify-between">
@@ -70,12 +90,14 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ProviderBinding, SessionView, fetchMySessions, fetchProviderBindings, fetchSecurityPrefs,
revokeSession, setSecurityPrefs, startSocialBind, unbindSocial
AgentPolicy, ProviderBinding, SessionView, fetchAgentPolicy, fetchMySessions, fetchProviderBindings,
fetchSecurityPrefs, revokeSession, setAgentPolicy, setSecurityPrefs, startSocialBind, unbindSocial
} from '@/api/portal'
const loading = ref(false)
const savingPrefs = ref(false)
const savingPolicy = ref(false)
const policy = reactive<AgentPolicy>({ auto_approve_tools: false, rate_limit_multiplier: 1 })
const sessions = ref<SessionView[]>([])
const loginNotify = ref(true)
const bindings = ref<ProviderBinding[]>([])
@@ -102,10 +124,12 @@ function formatTime(epochSeconds: number) {
async function loadAll() {
loading.value = true
try {
const [sessionList, pref, bindingList] = await Promise.all([fetchMySessions(), fetchSecurityPrefs(), fetchProviderBindings()])
const [sessionList, pref, bindingList, agentPolicy] = await Promise.all([fetchMySessions(), fetchSecurityPrefs(), fetchProviderBindings(), fetchAgentPolicy()])
sessions.value = sessionList
loginNotify.value = pref.login_notify
bindings.value = bindingList
policy.auto_approve_tools = agentPolicy.auto_approve_tools
policy.rate_limit_multiplier = agentPolicy.rate_limit_multiplier || 1
} finally {
loading.value = false
}
@@ -132,6 +156,19 @@ async function savePrefs(value: string | number | boolean) {
}
}
async function savePolicy() {
savingPolicy.value = true
try {
await setAgentPolicy({ auto_approve_tools: policy.auto_approve_tools, rate_limit_multiplier: policy.rate_limit_multiplier })
ElMessage.success('安全策略已保存')
} catch {
ElMessage.error('保存失败')
await loadAll()
} finally {
savingPolicy.value = false
}
}
async function bind(kind: string) {
const { redirect_url } = await startSocialBind(kind)
const win = window.open(redirect_url, '_blank', 'width=720,height=600')