feat: #27 插件系统 — 第三方节点扩展

- NodePlugin 模型: manifest规范(name/version/node_type/inputs_schema/outputs_schema)
- plugin_loader 服务: manifest校验、代码加载/卸载、沙箱执行(subprocess隔离+超时30s)
- plugins API: CRUD、启用/禁用、市场浏览、安装计数、沙箱测试执行
- PluginMarket.vue: 插件市场上传/浏览/安装/启用禁用/删除/测试
- 注册 register_external_tool 到 tool_registry,供工作流编辑器使用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-06 21:44:45 +08:00
parent 1b5f9deb44
commit 3c102ed5f9
9 changed files with 1029 additions and 3 deletions

View File

@@ -135,6 +135,12 @@ const router = createRouter({
name: 'agent-schedules',
component: () => import('@/views/AgentSchedules.vue'),
meta: { requiresAuth: true }
},
{
path: '/plugins',
name: 'plugin-market',
component: () => import('@/views/PluginMarket.vue'),
meta: { requiresAuth: true }
}
]
})

View File

@@ -0,0 +1,402 @@
<template>
<MainLayout>
<div class="plugin-market">
<!-- 顶部操作栏 -->
<div class="page-header">
<div class="header-left">
<h1>插件市场</h1>
<p class="subtitle">扩展平台节点类型上传或安装第三方插件</p>
</div>
<div class="header-right">
<el-button type="primary" @click="showUpload = true">
<el-icon><Upload /></el-icon>
上传插件
</el-button>
<el-button @click="activeTab = 'my'">
<el-icon><User /></el-icon>
我的插件
</el-button>
</div>
</div>
<!-- Tab 切换 -->
<el-tabs v-model="activeTab" @tab-change="loadData">
<el-tab-pane label="插件市场" name="market" />
<el-tab-pane label="我的插件" name="my" />
</el-tabs>
<!-- 搜索筛选 -->
<el-card class="search-bar" v-if="activeTab === 'market'">
<el-row :gutter="12">
<el-col :span="8">
<el-input v-model="searchQuery" placeholder="搜索插件..." clearable @clear="loadData" @keyup.enter="loadData">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</el-col>
<el-col :span="4">
<el-select v-model="categoryFilter" placeholder="分类" clearable @change="loadData">
<el-option label="自定义" value="custom" />
<el-option label="HTTP请求" value="http" />
<el-option label="数据处理" value="data" />
<el-option label="AI工具" value="ai" />
<el-option label="工具" value="tool" />
</el-select>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="loadData">搜索</el-button>
</el-col>
</el-row>
</el-card>
<!-- 插件列表 -->
<div v-loading="loading" class="plugin-grid" style="margin-top: 16px">
<el-empty v-if="!loading && plugins.length === 0" description="暂无插件" />
<el-row :gutter="16" v-else>
<el-col v-for="p in plugins" :key="p.id" :span="6" style="margin-bottom: 16px">
<el-card class="plugin-card" shadow="hover">
<div class="plugin-header">
<div class="plugin-icon">
<el-icon :size="28"><Box /></el-icon>
</div>
<div class="plugin-info">
<h4 class="plugin-name">{{ p.node_label || p.name }}</h4>
<el-tag size="small" type="info">{{ p.category }}</el-tag>
</div>
</div>
<p class="plugin-desc">{{ p.description || '无描述' }}</p>
<div class="plugin-meta">
<span class="meta-item">
<el-icon><Download /></el-icon> {{ p.install_count }}
</span>
<span class="meta-item">v{{ p.version }}</span>
<el-tag :type="p.enabled ? 'success' : 'danger'" size="small">
{{ p.enabled ? '已启用' : '已禁用' }}
</el-tag>
</div>
<div class="plugin-actions">
<el-button
v-if="activeTab === 'market'"
type="primary" size="small"
@click="installPlugin(p)"
>
<el-icon><Download /></el-icon>
安装
</el-button>
<el-button size="small" @click="viewDetail(p)">详情</el-button>
<el-button
v-if="activeTab === 'my'"
size="small"
@click="testPlugin(p)"
>
<el-icon><VideoPlay /></el-icon>
测试
</el-button>
<el-button
v-if="activeTab === 'my'"
:type="p.enabled ? 'warning' : 'success'"
size="small"
@click="togglePlugin(p)"
>
{{ p.enabled ? '禁用' : '启用' }}
</el-button>
<el-button
v-if="activeTab === 'my'"
type="danger"
size="small"
@click="deletePlugin(p)"
>
删除
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 上传对话框 -->
<el-dialog v-model="showUpload" title="上传插件" width="700px" :close-on-click-modal="false" destroy-on-close>
<el-form :model="uploadForm" label-width="120px">
<el-form-item label="插件名称" required>
<el-input v-model="uploadForm.manifest.name" placeholder="如: my-http-plugin" />
</el-form-item>
<el-form-item label="版本号">
<el-input v-model="uploadForm.manifest.version" placeholder="1.0.0" />
</el-form-item>
<el-form-item label="节点类型" required>
<el-input v-model="uploadForm.manifest.node_type" placeholder="如: custom_http_action英文字母+下划线)" />
<div class="form-hint">节点类型标识用于工作流编辑器中识别此节点</div>
</el-form-item>
<el-form-item label="节点显示名">
<el-input v-model="uploadForm.manifest.node_label" placeholder="如: HTTP请求" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="uploadForm.manifest.category" placeholder="选择分类">
<el-option label="自定义" value="custom" />
<el-option label="HTTP请求" value="http" />
<el-option label="数据处理" value="data" />
<el-option label="AI工具" value="ai" />
<el-option label="工具" value="tool" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="uploadForm.manifest.description" type="textarea" :rows="2" placeholder="插件功能描述" />
</el-form-item>
<el-form-item label="输入Schema (JSON)">
<el-input v-model="schemaInputs" type="textarea" :rows="4" placeholder='{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}' />
</el-form-item>
<el-form-item label="插件代码" required>
<el-input v-model="uploadForm.code" type="textarea" :rows="10" placeholder="async def execute(inputs, context):&#10; # 你的插件逻辑&#10; return {'result': 'hello'}" />
<div class="form-hint">必须包含 async def execute(inputs, context) 函数返回 dict</div>
</el-form-item>
<el-form-item label="公开到市场">
<el-switch v-model="uploadForm.is_public" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUpload = false">取消</el-button>
<el-button type="primary" @click="doUpload" :loading="uploading">上传</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="showDetail" :title="detailPlugin?.name" width="700px" destroy-on-close>
<el-descriptions v-if="detailPlugin" :column="2" border>
<el-descriptions-item label="名称">{{ detailPlugin.name }}</el-descriptions-item>
<el-descriptions-item label="版本">v{{ detailPlugin.version }}</el-descriptions-item>
<el-descriptions-item label="节点类型">{{ detailPlugin.node_type }}</el-descriptions-item>
<el-descriptions-item label="节点标签">{{ detailPlugin.node_label }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ detailPlugin.category }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailPlugin.enabled ? 'success' : 'danger'">{{ detailPlugin.enabled ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="安装次数">{{ detailPlugin.install_count }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detailPlugin.created_at }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ detailPlugin.description || '无' }}</el-descriptions-item>
</el-descriptions>
<div v-if="detailPlugin?.code" style="margin-top: 12px">
<h4>插件代码</h4>
<el-input :model-value="detailPlugin.code" type="textarea" :rows="10" readonly />
</div>
</el-dialog>
<!-- 测试对话框 -->
<el-dialog v-model="showTest" title="测试插件" width="700px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="输入参数 (JSON)">
<el-input v-model="testInputs" type="textarea" :rows="6" placeholder='{"key": "value"}' />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTest = false">关闭</el-button>
<el-button type="primary" @click="doTest" :loading="testing">执行测试</el-button>
</template>
<div v-if="testResult" style="margin-top: 16px">
<el-divider />
<h4>测试结果</h4>
<el-alert
:title="testResult.success ? '执行成功' : '执行失败'"
:type="testResult.success ? 'success' : 'error'"
:closable="false"
show-icon
/>
<el-input
:model-value="JSON.stringify(testResult, null, 2)"
type="textarea"
:rows="10"
readonly
style="margin-top: 8px"
/>
</div>
</el-dialog>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, User, Search, Download, VideoPlay, Box } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import api from '@/api'
// 状态
const activeTab = ref('market')
const plugins = ref<any[]>([])
const loading = ref(false)
const searchQuery = ref('')
const categoryFilter = ref('')
// 上传
const showUpload = ref(false)
const uploading = ref(false)
const uploadForm = ref({
manifest: { name: '', version: '1.0.0', node_type: '', node_label: '', category: 'custom', description: '', icon: '', tags: [] as string[] },
code: '',
is_public: false,
})
const schemaInputs = ref('')
// 详情
const showDetail = ref(false)
const detailPlugin = ref<any>(null)
// 测试
const showTest = ref(false)
const testing = ref(false)
const testInputs = ref('{}')
const testResult = ref<any>(null)
const testTarget = ref<any>(null)
// 加载
const loadData = async () => {
loading.value = true
try {
const endpoint = activeTab.value === 'market' ? '/api/v1/plugins/market' : '/api/v1/plugins/my'
const params: any = { skip: 0, limit: 50 }
if (activeTab.value === 'market') {
if (searchQuery.value) params.search = searchQuery.value
if (categoryFilter.value) params.category = categoryFilter.value
}
const resp = await api.get(endpoint, { params })
plugins.value = Array.isArray(resp.data) ? resp.data : []
} catch {
ElMessage.error('加载插件列表失败')
} finally {
loading.value = false
}
}
// 上传
const doUpload = async () => {
if (!uploadForm.value.manifest.name || !uploadForm.value.manifest.node_type || !uploadForm.value.code) {
ElMessage.warning('请填写必填项')
return
}
if (!uploadForm.value.code.includes('def execute')) {
ElMessage.warning('代码中必须包含 execute 函数')
return
}
uploading.value = true
try {
// 解析 schema JSON
const manifest: any = { ...uploadForm.value.manifest }
if (schemaInputs.value.trim()) {
try {
manifest.inputs_schema = JSON.parse(schemaInputs.value)
} catch {
ElMessage.warning('输入Schema 不是有效的 JSON')
uploading.value = false
return
}
}
await api.post('/api/v1/plugins', { manifest, code: uploadForm.value.code, is_public: uploadForm.value.is_public })
ElMessage.success('插件上传成功')
showUpload.value = false
uploadForm.value = {
manifest: { name: '', version: '1.0.0', node_type: '', node_label: '', category: 'custom', description: '', icon: '', tags: [] },
code: '',
is_public: false,
}
schemaInputs.value = ''
loadData()
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '上传失败')
} finally {
uploading.value = false
}
}
// 安装
const installPlugin = async (p: any) => {
try {
await api.post(`/api/v1/plugins/${p.id}/install`)
p.install_count += 1
ElMessage.success(`已安装插件「${p.name}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '安装失败')
}
}
// 切换启用
const togglePlugin = async (p: any) => {
try {
const resp = await api.post(`/api/v1/plugins/${p.id}/toggle`)
p.enabled = resp.data.enabled
ElMessage.success(resp.data.message)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '操作失败')
}
}
// 删除
const deletePlugin = async (p: any) => {
try {
await ElMessageBox.confirm(`确定删除插件「${p.name}」?此操作不可恢复。`, '确认删除', { type: 'warning' })
await api.delete(`/api/v1/plugins/${p.id}`)
ElMessage.success('已删除')
loadData()
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.response?.data?.detail || '删除失败')
}
}
// 详情
const viewDetail = async (p: any) => {
try {
const resp = await api.get(`/api/v1/plugins/${p.id}`)
detailPlugin.value = resp.data
showDetail.value = true
} catch {
ElMessage.error('加载详情失败')
}
}
// 测试
const testPlugin = (p: any) => {
testTarget.value = p
testInputs.value = '{}'
testResult.value = null
showTest.value = true
}
const doTest = async () => {
if (!testTarget.value) return
let inputs = {}
try {
inputs = JSON.parse(testInputs.value)
} catch {
ElMessage.warning('输入参数不是有效的 JSON')
return
}
testing.value = true
try {
const resp = await api.post(`/api/v1/plugins/${testTarget.value.id}/test`, { inputs })
testResult.value = resp.data
} catch (err: any) {
testResult.value = { success: false, error: err.response?.data?.detail || '测试失败' }
} finally {
testing.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.plugin-market { max-width: 1200px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
.header-left h1 { margin: 0; font-size: 24px; }
.header-right { display: flex; gap: 8px; }
.subtitle { color: var(--el-text-color-secondary); margin: 4px 0 0; font-size: 14px; }
.search-bar { margin-bottom: 8px; }
.plugin-grid { min-height: 200px; }
.plugin-card { height: 100%; }
.plugin-header { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
.plugin-icon { width: 44px; height: 44px; background: var(--el-color-primary-light-9); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: var(--el-color-primary); }
.plugin-name { margin: 0; font-size: 15px; }
.plugin-desc { color: var(--el-text-color-secondary); font-size: 13px; margin: 8px 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.plugin-meta { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; font-size: 12px; color: var(--el-text-color-secondary); }
.meta-item { display: flex; align-items: center; gap: 4px; }
.plugin-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.form-hint { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; }
</style>