update at 2025-10-14 21:52:11

This commit is contained in:
douboer
2025-10-14 21:52:11 +08:00
parent ac3ed480ab
commit 4f5eea604e
40 changed files with 15231 additions and 126 deletions

View File

@@ -0,0 +1,703 @@
<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>