Files
map-client-vue/web/src/components/ToolsManager.vue
2025-10-14 21:52:11 +08:00

704 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>