704 lines
16 KiB
Vue
704 lines
16 KiB
Vue
<template>
|
||
<div class="tools-manager-page">
|
||
<!-- 页面头部 -->
|
||
<div class="page-header">
|
||
<div class="header-info">
|
||
<h1>工具管理</h1>
|
||
<p>管理和配置 MCP 工具,扩展 AI 模型的能力</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<n-button type="primary" @click="showAddModal = true">
|
||
<template #icon>
|
||
<n-icon :component="PlusIcon" />
|
||
</template>
|
||
添加工具
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 工具列表 -->
|
||
<div class="tools-content">
|
||
<div v-if="tools.length === 0" class="empty-state">
|
||
<div class="empty-icon">
|
||
<n-icon :component="ToolIcon" size="48" />
|
||
</div>
|
||
<h3>暂无工具</h3>
|
||
<p>添加您的第一个 MCP 工具,扩展 AI 模型的能力</p>
|
||
<n-button type="primary" @click="showAddModal = true">添加工具</n-button>
|
||
</div>
|
||
|
||
<div v-else class="tools-grid">
|
||
<n-card
|
||
v-for="tool in tools"
|
||
:key="tool.id"
|
||
class="tool-card"
|
||
hoverable
|
||
>
|
||
<template #header>
|
||
<div class="card-header">
|
||
<div class="tool-info">
|
||
<n-icon :component="ToolIcon" size="20" class="tool-icon" />
|
||
<span class="tool-name">{{ tool.name }}</span>
|
||
</div>
|
||
<div class="card-actions">
|
||
<n-switch
|
||
v-model:value="tool.enabled"
|
||
@update:value="toggleTool(tool)"
|
||
size="small"
|
||
/>
|
||
<n-dropdown :options="getDropdownOptions()" @select="handleDropdownSelect($event, tool)">
|
||
<n-button text>
|
||
<n-icon :component="DotsVerticalIcon" size="18" />
|
||
</n-button>
|
||
</n-dropdown>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="tool-content">
|
||
<p class="tool-description">{{ tool.description || '暂无描述' }}</p>
|
||
|
||
<div class="tool-meta">
|
||
<div class="meta-item">
|
||
<span class="meta-label">端点:</span>
|
||
<span class="meta-value">{{ tool.endpoint || '未配置' }}</span>
|
||
</div>
|
||
<div class="meta-item">
|
||
<span class="meta-label">参数:</span>
|
||
<span class="meta-value">{{ getParameterCount(tool) }} 个</span>
|
||
</div>
|
||
<div class="meta-item">
|
||
<span class="meta-label">状态:</span>
|
||
<n-tag :type="tool.enabled ? 'success' : 'default'" size="small">
|
||
{{ tool.enabled ? '已启用' : '已禁用' }}
|
||
</n-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-actions">
|
||
<n-button size="small" @click="testTool(tool)">
|
||
<template #icon>
|
||
<n-icon :component="TestIcon" />
|
||
</template>
|
||
测试工具
|
||
</n-button>
|
||
<n-button size="small" @click="editTool(tool)">
|
||
<template #icon>
|
||
<n-icon :component="EditIcon" />
|
||
</template>
|
||
编辑
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
</n-card>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 添加/编辑工具弹窗 -->
|
||
<n-modal
|
||
v-model:show="showAddModal"
|
||
preset="card"
|
||
:title="editingTool ? '编辑工具' : '添加工具'"
|
||
class="tool-modal"
|
||
style="width: 600px"
|
||
:mask-closable="false"
|
||
>
|
||
<n-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="formRules"
|
||
label-placement="left"
|
||
label-width="100"
|
||
>
|
||
<n-form-item label="工具名称" path="name">
|
||
<n-input
|
||
v-model:value="formData.name"
|
||
placeholder="例如: web_search"
|
||
/>
|
||
</n-form-item>
|
||
|
||
<n-form-item label="显示名称" path="displayName">
|
||
<n-input
|
||
v-model:value="formData.displayName"
|
||
placeholder="例如: 网页搜索"
|
||
/>
|
||
</n-form-item>
|
||
|
||
<n-form-item label="描述" path="description">
|
||
<n-input
|
||
v-model:value="formData.description"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="描述工具的功能和用途"
|
||
/>
|
||
</n-form-item>
|
||
|
||
<n-form-item label="端点地址" path="endpoint">
|
||
<n-input
|
||
v-model:value="formData.endpoint"
|
||
placeholder="http://localhost:8080/api/tool"
|
||
/>
|
||
</n-form-item>
|
||
|
||
<n-form-item label="参数配置" path="parameters">
|
||
<n-input
|
||
v-model:value="formData.parameters"
|
||
type="textarea"
|
||
:rows="6"
|
||
placeholder='{"query": {"type": "string", "description": "搜索关键词"}}'
|
||
/>
|
||
</n-form-item>
|
||
|
||
<n-form-item label="启用工具">
|
||
<n-switch v-model:value="formData.enabled" />
|
||
</n-form-item>
|
||
</n-form>
|
||
|
||
<template #footer>
|
||
<div class="modal-footer">
|
||
<n-button @click="showAddModal = false">取消</n-button>
|
||
<n-button type="primary" @click="handleSaveTool" :loading="saving">
|
||
保存
|
||
</n-button>
|
||
</div>
|
||
</template>
|
||
</n-modal>
|
||
|
||
<!-- 测试工具弹窗 -->
|
||
<n-modal
|
||
v-model:show="showTestModal"
|
||
preset="card"
|
||
title="测试工具"
|
||
style="width: 600px"
|
||
>
|
||
<div class="test-content">
|
||
<n-form label-placement="left" label-width="100">
|
||
<n-form-item label="测试参数">
|
||
<n-input
|
||
v-model:value="testParams"
|
||
type="textarea"
|
||
:rows="6"
|
||
placeholder='{"query": "测试搜索"}'
|
||
/>
|
||
</n-form-item>
|
||
</n-form>
|
||
|
||
<div v-if="testResult.status === 'testing'" class="test-status">
|
||
<n-spin size="small" />
|
||
<span>测试中...</span>
|
||
</div>
|
||
|
||
<div v-else-if="testResult.status === 'success'" class="test-status success">
|
||
<n-icon :component="CheckIcon" size="20" color="#18a058" />
|
||
<span>测试成功</span>
|
||
</div>
|
||
|
||
<div v-else-if="testResult.status === 'error'" class="test-status error">
|
||
<n-icon :component="XIcon" size="20" color="#d03050" />
|
||
<span>测试失败:{{ testResult.error }}</span>
|
||
</div>
|
||
|
||
<div v-if="testResult.response" class="test-response">
|
||
<h4>响应结果:</h4>
|
||
<pre>{{ JSON.stringify(testResult.response, null, 2) }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="modal-footer">
|
||
<n-button @click="showTestModal = false">关闭</n-button>
|
||
<n-button type="primary" @click="runTest" :loading="testResult.status === 'testing'">
|
||
运行测试
|
||
</n-button>
|
||
</div>
|
||
</template>
|
||
</n-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import {
|
||
NCard,
|
||
NButton,
|
||
NIcon,
|
||
NTag,
|
||
NModal,
|
||
NForm,
|
||
NFormItem,
|
||
NInput,
|
||
NSwitch,
|
||
NDropdown,
|
||
NSpin,
|
||
useMessage
|
||
} from 'naive-ui'
|
||
import {
|
||
Plus as PlusIcon,
|
||
Tool as ToolIcon,
|
||
DotsVertical as DotsVerticalIcon,
|
||
PlayerPlay as TestIcon,
|
||
Edit as EditIcon,
|
||
X as XIcon,
|
||
Check as CheckIcon,
|
||
Copy as CopyIcon,
|
||
Trash as TrashIcon
|
||
} from '@vicons/tabler'
|
||
|
||
const message = useMessage()
|
||
|
||
// 工具接口定义
|
||
interface Tool {
|
||
id: string
|
||
name: string
|
||
displayName?: string
|
||
description: string
|
||
endpoint: string
|
||
parameters: Record<string, any>
|
||
enabled: boolean
|
||
createdAt?: Date
|
||
updatedAt?: Date
|
||
}
|
||
|
||
// 响应式数据
|
||
const tools = ref<Tool[]>([])
|
||
const showAddModal = ref(false)
|
||
const showTestModal = ref(false)
|
||
const editingTool = ref<Tool | null>(null)
|
||
const saving = ref(false)
|
||
const formRef = ref()
|
||
|
||
// 表单数据
|
||
const formData = reactive({
|
||
name: '',
|
||
displayName: '',
|
||
description: '',
|
||
endpoint: '',
|
||
parameters: '',
|
||
enabled: true
|
||
})
|
||
|
||
// 测试数据
|
||
const testParams = ref('')
|
||
const testResult = reactive({
|
||
status: 'idle' as 'idle' | 'testing' | 'success' | 'error',
|
||
error: '',
|
||
response: null as any
|
||
})
|
||
|
||
// 表单验证规则
|
||
const formRules = {
|
||
name: [
|
||
{ required: true, message: '请输入工具名称', trigger: 'blur' }
|
||
],
|
||
endpoint: [
|
||
{ required: true, message: '请输入端点地址', trigger: 'blur' }
|
||
]
|
||
}
|
||
|
||
// 获取参数数量
|
||
const getParameterCount = (tool: Tool): number => {
|
||
return Object.keys(tool.parameters || {}).length
|
||
}
|
||
|
||
// 下拉菜单选项
|
||
const getDropdownOptions = () => [
|
||
{
|
||
label: '编辑',
|
||
key: 'edit',
|
||
icon: () => h(NIcon, null, { default: () => h(EditIcon) })
|
||
},
|
||
{
|
||
label: '测试',
|
||
key: 'test',
|
||
icon: () => h(NIcon, null, { default: () => h(TestIcon) })
|
||
},
|
||
{
|
||
label: '复制配置',
|
||
key: 'copy',
|
||
icon: () => h(NIcon, null, { default: () => h(CopyIcon) })
|
||
},
|
||
{
|
||
label: '删除',
|
||
key: 'delete',
|
||
icon: () => h(NIcon, null, { default: () => h(TrashIcon) })
|
||
}
|
||
]
|
||
|
||
// 处理下拉菜单选择
|
||
const handleDropdownSelect = (key: string, tool: Tool) => {
|
||
switch (key) {
|
||
case 'edit':
|
||
editTool(tool)
|
||
break
|
||
case 'test':
|
||
testTool(tool)
|
||
break
|
||
case 'copy':
|
||
copyToolConfig(tool)
|
||
break
|
||
case 'delete':
|
||
deleteTool(tool)
|
||
break
|
||
}
|
||
}
|
||
|
||
// 切换工具启用状态
|
||
const toggleTool = (tool: Tool) => {
|
||
saveTools()
|
||
message.success(`${tool.displayName || tool.name} 已${tool.enabled ? '启用' : '禁用'}`)
|
||
}
|
||
|
||
// 编辑工具
|
||
const editTool = (tool: Tool) => {
|
||
editingTool.value = tool
|
||
formData.name = tool.name
|
||
formData.displayName = tool.displayName || ''
|
||
formData.description = tool.description
|
||
formData.endpoint = tool.endpoint
|
||
formData.parameters = JSON.stringify(tool.parameters, null, 2)
|
||
formData.enabled = tool.enabled
|
||
showAddModal.value = true
|
||
}
|
||
|
||
// 测试工具
|
||
const testTool = (tool: Tool) => {
|
||
editingTool.value = tool
|
||
testParams.value = JSON.stringify({}, null, 2)
|
||
testResult.status = 'idle'
|
||
testResult.error = ''
|
||
testResult.response = null
|
||
showTestModal.value = true
|
||
}
|
||
|
||
// 运行测试
|
||
const runTest = async () => {
|
||
if (!editingTool.value) return
|
||
|
||
testResult.status = 'testing'
|
||
testResult.error = ''
|
||
testResult.response = null
|
||
|
||
try {
|
||
const params = JSON.parse(testParams.value)
|
||
|
||
const response = await fetch(editingTool.value.endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(params)
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
testResult.status = 'success'
|
||
testResult.response = data
|
||
message.success('测试成功')
|
||
} catch (error) {
|
||
testResult.status = 'error'
|
||
testResult.error = error instanceof Error ? error.message : '未知错误'
|
||
message.error('测试失败')
|
||
}
|
||
}
|
||
|
||
// 复制工具配置
|
||
const copyToolConfig = (tool: Tool) => {
|
||
const config = {
|
||
name: tool.name,
|
||
displayName: tool.displayName,
|
||
description: tool.description,
|
||
endpoint: tool.endpoint,
|
||
parameters: tool.parameters
|
||
}
|
||
|
||
navigator.clipboard.writeText(JSON.stringify(config, null, 2))
|
||
.then(() => message.success('配置已复制到剪贴板'))
|
||
.catch(() => message.error('复制失败'))
|
||
}
|
||
|
||
// 删除工具
|
||
const deleteTool = (tool: Tool) => {
|
||
const index = tools.value.findIndex(t => t.id === tool.id)
|
||
if (index !== -1) {
|
||
tools.value.splice(index, 1)
|
||
saveTools()
|
||
message.success(`已删除工具 ${tool.displayName || tool.name}`)
|
||
}
|
||
}
|
||
|
||
// 保存工具
|
||
const handleSaveTool = async () => {
|
||
try {
|
||
await formRef.value?.validate()
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
saving.value = true
|
||
|
||
try {
|
||
let parameters = {}
|
||
try {
|
||
parameters = JSON.parse(formData.parameters || '{}')
|
||
} catch {
|
||
message.error('参数配置格式错误,请输入有效的 JSON')
|
||
saving.value = false
|
||
return
|
||
}
|
||
|
||
const toolData: Tool = {
|
||
id: editingTool.value?.id || generateId(),
|
||
name: formData.name,
|
||
displayName: formData.displayName,
|
||
description: formData.description,
|
||
endpoint: formData.endpoint,
|
||
parameters,
|
||
enabled: formData.enabled,
|
||
updatedAt: new Date()
|
||
}
|
||
|
||
if (editingTool.value) {
|
||
// 更新现有工具
|
||
const index = tools.value.findIndex(t => t.id === editingTool.value!.id)
|
||
if (index !== -1) {
|
||
tools.value[index] = toolData
|
||
}
|
||
message.success('工具已更新')
|
||
} else {
|
||
// 添加新工具
|
||
toolData.createdAt = new Date()
|
||
tools.value.push(toolData)
|
||
message.success('工具已添加')
|
||
}
|
||
|
||
saveTools()
|
||
showAddModal.value = false
|
||
resetForm()
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
// 重置表单
|
||
const resetForm = () => {
|
||
editingTool.value = null
|
||
formData.name = ''
|
||
formData.displayName = ''
|
||
formData.description = ''
|
||
formData.endpoint = ''
|
||
formData.parameters = ''
|
||
formData.enabled = true
|
||
}
|
||
|
||
// 生成 ID
|
||
const generateId = (): string => {
|
||
return `tool_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||
}
|
||
|
||
// 保存到本地存储
|
||
const saveTools = () => {
|
||
try {
|
||
localStorage.setItem('mcp-tools', JSON.stringify(tools.value))
|
||
} catch (error) {
|
||
console.error('保存工具列表失败:', error)
|
||
}
|
||
}
|
||
|
||
// 从本地存储加载
|
||
const loadTools = () => {
|
||
try {
|
||
const saved = localStorage.getItem('mcp-tools')
|
||
if (saved) {
|
||
tools.value = JSON.parse(saved)
|
||
}
|
||
} catch (error) {
|
||
console.error('加载工具列表失败:', error)
|
||
}
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
loadTools()
|
||
})
|
||
|
||
// h 函数导入
|
||
import { h } from 'vue'
|
||
</script>
|
||
|
||
<style scoped>
|
||
.tools-manager-page {
|
||
padding: 24px;
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.header-info h1 {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.header-info p {
|
||
font-size: 14px;
|
||
color: var(--text-color-3);
|
||
margin: 0;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 80px 20px;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 48px;
|
||
color: var(--border-color);
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.empty-state h3 {
|
||
font-size: 18px;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.empty-state p {
|
||
color: var(--text-color-3);
|
||
margin: 0 0 24px 0;
|
||
}
|
||
|
||
.tools-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.tool-card {
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.tool-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tool-icon {
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.tool-name {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.card-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tool-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.tool-description {
|
||
color: var(--text-color-2);
|
||
margin: 0;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.tool-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.meta-item {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.meta-label {
|
||
color: var(--text-color-3);
|
||
min-width: 60px;
|
||
}
|
||
|
||
.meta-value {
|
||
color: var(--text-color-2);
|
||
word-break: break-all;
|
||
}
|
||
|
||
.tool-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
}
|
||
|
||
.test-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.test-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
background: var(--color-target-bg);
|
||
}
|
||
|
||
.test-status.success {
|
||
background: rgba(24, 160, 88, 0.1);
|
||
}
|
||
|
||
.test-status.error {
|
||
background: rgba(208, 48, 80, 0.1);
|
||
color: #d03050;
|
||
}
|
||
|
||
.test-response {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.test-response h4 {
|
||
margin: 0 0 8px 0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.test-response pre {
|
||
background: var(--code-bg);
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
margin: 0;
|
||
}
|
||
</style>
|