feat: 添加工具市场前端页面

- 新增 Tools.vue 页面:工具列表、分类筛选、搜索、新建/编辑/删除/测试
- 新增路由 /tools
- 导航菜单添加"工具市场"入口

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-01 22:43:51 +08:00
parent 7b9e0826de
commit cd83090c61
3 changed files with 618 additions and 1 deletions

View File

@@ -43,6 +43,10 @@
<el-icon><Connection /></el-icon>
<span>数据源管理</span>
</el-menu-item>
<el-menu-item index="tools" @click="router.push('/tools')">
<el-icon><Tools /></el-icon>
<span>工具市场</span>
</el-menu-item>
<el-menu-item index="model-configs" @click="router.push('/model-configs')">
<el-icon><Setting /></el-icon>
<span>模型配置管理</span>
@@ -90,7 +94,7 @@
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis } from '@element-plus/icons-vue'
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -104,6 +108,7 @@ const activeMenu = computed(() => {
if (route.path === '/agents' || route.path.startsWith('/agents/')) return 'agents'
if (route.path === '/executions' || route.path.startsWith('/executions/')) return 'executions'
if (route.path === '/data-sources') return 'data-sources'
if (route.path === '/tools') return 'tools'
if (route.path === '/model-configs') return 'model-configs'
if (route.path === '/node-templates') return 'node-templates'
if (route.path === '/permissions') return 'permissions'
@@ -127,6 +132,8 @@ const handleMenuSelect = (key: string) => {
router.push('/executions')
} else if (key === 'data-sources') {
router.push('/data-sources')
} else if (key === 'tools') {
router.push('/tools')
} else if (key === 'model-configs') {
router.push('/model-configs')
} else if (key === 'node-templates') {

View File

@@ -112,6 +112,12 @@ const router = createRouter({
component: () => import('@/views/NodeTemplates.vue'),
meta: { requiresAuth: true }
},
{
path: '/tools',
name: 'tools',
component: () => import('@/views/Tools.vue'),
meta: { requiresAuth: true }
},
{
path: '/agent-chat',
name: 'agent-chat',

View File

@@ -0,0 +1,604 @@
<template>
<MainLayout>
<div class="tools-page">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="searchQuery"
placeholder="搜索工具名称或描述..."
clearable
style="width: 280px"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="categoryFilter" placeholder="全部分类" clearable style="width: 160px" @change="fetchTools">
<el-option
v-for="cat in categories"
:key="cat"
:label="cat"
:value="cat"
/>
</el-select>
<el-radio-group v-model="scopeFilter" @change="fetchTools">
<el-radio-button value="public">公开工具</el-radio-button>
<el-radio-button value="mine">我的工具</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="showCreateDialog">
<el-icon><Plus /></el-icon>
新建工具
</el-button>
</div>
</div>
<!-- 工具列表 -->
<el-table :data="tools" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="name" label="名称" min-width="140">
<template #default="{ row }">
<div class="tool-name">
<span class="name-text">{{ row.name }}</span>
<el-tag v-if="row.implementation_type === 'builtin'" size="small" type="info">内置</el-tag>
<el-tag v-else-if="row.implementation_type === 'code'" size="small" type="warning">代码</el-tag>
<el-tag v-else-if="row.implementation_type === 'http'" size="small" type="success">HTTP</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="category" label="分类" width="120">
<template #default="{ row }">
<el-tag v-if="row.category" size="small" effect="plain">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="use_count" label="使用次数" width="100" align="center" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="showTestDialog(row)">测试</el-button>
<el-button
v-if="row.implementation_type !== 'builtin'"
size="small"
@click="showEditDialog(row)"
>编辑</el-button>
<el-popconfirm
v-if="row.implementation_type !== 'builtin'"
title="确定删除此工具?"
@confirm="handleDelete(row)"
>
<template #reference>
<el-button size="small" type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty v-if="!loading && tools.length === 0" description="暂无工具">
<el-button type="primary" @click="showCreateDialog">创建第一个工具</el-button>
</el-empty>
<!-- 创建/编辑对话框 -->
<el-dialog
v-model="formDialogVisible"
:title="isEditing ? '编辑工具' : '新建工具'"
width="700px"
:close-on-click-modal="false"
>
<el-form :model="form" label-position="top" :rules="formRules" ref="formRef">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="工具名称" prop="name">
<el-input v-model="form.name" placeholder="唯一名称,如 translate_text" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分类" prop="category">
<el-select v-model="form.category" placeholder="选择分类" clearable filterable allow-create style="width:100%">
<el-option v-for="cat in categories" :key="cat" :label="cat" :value="cat" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="工具功能描述" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="实现类型" prop="implementation_type">
<el-select v-model="form.implementation_type" placeholder="选择类型" @change="onTypeChange">
<el-option label="代码 (Python)" value="code" />
<el-option label="HTTP 请求" value="http" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否公开">
<el-switch v-model="form.is_public" />
</el-form-item>
</el-col>
</el-row>
<!-- 代码类型配置 -->
<template v-if="form.implementation_type === 'code'">
<el-form-item label="Python 代码" prop="implementation_config.source">
<el-input
v-model="form.implementation_config.source"
type="textarea"
:rows="10"
placeholder="def run(args):&#10; '''&#10; args: dict, 包含 function_schema.parameters 中定义的参数&#10; returns: Any (将被 JSON 序列化)&#10; '''&#10; x = args.get('x', 0)&#10; y = args.get('y', 0)&#10; return x + y"
font-family="monospace"
/>
</el-form-item>
</template>
<!-- HTTP 类型配置 -->
<template v-if="form.implementation_type === 'http'">
<el-form-item label="请求 URL" prop="implementation_config.url">
<el-input v-model="form.implementation_config.url" placeholder="https://api.example.com/endpoint" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="请求方法" prop="implementation_config.method">
<el-select v-model="form.implementation_config.method" style="width:100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="超时(秒)">
<el-input-number v-model="form.implementation_config.timeout" :min="1" :max="120" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="请求头 (JSON)">
<el-input
v-model="formHeadersStr"
type="textarea"
:rows="3"
placeholder='{"Authorization": "Bearer xxx", "Content-Type": "application/json"}'
/>
</el-form-item>
</template>
<!-- Parameters Schema -->
<el-divider>参数定义 (function_schema.parameters)</el-divider>
<el-form-item label="参数 JSON Schema">
<el-input
v-model="formSchemaStr"
type="textarea"
:rows="6"
placeholder='{"type": "object", "properties": {"x": {"type": "integer", "description": "第一个数"}, "y": {"type": "integer", "description": "第二个数"}}, "required": ["x", "y"]}'
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveForm" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 测试对话框 -->
<el-dialog
v-model="testDialogVisible"
:title="'测试工具: ' + (testingTool?.name || '')"
width="650px"
>
<template v-if="testingTool">
<!-- 代码工具测试 -->
<template v-if="testingTool.implementation_type === 'code'">
<el-form label-position="top">
<el-form-item label="测试参数 (JSON)">
<el-input
v-model="testArgsStr"
type="textarea"
:rows="6"
placeholder='{"x": 1, "y": 2}'
/>
</el-form-item>
</el-form>
</template>
<!-- HTTP 工具测试 -->
<template v-else-if="testingTool.implementation_type === 'http'">
<el-form label-position="top">
<el-row :gutter="20">
<el-col :span="16">
<el-form-item label="URL">
<el-input v-model="testHttpUrl" :placeholder="testingTool.implementation_config?.url || ''" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="方法">
<el-select v-model="testHttpMethod" style="width:100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="请求头 (JSON)">
<el-input v-model="testHttpHeadersStr" type="textarea" :rows="3" placeholder='{"Content-Type": "application/json"}' />
</el-form-item>
<el-form-item label="请求体 (JSON)">
<el-input v-model="testHttpBodyStr" type="textarea" :rows="4" placeholder='{"key": "value"}' />
</el-form-item>
<el-form-item label="参数 (将被替换到 URL) (JSON)">
<el-input v-model="testArgsStr" type="textarea" :rows="3" placeholder='{"query": "hello"}' />
</el-form-item>
</el-form>
</template>
<!-- 通用参数 -->
<el-form label-position="top">
<el-form-item label="参数 (function_schema args)">
<el-input
v-model="testArgsStr"
type="textarea"
:rows="4"
placeholder='{"arg1": "value1"}'
/>
</el-form-item>
</el-form>
<!-- 测试结果 -->
<div v-if="testResult" class="test-result">
<el-divider>测试结果</el-divider>
<el-tag :type="testResult.success ? 'success' : 'danger'" effect="dark" style="margin-bottom: 10px">
{{ testResult.success ? '成功' : '失败' }}
<span v-if="testResult.elapsed_ms !== undefined"> ({{ testResult.elapsed_ms }}ms)</span>
</el-tag>
<el-input
v-model="testResultOutput"
type="textarea"
:rows="6"
readonly
/>
</div>
</template>
<template #footer>
<el-button @click="testDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="handleTest" :loading="testing">测试运行</el-button>
</template>
</el-dialog>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import api from '@/api'
interface Tool {
id: string
name: string
description: string
category?: string
function_schema: Record<string, any>
implementation_type: string
implementation_config?: Record<string, any>
is_public: boolean
use_count: number
user_id?: string
created_at: string
updated_at: string
}
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const tools = ref<Tool[]>([])
const categories = ref<string[]>([])
const searchQuery = ref('')
const categoryFilter = ref('')
const scopeFilter = ref('public')
// 表单
const formDialogVisible = ref(false)
const isEditing = ref(false)
const editingId = ref('')
const formRef = ref<any>(null)
const form = ref({
name: '',
description: '',
category: '',
implementation_type: 'code',
implementation_config: { source: '', url: '', method: 'GET', timeout: 30, headers: {} },
is_public: false,
})
const formHeadersStr = ref('{}')
const formSchemaStr = ref('{"type": "object", "properties": {}, "required": []}')
// 测试
const testDialogVisible = ref(false)
const testingTool = ref<Tool | null>(null)
const testArgsStr = ref('{}')
const testHttpUrl = ref('')
const testHttpMethod = ref('GET')
const testHttpHeadersStr = ref('{}')
const testHttpBodyStr = ref('')
const testResult = ref<any>(null)
const formRules = {
name: [{ required: true, message: '请输入工具名称', trigger: 'blur' }],
description: [{ required: true, message: '请输入工具描述', trigger: 'blur' }],
implementation_type: [{ required: true, message: '请选择实现类型', trigger: 'change' }],
}
const testResultOutput = computed(() => {
if (!testResult.value) return ''
const parts: string[] = []
if (testResult.value.error) parts.push(`错误: ${testResult.value.error}`)
if (testResult.value.status_code) parts.push(`状态码: ${testResult.value.status_code}`)
if (testResult.value.result !== undefined && testResult.value.result !== null) {
parts.push(`结果: ${JSON.stringify(testResult.value.result, null, 2)}`)
}
if (testResult.value.body) parts.push(`响应体: ${testResult.value.body}`)
return parts.join('\n')
})
// 切换类型时重置配置
function onTypeChange() {
if (form.value.implementation_type === 'code') {
form.value.implementation_config = { source: '' }
} else if (form.value.implementation_type === 'http') {
form.value.implementation_config = { url: '', method: 'GET', timeout: 30 }
formHeadersStr.value = '{}'
}
}
// 获取工具列表
async function fetchTools() {
loading.value = true
try {
const params: Record<string, any> = { scope: scopeFilter.value }
if (categoryFilter.value) params.category = categoryFilter.value
if (searchQuery.value) params.search = searchQuery.value
const res = await api.get('/api/v1/tools', { params })
tools.value = res.data || []
} catch (e: any) {
console.error('获取工具列表失败', e)
} finally {
loading.value = false
}
}
// 获取分类列表
async function fetchCategories() {
try {
const res = await api.get('/api/v1/tools/categories')
categories.value = res.data || []
} catch (e) {
console.error('获取分类列表失败', e)
}
}
// 搜索防抖
let searchTimer: ReturnType<typeof setTimeout> | null = null
function handleSearch() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(fetchTools, 300)
}
// 显示创建对话框
function showCreateDialog() {
isEditing.value = false
editingId.value = ''
form.value = {
name: '',
description: '',
category: '',
implementation_type: 'code',
implementation_config: { source: '' },
is_public: false,
}
formHeadersStr.value = '{}'
formSchemaStr.value = '{"type": "object", "properties": {}, "required": []}'
formDialogVisible.value = true
}
// 显示编辑对话框
function showEditDialog(row: Tool) {
isEditing.value = true
editingId.value = row.id
form.value = {
name: row.name,
description: row.description,
category: row.category || '',
implementation_type: row.implementation_type,
implementation_config: row.implementation_config ? { ...row.implementation_config } : {},
is_public: row.is_public,
}
formSchemaStr.value = JSON.stringify(row.function_schema?.parameters || { type: 'object', properties: {} }, null, 2)
formHeadersStr.value = JSON.stringify(row.implementation_config?.headers || {}, null, 2)
formDialogVisible.value = true
}
// 保存表单
async function handleSaveForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
let headers: Record<string, any> = {}
try {
headers = JSON.parse(formHeadersStr.value || '{}')
} catch {
ElMessage.warning('请求头 JSON 格式不正确')
return
}
let params: Record<string, any> = { type: 'object', properties: {} }
try {
params = JSON.parse(formSchemaStr.value || '{}')
} catch {
ElMessage.warning('参数 Schema JSON 格式不正确')
return
}
const config = { ...form.value.implementation_config }
if (form.value.implementation_type === 'http') {
config.headers = headers
}
const payload = {
name: form.value.name,
description: form.value.description,
category: form.value.category || null,
implementation_type: form.value.implementation_type,
implementation_config: config,
is_public: form.value.is_public,
function_schema: {
name: form.value.name,
description: form.value.description,
parameters: params,
},
}
saving.value = true
try {
if (isEditing.value) {
await api.put(`/api/v1/tools/${editingId.value}`, payload)
ElMessage.success('工具已更新')
} else {
await api.post('/api/v1/tools', payload)
ElMessage.success('工具已创建')
}
formDialogVisible.value = false
fetchTools()
fetchCategories()
} catch (e: any) {
console.error('保存工具失败', e)
} finally {
saving.value = false
}
}
// 删除工具
async function handleDelete(row: Tool) {
try {
await api.delete(`/api/v1/tools/${row.id}`)
ElMessage.success('工具已删除')
fetchTools()
} catch (e) {
console.error('删除工具失败', e)
}
}
// 显示测试对话框
function showTestDialog(row: Tool) {
testingTool.value = row
testArgsStr.value = '{}'
testResult.value = null
testHttpUrl.value = row.implementation_config?.url || ''
testHttpMethod.value = row.implementation_config?.method || 'GET'
testHttpHeadersStr.value = JSON.stringify(row.implementation_config?.headers || {}, null, 2)
testHttpBodyStr.value = ''
testDialogVisible.value = true
}
// 执行测试
async function handleTest() {
if (!testingTool.value) return
let args: Record<string, any> = {}
try {
args = JSON.parse(testArgsStr.value || '{}')
} catch {
ElMessage.warning('参数 JSON 格式不正确')
return
}
testing.value = true
testResult.value = null
try {
if (testingTool.value.implementation_type === 'code') {
const config = testingTool.value.implementation_config
const source = config?.source || ''
const res = await api.post('/api/v1/tools/test/code', { source, args })
testResult.value = res.data
} else if (testingTool.value.implementation_type === 'http') {
let headers: Record<string, any> = {}
try {
headers = JSON.parse(testHttpHeadersStr.value || '{}')
} catch { /* ignore */ }
let body: Record<string, any> | undefined
try {
body = testHttpBodyStr.value ? JSON.parse(testHttpBodyStr.value) : undefined
} catch { /* ignore */ }
const res = await api.post('/api/v1/tools/test/http', {
url: testHttpUrl.value,
method: testHttpMethod.value,
headers,
body,
args,
timeout: 30,
})
testResult.value = res.data
}
} catch (e: any) {
testResult.value = { success: false, error: e.message || '测试执行失败' }
} finally {
testing.value = false
}
}
watch(scopeFilter, fetchTools)
watch(categoryFilter, fetchTools)
onMounted(() => {
fetchTools()
fetchCategories()
})
</script>
<style scoped>
.tools-page {
height: 100%;
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
gap: 8px;
}
.tool-name {
display: flex;
align-items: center;
gap: 8px;
}
.name-text {
font-weight: 500;
}
.test-result {
margin-top: 10px;
}
</style>