mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(web): implement bot MCP management page and remove global MCP page
- Add bot-mcp.vue component with table, create/edit dialog, import/export - Wire MCP tab into bot detail page - Remove dead global MCP page, composable, and form component (/mcp route) - Add i18n keys for import/export/copy and MCP CRUD messages - Fix store/User.ts case sensitivity for Linux builds
This commit is contained in:
@@ -1,313 +0,0 @@
|
||||
<template>
|
||||
<section class="flex">
|
||||
<Dialog v-model:open="open">
|
||||
<DialogTrigger as-child>
|
||||
<Button
|
||||
variant="default"
|
||||
class="ml-auto my-4"
|
||||
>
|
||||
{{ $t('mcp.addTitle') }}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-106.25">
|
||||
<form @submit="createMCP">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('mcp.addTitle') }}</DialogTitle>
|
||||
<DialogDescription class="mb-4">
|
||||
{{ $t('mcp.addDescription') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Name -->
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="name"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="mb-2">
|
||||
{{ $t('mcp.name') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="$t('mcp.namePlaceholder')"
|
||||
v-bind="componentField"
|
||||
autocomplete="name"
|
||||
/>
|
||||
</FormControl>
|
||||
<blockquote class="h-5">
|
||||
<FormMessage />
|
||||
</blockquote>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Type -->
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="config.type"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="mb-2">
|
||||
{{ $t('mcp.type') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="$t('mcp.typePlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="stdio">
|
||||
{{ $t('mcp.types.stdio') }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<blockquote class="h-5">
|
||||
<FormMessage />
|
||||
</blockquote>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Cwd -->
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="config.cwd"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="mb-2">
|
||||
{{ $t('mcp.cwd') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="$t('mcp.cwdPlaceholder')"
|
||||
v-bind="componentField"
|
||||
autocomplete="cwd"
|
||||
/>
|
||||
</FormControl>
|
||||
<blockquote class="h-5">
|
||||
<FormMessage />
|
||||
</blockquote>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Command -->
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="config.command"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="mb-2">
|
||||
{{ $t('mcp.command') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
:placeholder="$t('mcp.commandPlaceholder')"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<blockquote class="h-5">
|
||||
<FormMessage />
|
||||
</blockquote>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Arguments -->
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="config.args"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="mb-2">
|
||||
{{ $t('mcp.arguments') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagsInput
|
||||
v-model="componentField.modelValue"
|
||||
:add-on-blur="true"
|
||||
:duplicate="true"
|
||||
@update:model-value="componentField['onUpdate:modelValue']"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="item in componentField.modelValue"
|
||||
:key="item"
|
||||
:value="item"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
:placeholder="$t('mcp.argumentsPlaceholder')"
|
||||
class="w-full py-1"
|
||||
/>
|
||||
</TagsInput>
|
||||
</FormControl>
|
||||
<blockquote class="h-5">
|
||||
<FormMessage />
|
||||
</blockquote>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Env (key:value tags) -->
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="config.env"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="mb-2">
|
||||
{{ $t('mcp.env') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagsInput
|
||||
:add-on-blur="true"
|
||||
:model-value="envTags.tagList.value"
|
||||
:convert-value="envTags.convertValue"
|
||||
@update:model-value="(tags) => envTags.handleUpdate(tags.map(String), componentField['onUpdate:modelValue'])"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="(value, index) in envTags.tagList.value"
|
||||
:key="index"
|
||||
:value="value"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
:placeholder="$t('mcp.envPlaceholder')"
|
||||
class="w-full py-1"
|
||||
/>
|
||||
</TagsInput>
|
||||
</FormControl>
|
||||
<blockquote class="h-5">
|
||||
<FormMessage />
|
||||
</blockquote>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Active -->
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="active"
|
||||
>
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<section class="flex gap-4">
|
||||
<Label>{{ $t('mcp.active') }}</Label>
|
||||
<Switch
|
||||
:model-value="componentField.modelValue"
|
||||
@update:model-value="componentField['onUpdate:modelValue']"
|
||||
/>
|
||||
</section>
|
||||
</FormControl>
|
||||
<blockquote class="h-5">
|
||||
<FormMessage />
|
||||
</blockquote>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="mt-4">
|
||||
<DialogClose as-child>
|
||||
<Button variant="outline">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">
|
||||
{{ $t('mcp.addTitle') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
FormField,
|
||||
FormControl,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText,
|
||||
Switch,
|
||||
Label,
|
||||
} from '@memoh/ui'
|
||||
import z from 'zod'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ref, inject, watch } from 'vue'
|
||||
import { type MCPListItem as MCPType } from '@/composables/api/useMcp'
|
||||
import { useKeyValueTags } from '@/composables/useKeyValueTags'
|
||||
import { useCreateOrUpdateMcp } from '@/composables/api/useMcp'
|
||||
|
||||
const envTags = useKeyValueTags()
|
||||
|
||||
const validateSchema = toTypedSchema(z.object({
|
||||
name: z.string().min(1),
|
||||
config: z.object({
|
||||
type: z.string().min(1),
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.coerce.string().check(z.minLength(1))).min(1),
|
||||
env: z.looseObject({}),
|
||||
cwd: z.string().min(1),
|
||||
}),
|
||||
active: z.coerce.boolean(),
|
||||
}))
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: validateSchema,
|
||||
})
|
||||
|
||||
const { mutate: fetchMCP } = useCreateOrUpdateMcp()
|
||||
|
||||
const open = inject('open', ref(false))
|
||||
const mcpEditData = inject('mcpEditData', ref<{
|
||||
name: string
|
||||
config: MCPType['config']
|
||||
active: boolean
|
||||
id: string
|
||||
} | null>(null))
|
||||
|
||||
watch(open, () => {
|
||||
if (open.value && mcpEditData.value) {
|
||||
form.setValues(mcpEditData.value)
|
||||
envTags.initFromObject(mcpEditData.value.config?.env as Record<string, string>)
|
||||
}
|
||||
if (!open.value) {
|
||||
mcpEditData.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const createMCP = form.handleSubmit(async (value) => {
|
||||
try {
|
||||
await fetchMCP({ ...value, id: mcpEditData.value?.id })
|
||||
open.value = false
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,70 +0,0 @@
|
||||
import { client } from '@memoh/sdk/client'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export interface MCPListItem {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
config: {
|
||||
cwd: string
|
||||
env: Record<string, string>
|
||||
args: string[]
|
||||
type: string
|
||||
command: string
|
||||
}
|
||||
active: boolean
|
||||
user: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateMcpRequest {
|
||||
name: string
|
||||
config: Record<string, unknown>
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface UpdateMcpRequest extends CreateMcpRequest {
|
||||
id?: string
|
||||
}
|
||||
|
||||
// ---- Query: MCP list ----
|
||||
|
||||
export function useMcpList() {
|
||||
return useQuery({
|
||||
key: ['mcp'],
|
||||
query: async () => {
|
||||
const { data } = await client.get({
|
||||
url: '/mcp/',
|
||||
throwOnError: true,
|
||||
}) as { data: { items: MCPListItem[] } }
|
||||
return data.items
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Mutations ----
|
||||
|
||||
export function useCreateOrUpdateMcp() {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (data: UpdateMcpRequest) => {
|
||||
const isEdit = !!data.id
|
||||
return isEdit
|
||||
? client.put({ url: `/mcp/${data.id}`, body: data, throwOnError: true })
|
||||
: client.post({ url: '/mcp/', body: data, throwOnError: true })
|
||||
},
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['mcp'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteMcp() {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (id: string) =>
|
||||
client.delete({ url: `/mcp/${id}`, throwOnError: true }),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['mcp'] }),
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* TagsInput key:value two-way conversion for add-platform config and create-mcp env.
|
||||
* TagsInput key:value two-way conversion for platform config and MCP env/headers.
|
||||
* Input: string[] of "key:value"; output: Record<string, string> via callback.
|
||||
*/
|
||||
export function useKeyValueTags() {
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
"loading": "Loading...",
|
||||
"operation": "Actions",
|
||||
"enable": "Enable",
|
||||
"optional": "optional"
|
||||
"optional": "optional",
|
||||
"import": "Import",
|
||||
"export": "Export",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied to clipboard"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Welcome Back",
|
||||
@@ -167,7 +171,19 @@
|
||||
"cwd": "Working Directory",
|
||||
"arguments": "Arguments",
|
||||
"env": "Environment"
|
||||
}
|
||||
},
|
||||
"empty": "No MCP servers configured yet.",
|
||||
"deleteConfirm": "Are you sure you want to delete this MCP server?",
|
||||
"loadFailed": "Failed to load MCP servers",
|
||||
"saveFailed": "Failed to save MCP server",
|
||||
"createSuccess": "MCP server created",
|
||||
"updateSuccess": "MCP server updated",
|
||||
"deleteSuccess": "MCP server deleted",
|
||||
"deleteFailed": "Failed to delete MCP server",
|
||||
"importHint": "Paste a standard mcpServers JSON configuration. Existing servers with the same name will be updated.",
|
||||
"importSuccess": "MCP servers imported",
|
||||
"importFailed": "Failed to import MCP servers",
|
||||
"exportFailed": "Failed to export MCP servers"
|
||||
},
|
||||
"home": {
|
||||
"title": "Home"
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
"loading": "加载中...",
|
||||
"operation": "操作",
|
||||
"enable": "启用",
|
||||
"optional": "可选"
|
||||
"optional": "可选",
|
||||
"import": "导入",
|
||||
"export": "导出",
|
||||
"copy": "复制",
|
||||
"copied": "已复制到剪贴板"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "欢迎回来",
|
||||
@@ -167,7 +171,19 @@
|
||||
"cwd": "工作目录",
|
||||
"arguments": "参数",
|
||||
"env": "环境变量"
|
||||
}
|
||||
},
|
||||
"empty": "暂未配置 MCP 服务器。",
|
||||
"deleteConfirm": "确定要删除这个 MCP 服务器吗?",
|
||||
"loadFailed": "加载 MCP 服务器列表失败",
|
||||
"saveFailed": "保存 MCP 服务器失败",
|
||||
"createSuccess": "MCP 服务器已创建",
|
||||
"updateSuccess": "MCP 服务器已更新",
|
||||
"deleteSuccess": "MCP 服务器已删除",
|
||||
"deleteFailed": "删除 MCP 服务器失败",
|
||||
"importHint": "粘贴标准 mcpServers JSON 配置。同名的服务器会被更新配置。",
|
||||
"importSuccess": "MCP 服务器已导入",
|
||||
"importFailed": "导入 MCP 服务器失败",
|
||||
"exportFailed": "导出 MCP 服务器失败"
|
||||
},
|
||||
"home": {
|
||||
"title": "首页"
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ $t('mcp.addTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('mcp.addDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 shrink-0 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@click="loadList"
|
||||
>
|
||||
<Spinner
|
||||
v-if="loading"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('bots.container.actions.refresh') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="handleExport"
|
||||
>
|
||||
{{ $t('common.export') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="importDialogOpen = true"
|
||||
>
|
||||
{{ $t('common.import') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
{{ $t('common.add') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-if="loading && items.length === 0"
|
||||
class="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Spinner />
|
||||
<span>{{ $t('common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
class="rounded-md border p-4"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('mcp.empty') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<DataTable
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
/>
|
||||
|
||||
<!-- Create/Edit dialog -->
|
||||
<Dialog v-model:open="formDialogOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ editingItem ? $t('common.edit') : $t('common.add') }} MCP Server</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('mcp.name') }}</Label>
|
||||
<Input
|
||||
v-model="formData.name"
|
||||
:placeholder="$t('mcp.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('mcp.command') }} <span class="text-muted-foreground text-xs">({{ $t('common.optional') }})</span></Label>
|
||||
<Input
|
||||
v-model="formData.command"
|
||||
:placeholder="$t('mcp.commandPlaceholder')"
|
||||
:disabled="!!formData.url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>URL <span class="text-muted-foreground text-xs">({{ $t('common.optional') }})</span></Label>
|
||||
<Input
|
||||
v-model="formData.url"
|
||||
placeholder="https://example.com/mcp"
|
||||
:disabled="!!formData.command"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formData.command"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label>{{ $t('mcp.arguments') }}</Label>
|
||||
<TagsInput
|
||||
v-model="argsTags"
|
||||
:add-on-blur="true"
|
||||
:duplicate="true"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="item in argsTags"
|
||||
:key="item"
|
||||
:value="item"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
:placeholder="$t('mcp.argumentsPlaceholder')"
|
||||
class="w-full py-1"
|
||||
/>
|
||||
</TagsInput>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formData.command"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label>{{ $t('mcp.env') }}</Label>
|
||||
<TagsInput
|
||||
:model-value="envTags.tagList.value"
|
||||
:add-on-blur="true"
|
||||
:convert-value="envTags.convertValue"
|
||||
@update:model-value="(tags) => envTags.handleUpdate(tags.map(String))"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="(value, index) in envTags.tagList.value"
|
||||
:key="index"
|
||||
:value="value"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
:placeholder="$t('mcp.envPlaceholder')"
|
||||
class="w-full py-1"
|
||||
/>
|
||||
</TagsInput>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formData.command"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label>{{ $t('mcp.cwd') }}</Label>
|
||||
<Input
|
||||
v-model="formData.cwd"
|
||||
:placeholder="$t('mcp.cwdPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formData.url"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label>Headers</Label>
|
||||
<TagsInput
|
||||
:model-value="headerTags.tagList.value"
|
||||
:add-on-blur="true"
|
||||
:convert-value="headerTags.convertValue"
|
||||
@update:model-value="(tags) => headerTags.handleUpdate(tags.map(String))"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="(value, index) in headerTags.tagList.value"
|
||||
:key="index"
|
||||
:value="value"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
<TagsInputInput
|
||||
placeholder="Key:Value"
|
||||
class="w-full py-1"
|
||||
/>
|
||||
</TagsInput>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formData.url && !formData.command"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label>Transport</Label>
|
||||
<Select v-model="formData.transport">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="http" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="http">
|
||||
HTTP (Streamable)
|
||||
</SelectItem>
|
||||
<SelectItem value="sse">
|
||||
SSE
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>{{ $t('mcp.active') }}</Label>
|
||||
<Switch v-model:checked="formData.active" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<DialogClose as-child>
|
||||
<Button variant="outline">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="submitting || !formData.name.trim() || (!formData.command.trim() && !formData.url.trim())"
|
||||
>
|
||||
<Spinner
|
||||
v-if="submitting"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('common.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Import dialog -->
|
||||
<Dialog v-model:open="importDialogOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('common.import') }} mcpServers</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="mt-4 space-y-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('mcp.importHint') }}
|
||||
</p>
|
||||
<Textarea
|
||||
v-model="importJson"
|
||||
rows="10"
|
||||
class="font-mono text-xs"
|
||||
placeholder='{ "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"] } } }'
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter class="mt-4">
|
||||
<DialogClose as-child>
|
||||
<Button variant="outline">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
:disabled="importSubmitting || !importJson.trim()"
|
||||
@click="handleImport"
|
||||
>
|
||||
<Spinner
|
||||
v-if="importSubmitting"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('common.import') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Export dialog -->
|
||||
<Dialog v-model:open="exportDialogOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('common.export') }} mcpServers</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="mt-4">
|
||||
<Textarea
|
||||
:model-value="exportJson"
|
||||
rows="10"
|
||||
class="font-mono text-xs"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter class="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleCopyExport"
|
||||
>
|
||||
{{ $t('common.copy') }}
|
||||
</Button>
|
||||
<DialogClose as-child>
|
||||
<Button>
|
||||
{{ $t('common.confirm') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { type ColumnDef } from '@tanstack/vue-table'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Spinner,
|
||||
Switch,
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText,
|
||||
Textarea,
|
||||
} from '@memoh/ui'
|
||||
import DataTable from '@/components/data-table/index.vue'
|
||||
import { useKeyValueTags } from '@/composables/useKeyValueTags'
|
||||
import { client } from '@memoh/sdk/client'
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
|
||||
interface McpItem {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
config: Record<string, unknown>
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{ botId: string }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<McpItem[]>([])
|
||||
const formDialogOpen = ref(false)
|
||||
const editingItem = ref<McpItem | null>(null)
|
||||
const submitting = ref(false)
|
||||
const importDialogOpen = ref(false)
|
||||
const importJson = ref('')
|
||||
const importSubmitting = ref(false)
|
||||
const exportDialogOpen = ref(false)
|
||||
const exportJson = ref('')
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
command: '',
|
||||
url: '',
|
||||
cwd: '',
|
||||
transport: 'http',
|
||||
active: true,
|
||||
})
|
||||
const argsTags = ref<string[]>([])
|
||||
const envTags = useKeyValueTags()
|
||||
const headerTags = useKeyValueTags()
|
||||
|
||||
function configValue(config: Record<string, unknown>, key: string): string {
|
||||
const val = config?.[key]
|
||||
return typeof val === 'string' ? val : ''
|
||||
}
|
||||
|
||||
function configArray(config: Record<string, unknown>, key: string): string[] {
|
||||
const val = config?.[key]
|
||||
if (Array.isArray(val)) return val.map(String)
|
||||
return []
|
||||
}
|
||||
|
||||
function configMap(config: Record<string, unknown>, key: string): Record<string, string> {
|
||||
const val = config?.[key]
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(val)) {
|
||||
out[k] = String(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<McpItem>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => h('div', { class: 'text-left py-4' }, t('mcp.table.name')),
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: () => h('div', { class: 'text-left' }, t('mcp.table.type')),
|
||||
cell: ({ row }) => h(Badge, { variant: 'outline' }, () => row.original.type),
|
||||
},
|
||||
{
|
||||
id: 'target',
|
||||
header: () => h('div', { class: 'text-left' }, 'Command / URL'),
|
||||
cell: ({ row }) => {
|
||||
const cfg = row.original.config ?? {}
|
||||
return h('span', { class: 'font-mono text-xs' },
|
||||
configValue(cfg, 'command') || configValue(cfg, 'url') || '-',
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: () => h('div', { class: 'text-center' }, t('mcp.active')),
|
||||
cell: ({ row }) => h('div', { class: 'text-center' },
|
||||
h(Badge, { variant: row.original.is_active ? 'default' : 'secondary' },
|
||||
() => row.original.is_active ? 'ON' : 'OFF'),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => h('div', { class: 'text-center' }, t('common.operation')),
|
||||
cell: ({ row }) => h('div', { class: 'flex gap-2 justify-center' }, [
|
||||
h(Button, {
|
||||
size: 'sm',
|
||||
variant: 'outline',
|
||||
onClick: () => openEditDialog(row.original),
|
||||
}, () => t('common.edit')),
|
||||
h(ConfirmPopover, {
|
||||
message: t('mcp.deleteConfirm'),
|
||||
onConfirm: () => handleDelete(row.original.id),
|
||||
}, {
|
||||
trigger: () => h(Button, {
|
||||
size: 'sm',
|
||||
variant: 'destructive',
|
||||
}, () => t('common.delete')),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await client.get({
|
||||
url: `/bots/${props.botId}/mcp`,
|
||||
throwOnError: true,
|
||||
}) as { data: { items: McpItem[] } }
|
||||
items.value = data.items ?? []
|
||||
} catch (error) {
|
||||
toast.error(resolveError(error, t('mcp.loadFailed')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editingItem.value = null
|
||||
formData.value = { name: '', command: '', url: '', cwd: '', transport: 'http', active: true }
|
||||
argsTags.value = []
|
||||
envTags.initFromObject(null)
|
||||
headerTags.initFromObject(null)
|
||||
formDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(item: McpItem) {
|
||||
editingItem.value = item
|
||||
const cfg = item.config ?? {}
|
||||
formData.value = {
|
||||
name: item.name,
|
||||
command: configValue(cfg, 'command'),
|
||||
url: configValue(cfg, 'url'),
|
||||
cwd: configValue(cfg, 'cwd'),
|
||||
transport: item.type === 'sse' ? 'sse' : 'http',
|
||||
active: item.is_active,
|
||||
}
|
||||
argsTags.value = configArray(cfg, 'args')
|
||||
envTags.initFromObject(configMap(cfg, 'env'))
|
||||
headerTags.initFromObject(configMap(cfg, 'headers'))
|
||||
formDialogOpen.value = true
|
||||
}
|
||||
|
||||
function buildRequestBody() {
|
||||
const body: Record<string, unknown> = {
|
||||
name: formData.value.name.trim(),
|
||||
is_active: formData.value.active,
|
||||
}
|
||||
if (formData.value.command.trim()) {
|
||||
body.command = formData.value.command.trim()
|
||||
if (argsTags.value.length > 0) body.args = argsTags.value
|
||||
const env: Record<string, string> = {}
|
||||
envTags.tagList.value.forEach((tag) => {
|
||||
const [k, v] = tag.split(':')
|
||||
if (k && v) env[k] = v
|
||||
})
|
||||
if (Object.keys(env).length > 0) body.env = env
|
||||
if (formData.value.cwd.trim()) body.cwd = formData.value.cwd.trim()
|
||||
} else if (formData.value.url.trim()) {
|
||||
body.url = formData.value.url.trim()
|
||||
const headers: Record<string, string> = {}
|
||||
headerTags.tagList.value.forEach((tag) => {
|
||||
const [k, v] = tag.split(':')
|
||||
if (k && v) headers[k] = v
|
||||
})
|
||||
if (Object.keys(headers).length > 0) body.headers = headers
|
||||
if (formData.value.transport === 'sse') body.transport = 'sse'
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const body = buildRequestBody()
|
||||
if (editingItem.value) {
|
||||
await client.put({
|
||||
url: `/bots/${props.botId}/mcp/${editingItem.value.id}`,
|
||||
body,
|
||||
throwOnError: true,
|
||||
})
|
||||
} else {
|
||||
await client.post({
|
||||
url: `/bots/${props.botId}/mcp`,
|
||||
body,
|
||||
throwOnError: true,
|
||||
})
|
||||
}
|
||||
formDialogOpen.value = false
|
||||
await loadList()
|
||||
toast.success(editingItem.value ? t('mcp.updateSuccess') : t('mcp.createSuccess'))
|
||||
} catch (error) {
|
||||
toast.error(resolveError(error, t('mcp.saveFailed')))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await client.delete({
|
||||
url: `/bots/${props.botId}/mcp/${id}`,
|
||||
throwOnError: true,
|
||||
})
|
||||
await loadList()
|
||||
toast.success(t('mcp.deleteSuccess'))
|
||||
} catch (error) {
|
||||
toast.error(resolveError(error, t('mcp.deleteFailed')))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
importSubmitting.value = true
|
||||
try {
|
||||
let parsed = JSON.parse(importJson.value)
|
||||
if (!parsed.mcpServers && typeof parsed === 'object') {
|
||||
parsed = { mcpServers: parsed }
|
||||
}
|
||||
await client.put({
|
||||
url: `/bots/${props.botId}/mcp/import`,
|
||||
body: parsed,
|
||||
throwOnError: true,
|
||||
})
|
||||
importDialogOpen.value = false
|
||||
importJson.value = ''
|
||||
await loadList()
|
||||
toast.success(t('mcp.importSuccess'))
|
||||
} catch (error) {
|
||||
toast.error(resolveError(error, t('mcp.importFailed')))
|
||||
} finally {
|
||||
importSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const { data } = await client.get({
|
||||
url: `/bots/${props.botId}/mcp/export`,
|
||||
throwOnError: true,
|
||||
}) as { data: { mcpServers: Record<string, unknown> } }
|
||||
exportJson.value = JSON.stringify(data, null, 2)
|
||||
exportDialogOpen.value = true
|
||||
} catch (error) {
|
||||
toast.error(resolveError(error, t('mcp.exportFailed')))
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyExport() {
|
||||
navigator.clipboard.writeText(exportJson.value)
|
||||
toast.success(t('common.copied'))
|
||||
}
|
||||
|
||||
function resolveError(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message.trim()) return error.message
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const msg = (error as { message?: string }).message
|
||||
if (msg?.trim()) return msg
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
watch(() => props.botId, () => {
|
||||
if (props.botId) loadList()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -434,7 +434,7 @@
|
||||
value="mcp"
|
||||
class="mt-6"
|
||||
>
|
||||
<!-- TODO: MCP content -->
|
||||
<BotMcp :bot-id="botId" />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="subagents"
|
||||
@@ -550,6 +550,7 @@ import type {
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
import BotSettings from './components/bot-settings.vue'
|
||||
import BotChannels from './components/bot-channels.vue'
|
||||
import BotMcp from './components/bot-mcp.vue'
|
||||
|
||||
type BotCheck = BotsBotCheck
|
||||
type BotContainerInfo = HandlersGetContainerResponse
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<section class="[&_td:last-child]:w-40">
|
||||
<CreateMCP />
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="mcpFormatData"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, provide, ref, computed } from 'vue'
|
||||
import DataTable from '@/components/data-table/index.vue'
|
||||
import CreateMCP from '@/components/create-mcp/index.vue'
|
||||
import { type ColumnDef } from '@tanstack/vue-table'
|
||||
import {
|
||||
Badge,
|
||||
Button
|
||||
} from '@memoh/ui'
|
||||
import { type MCPListItem as MCPType } from '@/composables/api/useMcp'
|
||||
import { useMcpList, useDeleteMcp } from '@/composables/api/useMcp'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const open = ref(false)
|
||||
const editMCPData = ref<{
|
||||
name: string
|
||||
config: MCPType['config']
|
||||
active: boolean
|
||||
id: string
|
||||
} | null>(null)
|
||||
provide('open', open)
|
||||
provide('mcpEditData', editMCPData)
|
||||
|
||||
const { mutate: DeleteMCP } = useDeleteMcp()
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns:ColumnDef<MCPType>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => h('div', { class: 'text-left py-4' }, t('mcp.table.name')),
|
||||
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: () => h('div', { class: 'text-left' }, t('mcp.table.type')),
|
||||
},
|
||||
{
|
||||
accessorKey: 'config.command',
|
||||
header: () => h('div', { class: 'text-left' }, t('mcp.table.command')),
|
||||
},
|
||||
{
|
||||
accessorKey: 'config.cwd',
|
||||
header: () => h('div', { class: 'text-left' }, t('mcp.table.cwd')),
|
||||
},
|
||||
{
|
||||
accessorKey: 'config.args',
|
||||
header: () => h('div', { class: 'text-left' }, t('mcp.table.arguments')),
|
||||
cell: ({ row }) => h('div', {class:'flex gap-4'}, row.original.config.args.map((argTxt) => {
|
||||
return h(Badge, {
|
||||
variant:'default'
|
||||
},()=>argTxt)
|
||||
}))
|
||||
},
|
||||
{
|
||||
accessorKey: 'config.env',
|
||||
header: () => h('div', { class: 'text-left' }, t('mcp.table.env')),
|
||||
cell: ({ row }) => h('div', { class: 'flex gap-4' }, Object.entries(row.original.config.env).map(([key,value]) => {
|
||||
return h(Badge, {
|
||||
variant: 'outline'
|
||||
}, ()=>`${key}:${value}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
accessorKey: 'control',
|
||||
header: () => h('div', { class: 'text-center' }, t('common.operation')),
|
||||
cell: ({ row }) => h('div', {class:'flex gap-2'}, [
|
||||
h(Button, {
|
||||
onClick() {
|
||||
editMCPData.value = {
|
||||
name: row.original.name,
|
||||
config: {...row.original.config},
|
||||
active: row.original.active,
|
||||
id:row.original.id
|
||||
}
|
||||
open.value=true
|
||||
}
|
||||
}, ()=>t('common.edit')),
|
||||
h(Button, {
|
||||
variant: 'destructive',
|
||||
async onClick() {
|
||||
try {
|
||||
await DeleteMCP(row.original.id)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
},()=>t('common.delete'))
|
||||
])
|
||||
}
|
||||
]
|
||||
|
||||
const { data: mcpData } = useMcpList()
|
||||
|
||||
const mcpFormatData = computed(() => mcpData.value ?? [])
|
||||
|
||||
</script>
|
||||
@@ -75,14 +75,6 @@ const routes = [
|
||||
breadcrumb: i18nRef('settings.user'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mcp',
|
||||
path: '/mcp',
|
||||
component: () => import('@/pages/mcp/index.vue'),
|
||||
meta: {
|
||||
breadcrumb: i18nRef('sidebar.mcp'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'platform',
|
||||
path: '/platform',
|
||||
|
||||
Reference in New Issue
Block a user