Files
AstrBot/dashboard/src/views/ToolUsePage.vue
T
2025-08-04 17:40:21 +08:00

813 lines
29 KiB
Vue

<template>
<div class="tools-page">
<v-container fluid class="pa-0" elevation="0">
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<h1 class="text-h1 font-weight-bold mb-2">
<v-icon color="black" class="me-2">mdi-function-variant</v-icon>{{ tm('title') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4 d-flex align-center">
{{ tm('subtitle') }}
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" color="primary" class="ms-1 cursor-pointer"
@click="openurl('https://astrbot.app/use/function-calling.html')">
mdi-information
</v-icon>
</template>
<span>{{ tm('tooltip.info') }}</span>
</v-tooltip>
</p>
</div>
<div>
<v-btn color="primary" prepend-icon="mdi-tools" class="me-2" variant="tonal" @click="showToolsDialog = true"
rounded="xl" size="x-large">
{{ tm('functionTools.buttons.view') }}({{ tools.length }})
</v-btn>
<v-btn color="success" prepend-icon="mdi-plus" class="me-2" variant="tonal"
@click="showMcpServerDialog = true" rounded="xl" size="x-large">
{{ tm('mcpServers.buttons.add') }}
</v-btn>
<v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true"
rounded="xl" size="x-large">
{{ tm('mcpServers.buttons.sync') }}
</v-btn>
</div>
</v-row>
<!-- 本地服务器列表 -->
<!-- MCP 服务器部分 -->
<div v-if="mcpServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
</div>
<v-row v-else>
<v-col v-for="(server, index) in mcpServers || []" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card style="background-color: rgb(var(--v-theme-mcpCardBg));" :item="server" title-field="name"
enabled-field="active" @toggle-enabled="updateServerStatus" @delete="deleteServer" @edit="editServer">
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(item)">
{{ getServerConfigSummary(item) }}
</span>
</div>
<div class="d-flex" style="gap: 8px;">
<div>
<div v-if="item.tools && item.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<v-dialog max-width="600px">
<template v-slot:activator="{ props: listToolsProps }">
<span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps"
style="text-decoration: underline;">
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{
item.tools.length }})
</span>
</template>
<template v-slot:default="{ isActive }">
<v-card style="padding: 16px;">
<v-card-title class="d-flex align-center">
<span>{{ tm('mcpServers.status.availableTools') }}</span>
</v-card-title>
<v-card-text>
<ul>
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{
tool
}}
</li>
</ul>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" color="primary" @click="isActive.value = false">
Close
</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</div>
</div>
<div v-else class="text-caption text-medium-emphasis">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
{{ tm('mcpServers.status.noTools') }}
</div>
</div>
<div v-if="mcpServerUpdateLoaders[item.name]" class="text-caption text-medium-emphasis">
<v-progress-circular indeterminate color="primary" size="16"></v-progress-circular>
</div>
</div>
</template>
</item-card>
</v-col>
</v-row>
</v-container>
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showMcpServerDialog" max-width="750px" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">{{ isEditMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ isEditMode ? tm('dialogs.addServer.editTitle') : tm('dialogs.addServer.title') }}</span>
</v-card-title>
<v-card-text class="py-4">
<v-form @submit.prevent="saveServer" ref="form">
<v-text-field v-model="currentServer.name" :label="tm('dialogs.addServer.fields.name')" variant="outlined"
:rules="[v => !!v || tm('dialogs.addServer.fields.nameRequired')]" required class="mb-3"></v-text-field>
<div class="mb-2 d-flex align-center">
<span class="text-subtitle-1">{{ tm('dialogs.addServer.fields.config') }}</span>
<v-spacer></v-spacer>
<v-btn size="small" color="primary" variant="tonal" @click="setConfigTemplate('stdio')" class="me-1">
{{ tm('mcpServers.buttons.useTemplateStdio') }}
</v-btn>
<v-btn size="small" color="primary" variant="tonal" @click="setConfigTemplate('streamable_http')"
class="me-1">
{{ tm('mcpServers.buttons.useTemplateStreamableHttp') }}
</v-btn>
<v-btn size="small" color="primary" variant="tonal" @click="setConfigTemplate('sse')" class="me-1">
{{ tm('mcpServers.buttons.useTemplateSse') }}
</v-btn>
</div>
<div class="monaco-container" style="margin-top: 16px;">
<VueMonacoEditor v-model:value="serverConfigJson" theme="vs-dark" language="json" :options="{
minimap: {
enabled: false
},
scrollBeyondLastLine: false,
automaticLayout: true,
lineNumbers: 'on',
roundedSelection: true,
tabSize: 2
}" @change="validateJson" />
</div>
<div v-if="jsonError" class="mt-2 text-error">
<v-icon color="error" size="small" class="me-1">mdi-alert-circle</v-icon>
<span>{{ jsonError }}</span>
</div>
</v-form>
<div style="margin-top: 8px;">
<small>{{ addServerDialogMessage }}</small>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="closeServerDialog" :disabled="loading">
{{ tm('dialogs.addServer.buttons.cancel') }}
</v-btn>
<v-btn variant="text" @click="testServerConnection" :disabled="loading">
{{ tm('dialogs.addServer.buttons.testConnection') }}
</v-btn>
<v-btn color="primary" @click="saveServer" :loading="loading" :disabled="!isServerFormValid">
{{ tm('dialogs.addServer.buttons.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showSyncMcpServerDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
<span>同步外部平台 MCP 服务器</span>
</v-card-title>
<v-card-text class="py-4">
<v-select v-model="selectedMcpServerProvider" :items="mcpServerProviderList"
label="选择平台" variant="outlined" required></v-select>
<div v-if="selectedMcpServerProvider === 'modelscope'">
<v-timeline align="start" side="end">
<v-timeline-item icon="mdi-numeric-1" icon-color="rgb(var(--v-theme-background))">
<div>
<div class="text-h4">发现 MCP 服务器</div>
<p class="mt-2">
访问 <a href="https://www.modelscope.cn/mcp" target="_blank">ModelScope 平台</a> 浏览需要的 MCP 服务器
</p>
</div>
</v-timeline-item>
<v-timeline-item icon="mdi-numeric-2" icon-color="rgb(var(--v-theme-background))">
<div>
<div class="text-h4">获取访问令牌</div>
<p class="mt-2">
<a href="https://modelscope.cn/my/myaccesstoken" target="_blank">账户设置</a>中获取个人访问令牌
</p>
</div>
</v-timeline-item>
<v-timeline-item icon="mdi-numeric-3" icon-color="rgb(var(--v-theme-background))">
<div>
<div class="text-h4">输入您的访问令牌</div>
<p class="mt-2">
输入您的访问令牌以同步 MCP 服务器
</p>
<v-text-field v-model="mcpProviderToken" type="password" variant="outlined"
label="访问令牌" class="mt-2" hide-details/>
</div>
</v-timeline-item>
</v-timeline>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showSyncMcpServerDialog = false" :disabled="loading">
{{ tm('dialogs.addServer.buttons.cancel') }}
</v-btn>
<v-btn color="primary" @click="syncMcpServers" :loading="loading" :disabled="loading">
{{ tm('dialogs.addServer.buttons.sync') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 函数工具对话框 -->
<v-dialog v-model="showToolsDialog" max-width="800px">
<v-card elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
{{ tm('functionTools.title') }}
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
</v-card-title>
<v-expand-transition>
<v-card-text class="pa-0" v-if="showTools">
<div class="pa-4">
<div v-if="tools.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">{{ tm('functionTools.empty') }}</p>
</div>
<div v-else>
<v-text-field v-model="toolSearch" prepend-inner-icon="mdi-magnify" :label="tm('functionTools.search')"
variant="outlined" density="compact" class="mb-4" hide-details clearable></v-text-field>
<small>复选框代表该工具是否被启用</small>
<v-expansion-panels v-model="openedPanel" multiple style="max-height: 500px; overflow-y: auto;">
<v-expansion-panel v-for="(tool, index) in filteredTools" :key="index" :value="index"
class="mb-2 tool-panel" rounded="lg">
<v-expansion-panel-title>
<v-row no-gutters align="center">
<v-col cols="1">
<v-checkbox v-model="tool.active" color="primary" hide-details density="compact" @click.stop
@change="toggleToolStatus(tool)"></v-checkbox>
</v-col>
<v-col cols="3">
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">
{{ tool.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
:title="tool.name">
{{ formatToolName(tool.name) }}
</span>
</div>
</v-col>
<v-col cols="8" class="text-grey">
{{ tool.description }}
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
{{ tm('functionTools.description') }}
</p>
<p class="text-body-2 ml-6 mb-4">{{ tool.description }}</p>
<template v-if="tool.parameters && tool.parameters.properties">
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
{{ tm('functionTools.parameters') }}
</p>
<v-table density="compact" class="params-table mt-1">
<thead>
<tr>
<th>{{ tm('functionTools.table.paramName') }}</th>
<th>{{ tm('functionTools.table.type') }}</th>
<th>{{ tm('functionTools.table.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.parameters.properties" :key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text class="text-caption">
{{ param.type }}
</v-chip>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
<div v-else class="text-center pa-4 text-medium-emphasis">
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
<p>{{ tm('functionTools.noParameters') }}</p>
</div>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</div>
</v-card-text>
</v-expand-transition>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showToolsDialog = false">
{{ tm('dialogs.serverDetail.buttons.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCard from '@/components/shared/ItemCard.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
export default {
name: 'ToolUsePage',
components: {
AstrBotConfig,
VueMonacoEditor,
ItemCard
},
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/tooluse');
return { t, tm };
},
data() {
return {
refreshInterval: null,
mcpServers: [],
tools: [],
showMcpServerDialog: false,
selectedMcpServerProvider: "modelscope",
mcpServerProviderList: ["modelscope"],
mcpProviderToken: '',
showSyncMcpServerDialog: false,
addServerDialogMessage: "",
showToolsDialog: false,
showTools: true,
loading: false,
loadingGettingServers: false,
mcpServerUpdateLoaders: {}, // record loading state for each server update
isEditMode: false,
serverConfigJson: '',
jsonError: null,
currentServer: {
name: '',
active: true,
tools: []
},
save_message_snack: false,
save_message: "",
save_message_success: "success",
toolSearch: '',
openedPanel: [], // 存储打开的面板索引
}
},
computed: {
filteredTools() {
if (!this.toolSearch) return this.tools;
const searchTerm = this.toolSearch.toLowerCase();
return this.tools.filter(tool =>
tool.name.toLowerCase().includes(searchTerm) ||
tool.description.toLowerCase().includes(searchTerm)
);
},
isServerFormValid() {
return !!this.currentServer.name && !this.jsonError;
},
// 显示服务器配置的文本摘要
getServerConfigSummary() {
return (server) => {
if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`;
}
// 如果没有command字段,尝试显示其他有意义的配置信息
const configKeys = Object.keys(server).filter(key =>
!['name', 'active', 'tools'].includes(key)
);
if (configKeys.length > 0) {
return this.tm('mcpServers.status.configSummary', { keys: configKeys.join(', ') });
}
return this.tm('mcpServers.status.noConfig');
}
},
},
mounted() {
this.getServers();
this.getTools();
this.refreshInterval = setInterval(() => {
this.getServers();
this.getTools();
}, 5000);
},
unmounted() {
// 清除定时器 if it exists
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: {
openurl(url) {
window.open(url, '_blank');
},
formatToolName(name) {
if (name.includes(':')) {
// MCP 工具通常命名为 mcp:server:tool
const parts = name.split(':');
return parts[parts.length - 1]; // 返回最后一部分
}
return name;
},
getServers() {
this.loadingGettingServers = true;
axios.get('/api/tools/mcp/servers')
.then(response => {
this.mcpServers = response.data.data || [];
this.mcpServers.forEach(server => {
// Ensure each server has a loader state
if (!this.mcpServerUpdateLoaders[server.name]) {
this.mcpServerUpdateLoaders[server.name] = false;
}
});
})
.catch(error => {
this.showError(this.tm('messages.getServersError', { error: error.message }));
}).finally(() => {
this.loadingGettingServers = false;
});
},
getTools() {
axios.get('/api/tools/list')
.then(response => {
this.tools = response.data.data || [];
})
.catch(error => {
this.showError(this.tm('messages.getToolsError', { error: error.message }));
});
},
validateJson() {
try {
if (!this.serverConfigJson.trim()) {
this.jsonError = this.tm('dialogs.addServer.errors.configEmpty');
return false;
}
JSON.parse(this.serverConfigJson);
this.jsonError = null;
return true;
} catch (e) {
this.jsonError = this.tm('dialogs.addServer.errors.jsonFormat', { error: e.message });
return false;
}
},
setConfigTemplate(type = 'stdio') {
let template = {};
if (type === 'streamable_http') {
template = {
transport: "streamable_http",
url: "your mcp server url",
headers: {},
timeout: 30,
};
} else if (type === 'sse') {
template = {
transport: "sse",
url: "your mcp server url",
headers: {},
timeout: 30,
};
} else {
template = {
command: "python",
args: ["-m", "your_module"],
};
}
this.serverConfigJson = JSON.stringify(template, null, 2);
},
saveServer() {
if (!this.validateJson()) {
return;
}
this.loading = true;
// 解析JSON配置并与基本信息合并
try {
const configObj = JSON.parse(this.serverConfigJson);
// 创建要发送的完整配置对象
const serverData = {
name: this.currentServer.name,
active: this.currentServer.active,
...configObj
};
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
axios.post(endpoint, serverData)
.then(response => {
this.loading = false;
this.showMcpServerDialog = false;
this.addServerDialogMessage = "";
this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.saveSuccess'));
this.resetForm();
})
.catch(error => {
this.loading = false;
this.showError(this.tm('messages.saveError', { error: error.response?.data?.message || error.message }));
});
} catch (e) {
this.loading = false;
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
}
},
deleteServer(server) {
let serverName = server.name || server;
if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) {
axios.post('/api/tools/mcp/delete', { name: serverName })
.then(response => {
this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.deleteSuccess'));
})
.catch(error => {
this.showError(this.tm('messages.deleteError', { error: error.response?.data?.message || error.message }));
});
}
},
editServer(server) {
// 创建一个不包含基本字段的配置对象副本
const configCopy = { ...server };
// 移除基本字段,只保留配置相关字段
try {
delete configCopy.name;
delete configCopy.active;
delete configCopy.tools;
delete configCopy.errlogs;
} catch (e) {
console.error("Error removing basic fields: ", e);
}
// 设置当前服务器的基本信息
this.currentServer = {
name: server.name,
active: server.active,
tools: server.tools || []
};
// 将剩余配置转换为JSON字符串
this.serverConfigJson = JSON.stringify(configCopy, null, 2);
this.isEditMode = true;
this.showMcpServerDialog = true;
},
updateServerStatus(server) {
// 切换服务器状态
this.mcpServerUpdateLoaders[server.name] = true;
server.active = !server.active;
axios.post('/api/tools/mcp/update', server)
.then(response => {
this.getServers();
this.showSuccess(response.data.message || this.tm('messages.updateSuccess'));
})
.catch(error => {
this.showError(this.tm('messages.updateError', { error: error.response?.data?.message || error.message }));
server.active = !server.active;
})
.finally(() => {
this.mcpServerUpdateLoaders[server.name] = false;
});
},
closeServerDialog() {
this.showMcpServerDialog = false;
this.addServerDialogMessage = '';
this.resetForm();
},
testServerConnection() {
if (!this.validateJson()) {
return;
}
this.loading = true;
let configObj;
try {
configObj = JSON.parse(this.serverConfigJson);
} catch (e) {
this.loading = false;
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
return;
}
axios.post('/api/tools/mcp/test', {
"mcp_server_config": configObj,
})
.then(response => {
this.loading = false;
this.addServerDialogMessage = `${response.data.message} (tools: ${response.data.data})`;
})
.catch(error => {
this.loading = false;
this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message }));
});
},
resetForm() {
this.currentServer = {
name: '',
active: true,
tools: []
};
this.serverConfigJson = '';
this.jsonError = null;
this.isEditMode = false;
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
this.save_message_snack = true;
},
// MCP 市场相关方法已移除
// 切换工具状态
async toggleToolStatus(tool) {
try {
const response = await axios.post('/api/tools/toggle-tool', {
name: tool.name,
activate: tool.active
});
if (response.data.status === 'ok') {
this.showSuccess(response.data.message || this.tm('messages.toggleToolSuccess'));
} else {
// 如果失败,恢复原状态
tool.active = !tool.active;
this.showError(response.data.message || this.tm('messages.toggleToolError'));
}
} catch (error) {
// 如果失败,恢复原状态
tool.active = !tool.active;
this.showError(this.tm('messages.toggleToolError', { error: error.response?.data?.message || error.message }));
}
},
// 同步 MCP 服务器
async syncMcpServers() {
if (!this.selectedMcpServerProvider) {
this.showError(this.tm('syncProvider.status.selectProvider'));
return;
}
this.loading = true;
try {
const requestData = {
name: this.selectedMcpServerProvider
};
// 根据不同平台添加相应的参数
if (this.selectedMcpServerProvider === 'modelscope') {
if (!this.mcpProviderToken.trim()) {
this.showError(this.tm('syncProvider.status.enterToken'));
this.loading = false;
return;
}
requestData.access_token = this.mcpProviderToken.trim();
}
const response = await axios.post('/api/tools/mcp/sync-provider', requestData);
if (response.data.status === 'ok') {
this.showSuccess(response.data.message || this.tm('syncProvider.messages.syncSuccess'));
this.showSyncMcpServerDialog = false;
this.mcpProviderToken = '';
// 刷新服务器列表
this.getServers();
this.getTools();
} else {
this.showError(response.data.message || this.tm('syncProvider.messages.syncError', { error: 'Unknown error' }));
}
} catch (error) {
console.error('同步 MCP 服务器失败:', error);
this.showError(this.tm('syncProvider.messages.syncError', {
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
}));
} finally {
this.loading = false;
}
}
}
}
</script>
<style scoped>
.tools-page {
padding: 20px;
padding-top: 8px;
}
.tool-chips {
max-height: 60px;
overflow-y: auto;
}
.tool-panel {
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.tool-panel:hover {
border-color: rgba(0, 0, 0, 0.1);
}
.params-table {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
}
.params-table th {
background-color: rgba(0, 0, 0, 0.02);
}
.monaco-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
height: 300px;
margin-top: 4px;
overflow: hidden;
}
</style>