Files
xiaozhi-esp32-server/main/manager-web/src/components/FunctionDialog.vue
T

837 lines
21 KiB
Vue
Raw Normal View History

2025-05-12 14:10:05 +08:00
<template>
2025-06-12 23:07:42 +08:00
<el-drawer :visible.sync="dialogVisible" direction="rtl" size="80%" :wrapperClosable="false" :withHeader="false">
2025-05-12 14:10:05 +08:00
<!-- 自定义标题区域 -->
<div class="custom-header">
<div class="header-left">
<h3 class="bold-title">功能管理</h3>
</div>
<button class="custom-close-btn" @click="closeDialog">×</button>
</div>
<div class="function-manager">
<!-- 左侧未选功能 -->
<div class="function-column">
<div class="column-header">
<h4 class="column-title">未选功能</h4>
<el-button type="text" @click="selectAll" class="select-all-btn">全选</el-button>
</div>
<div class="function-list">
<div v-if="unselected.length">
<div v-for="func in unselected" :key="func.name" class="function-item">
<el-checkbox :label="func.name" v-model="selectedNames" @change="(val) => handleCheckboxChange(func, val)"
@click.native.stop></el-checkbox>
<div class="func-tag" @click="handleFunctionClick(func)">
<div class="color-dot" :style="{ backgroundColor: getFunctionColor(func.name) }"></div>
<span>{{ func.name }}</span>
</div>
2025-05-12 16:48:34 +08:00
</div>
</div>
<div v-else style="display: flex; justify-content: center; align-items: center;">
<el-empty description="没有更多的插件了" />
2025-05-12 14:10:05 +08:00
</div>
</div>
</div>
<!-- 中间已选功能 -->
<div class="function-column">
<div class="column-header">
<h4 class="column-title">已选功能</h4>
<el-button type="text" @click="deselectAll" class="select-all-btn">全选</el-button>
</div>
<div class="function-list">
<div v-if="selectedList.length > 0">
<div v-for="func in selectedList" :key="func.name" class="function-item">
<el-checkbox :label="func.name" v-model="selectedNames" @change="(val) => handleCheckboxChange(func, val)"
@click.native.stop></el-checkbox>
<div class="func-tag" @click="handleFunctionClick(func)">
<div class="color-dot" :style="{ backgroundColor: getFunctionColor(func.name) }"></div>
<span>{{ func.name }}</span>
</div>
2025-05-12 16:48:34 +08:00
</div>
2025-05-12 14:10:05 +08:00
</div>
<div v-else style="display: flex; justify-content: center; align-items: center;">
<el-empty description="请选择插件功能" />
</div>
2025-05-12 14:10:05 +08:00
</div>
</div>
<!-- 右侧参数配置 -->
<div class="params-column">
<h4 v-if="currentFunction" class="column-title">参数配置 - {{ currentFunction.name }}</h4>
<div v-if="currentFunction" class="params-container">
2025-06-12 23:07:42 +08:00
<el-form :model="currentFunction" class="param-form">
<!-- 遍历 fieldsMeta而不是 params keys -->
2025-06-11 23:05:38 +08:00
<div v-if="currentFunction.fieldsMeta.length == 0">
<el-empty :description="currentFunction.name + ' 无需配置参数'" />
</div>
<el-form-item v-for="field in currentFunction.fieldsMeta" :key="field.key" :label="field.label"
2025-06-12 23:07:42 +08:00
class="param-item" :class="{ 'textarea-field': field.type === 'array' || field.type === 'json' }">
<template #label>
2025-06-11 23:05:38 +08:00
<span style="font-size: 16px; margin-right: 6px;">{{ field.label }}</span>
<el-tooltip effect="dark" :content="fieldRemark(field)" placement="top">
<img src="@/assets/home/info.png" alt="" class="info-icon">
</el-tooltip>
</template>
<!-- ARRAY -->
2025-06-12 23:07:42 +08:00
<el-input v-if="field.type === 'array'" type="textarea" v-model="currentFunction.params[field.key]"
@change="val => handleParamChange(currentFunction, field.key, val)" />
<!-- JSON -->
<el-input v-else-if="field.type === 'json'" type="textarea" :rows="6" placeholder="请输入合法的 JSON"
v-model="textCache[field.key]" @blur="flushJson(field)" />
<!-- number -->
<el-input-number v-else-if="field.type === 'number'" :value="currentFunction.params[field.key]"
@change="val => handleParamChange(currentFunction, field.key, val)" />
<!-- boolean -->
<el-switch v-else-if="field.type === 'boolean' || field.type === 'bool'"
:value="currentFunction.params[field.key]"
@change="val => handleParamChange(currentFunction, field.key, val)" />
<!-- string or fallback -->
<el-input v-else v-model="currentFunction.params[field.key]"
@change="val => handleParamChange(currentFunction, field.key, val)" />
</el-form-item>
</el-form>
</div>
2025-05-12 14:10:05 +08:00
<div v-else class="empty-tip">请选择已配置的功能进行参数设置</div>
</div>
</div>
2025-06-24 14:39:18 +08:00
<!-- MCP区域 -->
<div class="mcp-access-point">
<div class="mcp-container">
<!-- 左侧区域 -->
2025-06-27 11:33:12 +08:00
<div class="mcp-left">
<div class="mcp-header">
<h3 class="bold-title">MCP接入点</h3>
2025-06-24 14:39:18 +08:00
</div>
2025-06-27 11:33:12 +08:00
<div class="url-header">
<div class="address-desc">
<span>以下是智能体的MCP接入点地址</span>
<a href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md"
class="doc-link">查看接入点使用文档</a>
</div>
</div>
<el-input v-model="mcpUrl" readonly class="url-input">
<template #suffix>
<el-button @click="copyUrl" class="inner-copy-btn" icon="el-icon-document-copy">
复制
</el-button>
</template>
</el-input>
2025-06-24 14:39:18 +08:00
</div>
<!-- 右侧区域 -->
<div class="mcp-right">
2025-06-27 11:33:12 +08:00
<div class="mcp-header">
<h3 class="bold-title">接入点状态</h3>
</div>
2025-06-24 14:39:18 +08:00
<div class="status-container">
<span class="status-indicator" :class="mcpStatus"></span>
2025-06-27 11:33:12 +08:00
<span class="status-text">{{
mcpStatus === 'connected' ? '已连接' :
mcpStatus === 'loading' ? '加载中...' : '未连接'
}}</span>
2025-06-24 14:39:18 +08:00
<button class="refresh-btn" @click="refreshStatus">
<span class="refresh-icon"></span>
<span>刷新</span>
</button>
</div>
2025-06-27 11:33:12 +08:00
<div class="mcp-tools-list">
<div v-if="mcpTools.length > 0" class="tools-grid">
<el-button v-for="tool in mcpTools" :key="tool" size="small" class="tool-btn" plain>
{{ tool }}
</el-button>
</div>
<div v-else class="no-tools">
<span>暂无可用工具</span>
</div>
</div>
2025-06-24 14:39:18 +08:00
</div>
</div>
</div>
2025-05-12 14:10:05 +08:00
<div class="drawer-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="saveSelection">保存配置</el-button>
</div>
</el-drawer>
</template>
<script>
2025-06-27 11:33:12 +08:00
import Api from '@/apis/api';
2025-05-12 14:10:05 +08:00
export default {
props: {
value: Boolean,
functions: {
type: Array,
default: () => []
},
allFunctions: {
type: Array,
default: () => []
2025-06-27 11:33:12 +08:00
},
agentId: {
type: String,
required: true
2025-05-12 14:10:05 +08:00
}
},
data() {
return {
textCache: {},
2025-05-12 14:10:05 +08:00
dialogVisible: this.value,
selectedNames: [],
currentFunction: null,
modifiedFunctions: {},
functionColorMap: [
'#FF6B6B', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FFEEAD', '#D4A5A5', '#A2836E'
],
tempFunctions: {},
// 添加一个标志位来跟踪是否已经保存
hasSaved: false,
2025-05-12 16:48:34 +08:00
loading: false,
2025-06-27 11:33:12 +08:00
mcpUrl: "",
2025-06-24 14:39:18 +08:00
mcpStatus: "disconnected",
2025-06-27 11:33:12 +08:00
mcpTools: [],
2025-05-12 14:10:05 +08:00
}
},
computed: {
selectedList() {
return this.allFunctions.filter(f => this.selectedNames.includes(f.name));
},
unselected() {
return this.allFunctions.filter(f => !this.selectedNames.includes(f.name));
}
},
watch: {
currentFunction(newFn) {
if (!newFn) return;
// 对每个字段,如果是 array 或 json,就在 textCache 里生成初始字符串
newFn.fieldsMeta.forEach(f => {
const v = newFn.params[f.key];
if (f.type === 'array') {
this.$set(this.textCache, f.key, Array.isArray(v) ? v.join('\n') : '');
}
else if (f.type === 'json') {
try {
this.$set(this.textCache, f.key, JSON.stringify(v ?? {}, null, 2));
} catch {
this.$set(this.textCache, f.key, '');
}
}
});
},
value(v) {
this.dialogVisible = v;
if (v) {
// 对话框打开时,初始化选中态
2025-05-12 14:10:05 +08:00
this.selectedNames = this.functions.map(f => f.name);
// 把后端传来的 this.functions(带 paramsmerge 到 allFunctions 上
this.functions.forEach(saved => {
const idx = this.allFunctions.findIndex(f => f.name === saved.name);
if (idx >= 0) {
// 保留用户之前在 saved.params 上的改动
this.allFunctions[idx].params = { ...saved.params };
}
});
// 右侧默认指向第一个
2025-05-12 14:10:05 +08:00
this.currentFunction = this.selectedList[0] || null;
2025-06-27 11:33:12 +08:00
// 加载MCP数据
this.loadMcpAddress();
this.loadMcpTools();
2025-05-12 14:10:05 +08:00
}
},
dialogVisible(newVal) {
this.$emit('input', newVal);
}
},
methods: {
2025-06-24 14:39:18 +08:00
copyUrl() {
const textarea = document.createElement('textarea');
textarea.value = this.mcpUrl;
textarea.style.position = 'fixed'; // 防止页面滚动
document.body.appendChild(textarea);
textarea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
this.$message.success('已复制到剪贴板');
} else {
this.$message.error('复制失败,请手动复制');
}
} catch (err) {
this.$message.error('复制失败,请手动复制');
console.error('复制失败:', err);
} finally {
document.body.removeChild(textarea);
}
},
refreshStatus() {
this.mcpStatus = "loading";
2025-06-27 11:33:12 +08:00
this.loadMcpTools();
},
// 加载MCP接入点地址
loadMcpAddress() {
Api.agent.getAgentMcpAccessAddress(this.agentId, (res) => {
if (res.data.code === 0) {
this.mcpUrl = res.data.data || "";
} else {
this.mcpUrl = "";
console.error('获取MCP地址失败:', res.data.msg);
}
});
},
// 加载MCP工具列表
loadMcpTools() {
Api.agent.getAgentMcpToolsList(this.agentId, (res) => {
if (res.data.code === 0) {
this.mcpTools = res.data.data || [];
// 根据工具列表更新状态
this.mcpStatus = this.mcpTools.length > 0 ? "connected" : "disconnected";
} else {
this.mcpTools = [];
this.mcpStatus = "disconnected";
console.error('获取MCP工具列表失败:', res.data.msg);
}
});
2025-06-24 14:39:18 +08:00
},
flushArray(key) {
const text = this.textCache[key] || '';
const arr = text
.split('\n')
.map(s => s.trim())
.filter(Boolean);
this.handleParamChange(this.currentFunction, key, arr);
},
flushJson(field) {
const key = field.key;
if (!key) {
return;
}
const text = this.textCache[key] || '';
try {
const obj = JSON.parse(text);
this.handleParamChange(this.currentFunction, key, obj);
} catch {
this.$message.error(`${this.currentFunction.name}${key}字段格式错误:JSON格式有误`);
}
},
2025-05-12 14:10:05 +08:00
handleFunctionClick(func) {
if (this.selectedNames.includes(func.name)) {
const tempFunc = this.tempFunctions[func.name];
this.currentFunction = tempFunc ? tempFunc : func;
2025-05-12 14:10:05 +08:00
}
},
handleParamChange(func, key, value) {
if (!this.tempFunctions[func.name]) {
this.tempFunctions[func.name] = JSON.parse(JSON.stringify(func));
}
this.tempFunctions[func.name].params[key] = value;
},
handleCheckboxChange(func, checked) {
if (checked) {
if (!this.selectedNames.includes(func.name)) {
this.selectedNames = [...this.selectedNames, func.name];
}
} else {
this.selectedNames = this.selectedNames.filter(name => name !== func.name);
}
2025-05-12 16:48:34 +08:00
if (this.selectedList.length > 0) {
this.currentFunction = this.selectedList[0];
} else {
2025-05-12 14:10:05 +08:00
this.currentFunction = null;
}
},
selectAll() {
this.selectedNames = [...this.allFunctions.map(f => f.name)];
if (this.selectedList.length > 0) {
this.currentFunction = JSON.parse(JSON.stringify(this.selectedList[0]));
}
},
deselectAll() {
this.selectedNames = [];
this.currentFunction = null;
},
closeDialog() {
this.tempFunctions = {};
this.selectedNames = this.functions.map(f => f.name);
this.currentFunction = null;
this.dialogVisible = false;
this.$emit('input', false);
this.$emit('dialog-closed', false);
},
saveSelection() {
Object.keys(this.tempFunctions).forEach(name => {
this.modifiedFunctions[name] = JSON.parse(JSON.stringify(this.tempFunctions[name]));
});
this.tempFunctions = {};
this.hasSaved = true;
const selected = this.selectedList.map(f => {
const modified = this.modifiedFunctions[f.name];
return {
id: f.id,
name: f.name,
params: modified
? { ...modified.params }
: { ...f.params }
}
});
2025-05-12 14:10:05 +08:00
this.$emit('update-functions', selected);
this.dialogVisible = false;
// 通知父组件对话框已关闭且已保存
this.$emit('dialog-closed', true);
},
getFunctionColor(name) {
const hash = [...name].reduce((acc, char) => acc + char.charCodeAt(0), 0);
return this.functionColorMap[hash % this.functionColorMap.length];
},
fieldRemark(field) {
let description = (field && field.label) ? field.label : '';
if (field.default) {
description += `(默认值:${field.default}`;
}
return description;
},
2025-05-12 14:10:05 +08:00
}
}
</script>
<style lang="scss" scoped>
.function-manager {
display: grid;
grid-template-columns: max-content max-content 1fr;
2025-05-12 14:10:05 +08:00
gap: 12px;
2025-06-24 14:39:18 +08:00
height: calc(58vh);
2025-05-12 14:10:05 +08:00
}
.custom-header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #EBEEF5;
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.bold-title {
font-size: 18px;
font-weight: bold;
margin: 0;
}
.select-all-btn {
padding: 0;
height: auto;
font-size: 14px;
}
}
.function-column {
position: relative;
width: auto;
padding: 10px;
overflow-y: auto;
border-right: 1px solid #EBEEF5;
scrollbar-width: none;
overflow-x: hidden;
2025-05-12 14:10:05 +08:00
}
.function-column::-webkit-scrollbar {
display: none;
}
.function-list {
display: flex;
flex-direction: column;
gap: 8px;
2025-05-12 14:10:05 +08:00
}
.function-item {
padding: 8px 12px;
margin: 4px 0;
width: 100%;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
background-color: #f5f7fa;
}
}
.params-column {
min-width: 280px;
padding: 10px;
overflow-y: auto;
scrollbar-width: none;
}
.params-column::-webkit-scrollbar {
display: none;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.column-title {
text-align: center;
width: 100%;
}
.func-tag {
display: flex;
align-items: center;
2025-05-12 16:48:34 +08:00
cursor: pointer;
flex-grow: 1;
margin-left: 8px;
2025-05-12 14:10:05 +08:00
}
.color-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
margin-right: 8px;
border-radius: 50%;
}
.param-form {
.param-item {
font-size: 16px;
2025-06-12 23:07:42 +08:00
&.textarea-field {
::v-deep .el-form-item__content {
margin-left: 0 !important;
display: block;
width: 100%;
}
::v-deep .el-form-item__label {
display: block;
width: 100% !important;
margin-bottom: 8px;
}
}
}
.param-input {
width: 100%;
}
2025-05-12 14:10:05 +08:00
::v-deep .el-form-item {
display: flex;
2025-06-12 23:07:42 +08:00
flex-direction: column;
2025-05-12 14:10:05 +08:00
margin-bottom: 12px;
.el-form-item__label {
font-size: 14px !important;
color: #606266;
text-align: left;
padding-right: 10px;
flex-shrink: 0;
width: auto !important;
}
.el-form-item__content {
margin-left: 0 !important;
flex-grow: 1;
.el-input__inner {
text-align: left;
padding-left: 8px;
width: 100%;
}
}
}
}
.params-container {
padding: 16px;
border-radius: 4px;
min-width: 280px;
}
.empty-tip {
padding: 20px;
color: #909399;
text-align: center;
}
.drawer-footer {
position: absolute;
bottom: 0;
width: 100%;
border-top: 1px solid #e8e8e8;
padding: 10px 16px;
text-align: center;
background: #fff;
}
.info-icon {
width: 16px;
height: 16px;
margin-right: 1vh;
}
.custom-close-btn {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
width: 35px;
height: 35px;
border-radius: 50%;
border: 2px solid #cfcfcf;
background: none;
font-size: 30px;
font-weight: lighter;
color: #cfcfcf;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
padding: 0;
outline: none;
transition: all 0.3s;
}
.custom-close-btn:hover {
color: #409EFF;
border-color: #409EFF;
}
2025-05-12 16:48:34 +08:00
::v-deep .el-checkbox__label {
display: none;
}
2025-06-24 14:39:18 +08:00
.mcp-access-point {
border-top: 1px solid #EBEEF5;
padding: 20px 24px;
text-align: left;
}
.mcp-header {
.bold-title {
font-size: 18px;
font-weight: bold;
margin: 5px 0 30px 0;
}
}
.mcp-container {
display: flex;
justify-content: space-between;
gap: 30px;
}
2025-06-27 11:33:12 +08:00
.mcp-left,
.mcp-right {
2025-06-24 14:39:18 +08:00
flex: 1;
}
.url-header {
margin-bottom: 8px;
color: black;
2025-06-27 11:33:12 +08:00
2025-06-24 14:39:18 +08:00
h4 {
margin: 0 0 15px 0;
font-size: 16px;
font-weight: normal;
}
.address-desc {
display: flex;
align-items: center;
font-size: 14px;
margin-bottom: 12px;
.doc-link {
color: #1677ff;
text-decoration: none;
margin-left: 4px;
&:hover {
text-decoration: underline;
}
}
}
}
.url-input {
border-radius: 4px 0 0 4px;
font-size: 14px;
height: 36px;
box-sizing: border-box;
background-color: #f5f5f5;
}
2025-06-27 11:33:12 +08:00
::v-deep .el-input__inner {
2025-06-24 14:39:18 +08:00
background-color: #f5f5f5;
2025-06-27 11:33:12 +08:00
padding-right: 80px;
2025-06-24 14:39:18 +08:00
}
.url-input {
::v-deep .el-input__suffix {
right: 0;
display: flex;
align-items: center;
2025-06-27 11:33:12 +08:00
padding-right: 10px;
2025-06-24 14:39:18 +08:00
.inner-copy-btn {
pointer-events: auto;
border: none;
2025-06-27 11:33:12 +08:00
background: #1677ff;
color: white;
2025-06-24 14:39:18 +08:00
padding: 6px;
2025-06-27 11:33:12 +08:00
margin-top: 4px;
2025-06-24 14:39:18 +08:00
margin-left: 4px;
}
}
}
.mcp-right {
h4 {
margin: 0 0 10px 0;
font-size: 16px;
font-weight: normal;
color: black;
}
}
.status-container {
display: flex;
align-items: center;
.status-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
&.disconnected {
2025-06-27 11:33:12 +08:00
background-color: #909399;
/* 灰色 - 未连接 */
2025-06-24 14:39:18 +08:00
}
&.connected {
2025-06-27 11:33:12 +08:00
background-color: #67C23A;
/* 绿色 - 已连接 */
2025-06-24 14:39:18 +08:00
}
&.loading {
2025-06-27 11:33:12 +08:00
background-color: #E6A23C;
/* 橙色 - 加载中 */
2025-06-24 14:39:18 +08:00
animation: pulse 1.5s infinite;
}
}
.status-text {
font-size: 14px;
margin-right: 10px;
}
.refresh-btn {
display: flex;
align-items: center;
padding: 2px 10px;
background: white;
color: black;
border: 1px solid #DCDFE6;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
&:hover {
background: #1677ff;
color: white;
border-color: #1677ff;
}
.refresh-icon {
margin-right: 6px;
font-size: 14px;
}
}
}
@keyframes pulse {
2025-06-27 11:33:12 +08:00
0% {
opacity: 1;
}
50% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
.mcp-tools-list {
margin-top: 10px;
.tools-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tool-btn {
padding: 6px 12px;
border-color: #1677ff;
color: #1677ff;
background-color: white;
font-size: 12px;
&:hover {
background-color: #1677ff;
color: white;
border-color: #1677ff;
}
}
.no-tools {
text-align: center;
color: #909399;
font-size: 14px;
padding: 10px 0;
}
2025-06-24 14:39:18 +08:00
}
2025-05-12 14:10:05 +08:00
</style>