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:
@@ -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 }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
402
frontend/src/views/PluginMarket.vue
Normal file
402
frontend/src/views/PluginMarket.vue
Normal 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): # 你的插件逻辑 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>
|
||||
Reference in New Issue
Block a user