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:
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user