feat(web): parallel health checks and MCP form UX improvements

- Bot overview: fetch check keys first, then parallel-request each key
  independently with per-item loading spinner
- Stable check ordering via key-indexed placeholder array
- MCP form: replace flat command/url fields with Stdio/Remote tab
  switcher for clear mode selection
- MCP type labels: "Stdio (Local)" / "Remote (HTTP/SSE)"
This commit is contained in:
BBQ
2026-02-13 02:43:31 +08:00
parent 76dbae2844
commit 2614763547
5 changed files with 545 additions and 199 deletions
+1 -1
View File
@@ -168,7 +168,7 @@
"cwdPlaceholder": "Enter working directory path",
"env": "Environment",
"envPlaceholder": "Format: KEY:VALUE",
"active": "Enable Now",
"active": "Enabled",
"types": {
"stdio": "Stdio (Local)",
"remote": "Remote (HTTP/SSE)"
+1 -1
View File
@@ -168,7 +168,7 @@
"cwdPlaceholder": "输入工作目录路径",
"env": "环境变量",
"envPlaceholder": "格式:KEY:VALUE",
"active": "立即启用",
"active": "启用",
"types": {
"stdio": "本地命令 (Stdio)",
"remote": "远程服务 (HTTP/SSE)"
+450 -173
View File
@@ -9,70 +9,62 @@
{{ $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('common.refresh') }}
</Button>
<Button
variant="outline"
size="sm"
@click="handleExport"
>
{{ $t('common.export') }}
</Button>
<Button
size="sm"
@click="openCreateDialog"
>
{{ $t('common.add') }}
</Button>
<div class="flex flex-wrap items-center gap-2 shrink-0 justify-end">
<template v-if="selectedIds.length === 0">
<Button
variant="outline"
size="sm"
:disabled="loading"
@click="loadList"
>
<Spinner
v-if="loading"
class="mr-1.5"
/>
{{ $t('common.refresh') }}
</Button>
<Button
size="sm"
@click="openCreateDialog"
>
{{ $t('common.add') }}
</Button>
</template>
<template v-else>
<span class="text-sm text-muted-foreground mr-1">
{{ $t('common.batchSelected', { count: selectedIds.length }) }}
</span>
<Button
variant="ghost"
size="sm"
@click="clearSelection"
>
{{ $t('common.cancelSelection') }}
</Button>
<Button
variant="outline"
size="sm"
@click="handleBatchExport"
>
{{ $t('common.export') }}
</Button>
<ConfirmPopover
:message="$t('common.batchDeleteConfirm', { count: selectedIds.length })"
@confirm="handleBatchDelete"
>
<template #trigger>
<Button
variant="destructive"
size="sm"
>
{{ $t('common.delete') }}
</Button>
</template>
</ConfirmPopover>
</template>
</div>
</div>
<!-- Batch bar -->
<div
v-if="selectedIds.length > 0"
class="flex items-center gap-3 rounded-md border bg-muted/50 px-3 py-2"
>
<span class="text-sm text-muted-foreground">
{{ $t('common.batchSelected', { count: selectedIds.length }) }}
</span>
<Button
variant="outline"
size="sm"
@click="handleBatchExport"
>
{{ $t('common.batchExport') }}
</Button>
<ConfirmPopover
:message="$t('common.batchDeleteConfirm', { count: selectedIds.length })"
@confirm="handleBatchDelete"
>
<Button
variant="destructive"
size="sm"
>
{{ $t('common.batchDelete') }}
</Button>
</ConfirmPopover>
<Button
variant="ghost"
size="sm"
@click="clearSelection"
>
{{ $t('common.cancelSelection') }}
</Button>
</div>
<!-- Loading -->
<div
v-if="loading && items.length === 0"
@@ -99,38 +91,18 @@
:data="items"
/>
<!-- Add/Edit dialog: Single form + Import tab with bidirectional sync -->
<!-- Add dialog: tabs (single | import). Edit dialog: two columns (form | json) with sync -->
<Dialog v-model:open="formDialogOpen">
<DialogContent class="sm:max-w-lg">
<DialogContent :class="editingItem ? 'sm:max-w-4xl max-h-[90vh] flex flex-col w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto sm:max-w-full' : 'sm:max-w-[28rem] w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto'">
<DialogHeader>
<DialogTitle>{{ editingItem ? $t('common.edit') : $t('common.add') }} MCP Server</DialogTitle>
</DialogHeader>
<Tabs
v-model="addDialogTab"
class="mt-4 w-full"
>
<TabsList class="w-full">
<TabsTrigger
value="single"
:disabled="!!editingItem"
>
{{ $t('common.tabAddSingle') }}
</TabsTrigger>
<TabsTrigger
value="import"
:disabled="!!editingItem"
>
{{ $t('common.tabImportJson') }}
</TabsTrigger>
</TabsList>
<TabsContent
value="single"
class="mt-3"
>
<!-- Edit: two columns on desktop, stacked on mobile -->
<template v-if="editingItem">
<div class="mt-3 flex flex-col md:grid md:grid-cols-2 gap-4 flex-1 min-h-0 overflow-y-auto">
<form
class="flex flex-col gap-3"
class="flex flex-col gap-3 min-h-0 rounded-lg border border-border bg-card p-3 md:bg-transparent md:border-0 md:p-0 md:rounded-none md:overflow-y-auto md:pr-2"
@submit.prevent="handleSubmit"
>
<div class="space-y-1.5">
@@ -138,9 +110,9 @@
<Input
v-model="formData.name"
:placeholder="$t('common.namePlaceholder')"
@update:model-value="syncFormToEditJson"
/>
</div>
<Tabs
v-model="connectionMode"
class="w-full"
@@ -153,7 +125,6 @@
{{ $t('mcp.types.remote') }}
</TabsTrigger>
</TabsList>
<TabsContent
value="stdio"
class="mt-3 flex flex-col gap-3"
@@ -163,15 +134,16 @@
<Input
v-model="formData.command"
:placeholder="$t('mcp.commandPlaceholder')"
@update:model-value="syncFormToEditJson"
/>
</div>
<div class="space-y-1.5">
<Label>{{ $t('mcp.arguments') }}</Label>
<TagsInput
v-model="argsTags"
:add-on-blur="true"
:duplicate="true"
@update:model-value="syncFormToEditJson"
>
<TagsInputItem
v-for="item in argsTags"
@@ -187,14 +159,13 @@
/>
</TagsInput>
</div>
<div 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))"
@update:model-value="(tags) => { envTags.handleUpdate(tags.map(String)); syncFormToEditJson() }"
>
<TagsInputItem
v-for="(value, index) in envTags.tagList.value"
@@ -210,16 +181,15 @@
/>
</TagsInput>
</div>
<div class="space-y-1.5">
<Label>{{ $t('mcp.cwd') }}</Label>
<Input
v-model="formData.cwd"
:placeholder="$t('mcp.cwdPlaceholder')"
@update:model-value="syncFormToEditJson"
/>
</div>
</TabsContent>
<TabsContent
value="remote"
class="mt-3 flex flex-col gap-3"
@@ -229,16 +199,16 @@
<Input
v-model="formData.url"
placeholder="https://example.com/mcp"
@update:model-value="syncFormToEditJson"
/>
</div>
<div 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))"
@update:model-value="(tags) => { headerTags.handleUpdate(tags.map(String)); syncFormToEditJson() }"
>
<TagsInputItem
v-for="(value, index) in headerTags.tagList.value"
@@ -254,10 +224,12 @@
/>
</TagsInput>
</div>
<div class="space-y-1.5">
<Label>Transport</Label>
<Select v-model="formData.transport">
<Select
v-model="formData.transport"
@update:model-value="syncFormToEditJson"
>
<SelectTrigger class="w-full">
<SelectValue placeholder="http" />
</SelectTrigger>
@@ -275,12 +247,243 @@
</div>
</TabsContent>
</Tabs>
</form>
<div class="flex flex-col min-h-0 rounded-lg border border-border bg-card p-3 md:bg-transparent md:border-0 md:p-0 md:rounded-none">
<Label class="text-sm mb-1">JSON</Label>
<Textarea
v-model="editJson"
class="font-mono text-xs flex-1 min-h-[180px] md:min-h-[200px]"
@update:model-value="syncEditJsonToForm"
/>
</div>
</div>
<DialogFooter class="mt-4 flex-shrink-0 flex-row flex-wrap items-center gap-2 sm:justify-between">
<div class="flex items-center gap-2">
<Label class="text-sm font-normal">{{ $t('mcp.active') }}</Label>
<Switch
:model-value="formData.active"
@update:model-value="(val) => (formData.active = !!val)"
/>
</div>
<div class="flex gap-2">
<DialogClose as-child>
<Button variant="outline">
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
:disabled="submitting || !formData.name.trim() || (connectionMode === 'stdio' ? !formData.command.trim() : !formData.url.trim())"
@click="handleSubmit"
>
<Spinner
v-if="submitting"
class="mr-1.5"
/>
{{ $t('common.confirm') }}
</Button>
</div>
</DialogFooter>
</template>
<div class="flex items-center gap-3">
<Label>{{ $t('mcp.active') }}</Label>
<Switch v-model:checked="formData.active" />
</div>
<!-- Add: tabs single | import -->
<template v-else>
<Tabs
v-model="addDialogTab"
class="mt-4 w-full"
>
<TabsList class="w-full">
<TabsTrigger value="single">
{{ $t('common.tabAddSingle') }}
</TabsTrigger>
<TabsTrigger value="import">
{{ $t('common.tabImportJson') }}
</TabsTrigger>
</TabsList>
<TabsContent
value="single"
class="mt-3"
>
<form
class="flex flex-col gap-3"
@submit.prevent="handleSubmit"
>
<div class="space-y-1.5">
<Label>{{ $t('common.name') }}</Label>
<Input
v-model="formData.name"
:placeholder="$t('common.namePlaceholder')"
/>
</div>
<Tabs
v-model="connectionMode"
class="w-full"
>
<TabsList class="w-full">
<TabsTrigger value="stdio">
{{ $t('mcp.types.stdio') }}
</TabsTrigger>
<TabsTrigger value="remote">
{{ $t('mcp.types.remote') }}
</TabsTrigger>
</TabsList>
<TabsContent
value="stdio"
class="mt-3 flex flex-col gap-3"
>
<div class="space-y-1.5">
<Label>{{ $t('mcp.command') }}</Label>
<Input
v-model="formData.command"
:placeholder="$t('mcp.commandPlaceholder')"
/>
</div>
<div 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 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 class="space-y-1.5">
<Label>{{ $t('mcp.cwd') }}</Label>
<Input
v-model="formData.cwd"
:placeholder="$t('mcp.cwdPlaceholder')"
/>
</div>
</TabsContent>
<TabsContent
value="remote"
class="mt-3 flex flex-col gap-3"
>
<div class="space-y-1.5">
<Label>URL</Label>
<Input
v-model="formData.url"
placeholder="https://example.com/mcp"
/>
</div>
<div 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 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>
</TabsContent>
</Tabs>
<DialogFooter class="mt-4 flex-row flex-wrap items-center gap-2 sm:justify-between">
<div class="flex items-center gap-2">
<Label class="text-sm font-normal">{{ $t('mcp.active') }}</Label>
<Switch
:model-value="formData.active"
@update:model-value="(val) => (formData.active = !!val)"
/>
</div>
<div class="flex gap-2">
<DialogClose as-child>
<Button variant="outline">
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
type="submit"
:disabled="submitting || !formData.name.trim() || (connectionMode === 'stdio' ? !formData.command.trim() : !formData.url.trim())"
>
<Spinner
v-if="submitting"
class="mr-1.5"
/>
{{ $t('common.confirm') }}
</Button>
</div>
</DialogFooter>
</form>
</TabsContent>
<TabsContent
value="import"
class="mt-3 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="importJsonPlaceholder"
/>
<DialogFooter class="mt-4">
<DialogClose as-child>
<Button variant="outline">
@@ -288,59 +491,25 @@
</Button>
</DialogClose>
<Button
type="submit"
:disabled="submitting || !formData.name.trim() || (connectionMode === 'stdio' ? !formData.command.trim() : !formData.url.trim())"
:disabled="importSubmitting || !importJson.trim()"
@click="handleImport"
>
<Spinner
v-if="submitting"
v-if="importSubmitting"
class="mr-1.5"
/>
{{ $t('common.confirm') }}
{{ $t('common.import') }}
</Button>
</DialogFooter>
</form>
</TabsContent>
<TabsContent
value="import"
class="mt-3 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="importJsonPlaceholder"
/>
<div class="flex flex-wrap gap-2">
<Button
:disabled="importSubmitting || !importJson.trim()"
@click="handleImport"
>
<Spinner
v-if="importSubmitting"
class="mr-1.5"
/>
{{ $t('common.import') }}
</Button>
</div>
<DialogFooter class="mt-4">
<DialogClose as-child>
<Button variant="outline">
{{ $t('common.cancel') }}
</Button>
</DialogClose>
</DialogFooter>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</template>
</DialogContent>
</Dialog>
<!-- Export dialog -->
<Dialog v-model:open="exportDialogOpen">
<DialogContent class="sm:max-w-lg">
<DialogContent class="sm:max-w-lg w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto">
<DialogHeader>
<DialogTitle>{{ $t('common.export') }} mcpServers</DialogTitle>
</DialogHeader>
@@ -378,7 +547,6 @@ import { type ColumnDef } from '@tanstack/vue-table'
import {
Badge,
Button,
Checkbox,
Dialog,
DialogClose,
DialogContent,
@@ -442,7 +610,7 @@ const importJsonPlaceholder = `{
"mcpServers": {
"hello": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-hello-world"]
"args": ["-y", "mcp-hello-world"]
}
}
}`
@@ -463,6 +631,10 @@ const formData = ref({
active: true,
})
// Edit dialog: JSON panel synced with formData
const editJson = ref('')
let editSyncFromJson = false
watch(connectionMode, (mode) => {
if (mode === 'stdio') {
formData.value.url = ''
@@ -545,19 +717,28 @@ const columns = computed<ColumnDef<McpItem>[]>(() => [
id: 'select',
header: () =>
h('div', { class: 'flex items-center justify-center py-4' }, [
h(Checkbox, {
h('input', {
type: 'checkbox',
class: 'size-4 cursor-pointer rounded border border-input',
checked: isAllSelected.value,
'onUpdate:checked': (v: boolean | 'indeterminate') => toggleSelectAll(v === true),
onChange: (e: Event) => {
toggleSelectAll((e.target as HTMLInputElement).checked)
},
}),
]),
cell: ({ row }) =>
h('div', { class: 'flex justify-center' }, [
h(Checkbox, {
checked: selectedIds.value.includes(row.original.id),
'onUpdate:checked': (v: boolean | 'indeterminate') =>
toggleSelection(row.original.id, v === true),
cell: ({ row }) => {
const id = row.original.id
return h('div', { class: 'flex justify-center' }, [
h('input', {
type: 'checkbox',
class: 'size-4 cursor-pointer rounded border border-input',
checked: selectedIds.value.includes(id),
onChange: (e: Event) => {
toggleSelection(id, (e.target as HTMLInputElement).checked)
},
}),
]),
])
},
},
{
accessorKey: 'name',
@@ -573,9 +754,17 @@ const columns = computed<ColumnDef<McpItem>[]>(() => [
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') || '-',
)
const cmd = configValue(cfg, 'command')
const url = configValue(cfg, 'url')
const args = configArray(cfg, 'args')
const full =
cmd
? (args.length ? `${cmd} ${args.join(' ')}` : cmd)
: (url || '-')
return h('span', {
class: 'font-mono text-xs block max-w-[280px] truncate',
title: full,
}, full)
},
},
{
@@ -637,7 +826,6 @@ function openCreateDialog() {
function openEditDialog(item: McpItem) {
editingItem.value = item
addDialogTab.value = 'single'
const cfg = item.config ?? {}
connectionMode.value = item.type === 'stdio' ? 'stdio' : 'remote'
formData.value = {
@@ -646,14 +834,116 @@ function openEditDialog(item: McpItem) {
url: configValue(cfg, 'url'),
cwd: configValue(cfg, 'cwd'),
transport: item.type === 'sse' ? 'sse' : 'http',
active: item.is_active,
active: !!item.is_active,
}
argsTags.value = configArray(cfg, 'args')
envTags.initFromObject(configMap(cfg, 'env'))
headerTags.initFromObject(configMap(cfg, 'headers'))
editSyncFromJson = false
syncFormToEditJson()
formDialogOpen.value = true
}
function buildFormToEntry(): McpServerEntry | null {
const d = formData.value
const name = d.name.trim()
if (!name) return null
if (d.command.trim()) {
const entry: McpServerEntry = {
command: d.command.trim(),
args: argsTags.value.length ? argsTags.value : undefined,
cwd: d.cwd.trim() || undefined,
}
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) entry.env = env
return entry
}
if (d.url.trim()) {
const entry: McpServerEntry = {
url: d.url.trim(),
transport: d.transport === 'sse' ? 'sse' : undefined,
}
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) entry.headers = headers
return entry
}
return null
}
function syncFormToEditJson() {
if (editSyncFromJson) return
const entry = buildFormToEntry()
if (!entry) {
editJson.value = ''
return
}
const name = formData.value.name.trim()
const mcpServers: Record<string, McpServerEntry> = { [name]: entry }
editJson.value = JSON.stringify({ mcpServers }, null, 2)
}
function syncEditJsonToForm() {
const raw = editJson.value.trim()
if (!raw) return
editSyncFromJson = true
try {
let parsed: { mcpServers?: Record<string, McpServerEntry> } = JSON.parse(raw)
if (!parsed.mcpServers && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsed = { mcpServers: parsed as Record<string, McpServerEntry> }
}
const servers = parsed.mcpServers
if (!servers || typeof servers !== 'object') {
editSyncFromJson = false
return
}
const entries = Object.entries(servers)
const single = entries.length === 1 ? entries[0] : null
if (!single) {
editSyncFromJson = false
return
}
const [name, e] = single
if (e.command) {
connectionMode.value = 'stdio'
formData.value = {
name,
command: e.command ?? '',
url: '',
cwd: e.cwd ?? '',
transport: 'http',
active: formData.value.active,
}
argsTags.value = e.args ?? []
envTags.initFromObject(e.env ?? null)
headerTags.initFromObject(null)
} else if (e.url) {
connectionMode.value = 'remote'
formData.value = {
name,
command: '',
url: e.url ?? '',
cwd: '',
transport: e.transport === 'sse' ? 'sse' : 'http',
active: formData.value.active,
}
argsTags.value = []
envTags.initFromObject(null)
headerTags.initFromObject(e.headers ?? null)
}
} catch {
// ignore parse error
}
editSyncFromJson = false
}
function buildRequestBody() {
const body: Record<string, unknown> = {
name: formData.value.name.trim(),
@@ -773,19 +1063,6 @@ async function handleImport() {
}
}
async function handleExport() {
try {
const { data } = await client.get({
url: `/bots/${props.botId}/mcp-ops/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'))
@@ -57,16 +57,17 @@
/>
</div>
<!-- Allow Guest -->
<div class="flex items-center justify-between">
<Label>{{ $t('bots.settings.allowGuest') }}</Label>
<Switch
:model-value="form.allow_guest"
@update:model-value="(val) => form.allow_guest = !!val"
/>
</div>
<Separator />
<!-- Allow Guest: only for public bot -->
<template v-if="isPublicBot">
<div class="flex items-center justify-between">
<Label>{{ $t('bots.settings.allowGuest') }}</Label>
<Switch
:model-value="form.allow_guest"
@update:model-value="(val) => form.allow_guest = !!val"
/>
</div>
<Separator />
</template>
<!-- Save -->
<div class="flex justify-end">
@@ -135,8 +136,11 @@ import type { Ref } from 'vue'
const props = defineProps<{
botId: string
botType?: string
}>()
const isPublicBot = computed(() => props.botType === 'public')
const { t } = useI18n()
const router = useRouter()
@@ -220,14 +224,16 @@ watch(settings, (val) => {
const hasChanges = computed(() => {
if (!settings.value) return true
const s = settings.value
return (
let changed =
form.chat_model_id !== (s.chat_model_id ?? '')
|| form.memory_model_id !== (s.memory_model_id ?? '')
|| form.embedding_model_id !== (s.embedding_model_id ?? '')
|| form.max_context_load_time !== (s.max_context_load_time ?? 0)
|| form.language !== (s.language ?? '')
|| form.allow_guest !== (s.allow_guest ?? false)
)
if (isPublicBot.value) {
changed = changed || form.allow_guest !== (s.allow_guest ?? false)
}
return changed
})
async function handleSave() {
+74 -11
View File
@@ -191,20 +191,32 @@
>
<div class="flex items-center justify-between gap-2">
<p class="font-mono text-xs">{{ checkKeyLabel(item.check_key) }}</p>
<div v-if="isCheckLoading(item)">
<Spinner class="size-3.5" />
</div>
<Badge
v-else
:variant="checkStatusVariant(item.status)"
class="text-[10px]"
>
{{ checkStatusLabel(item.status) }}
</Badge>
</div>
<p class="mt-2 text-sm">{{ item.summary }}</p>
<p
v-if="item.detail"
class="mt-1 text-xs text-muted-foreground break-all"
v-if="isCheckLoading(item)"
class="mt-2 text-sm text-muted-foreground"
>
{{ item.detail }}
{{ $t('common.loading') }}
</p>
<template v-else>
<p class="mt-2 text-sm">{{ item.summary }}</p>
<p
v-if="item.detail"
class="mt-1 text-xs text-muted-foreground break-all"
>
{{ item.detail }}
</p>
</template>
</li>
</ul>
</div>
@@ -452,7 +464,10 @@
value="settings"
class="mt-6"
>
<BotSettings :bot-id="botId" />
<BotSettings
:bot-id="botId"
:bot-type="bot?.type"
/>
</TabsContent>
</Tabs>
@@ -538,11 +553,11 @@ import { useI18n } from 'vue-i18n'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import {
getBotsById, putBotsById,
getBotsByIdChecks,
getBotsByBotIdContainer, postBotsByBotIdContainer, deleteBotsByBotIdContainer,
postBotsByBotIdContainerStart, postBotsByBotIdContainerStop,
getBotsByBotIdContainerSnapshots, postBotsByBotIdContainerSnapshots,
} from '@memoh/sdk'
import { client } from '@memoh/sdk/client'
import type {
BotsBotCheck, HandlersGetContainerResponse,
HandlersListSnapshotsResponse,
@@ -581,9 +596,20 @@ const { mutateAsync: updateBot, isLoading: updateBotLoading } = useMutation({
},
})
async function fetchBotChecks(id: string): Promise<BotCheck[]> {
const { data } = await getBotsByIdChecks({ path: { id }, throwOnError: true })
return data.items ?? []
async function fetchCheckKeys(id: string): Promise<string[]> {
const { data } = await client.get({
url: `/bots/${id}/checks/keys`,
throwOnError: true,
}) as { data: { keys: string[] } }
return data.keys ?? []
}
async function fetchSingleCheck(id: string, key: string): Promise<BotCheck> {
const { data } = await client.get({
url: `/bots/${id}/checks/run/${key}`,
throwOnError: true,
}) as { data: BotCheck }
return data
}
// Replace breadcrumb bot id with display name when available.
@@ -830,6 +856,10 @@ function checkStatusLabel(status: BotCheck['status']): string {
return t('bots.checks.status.ok')
}
function isCheckLoading(item: BotCheck): boolean {
return item.status === 'unknown' && !item.summary
}
function checkKeyLabel(checkKey: string): string {
const key = checkKeyI18nKeys[checkKey]
if (!key) {
@@ -840,10 +870,43 @@ function checkKeyLabel(checkKey: string): string {
async function loadChecks(showToast: boolean) {
checksLoading.value = true
checks.value = []
try {
checks.value = await fetchBotChecks(botId.value)
const keys = await fetchCheckKeys(botId.value)
if (keys.length === 0) return
// Maintain key order: pre-fill placeholders, replace as results arrive.
const keyOrder = new Map(keys.map((k, i) => [k, i]))
checks.value = keys.map((key) => ({
check_key: key,
status: 'unknown' as BotCheck['status'],
summary: '',
}))
const pending = keys.map(async (key) => {
try {
const result = await fetchSingleCheck(botId.value, key)
const idx = keyOrder.get(key)
if (idx !== undefined) {
const updated = [...checks.value]
updated[idx] = result
checks.value = updated
}
} catch {
const idx = keyOrder.get(key)
if (idx !== undefined) {
const updated = [...checks.value]
updated[idx] = {
check_key: key,
status: 'error' as BotCheck['status'],
summary: 'Check failed',
}
checks.value = updated
}
}
})
await Promise.all(pending)
} catch (error) {
checks.value = []
if (showToast) {
toast.error(resolveErrorMessage(error, t('bots.checks.loadFailed')))
}