feat: add Supermarket integration (MCP & Skill marketplace) (#309)

* feat: add Supermarket integration (MCP & Skill marketplace)

Backend:
- Add [supermarket] config section with base_url (default: supermarket.memoh.ai)
- Add SupermarketHandler with proxy endpoints for MCPs, Skills, and Tags
- Add install endpoints: POST /bots/:id/supermarket/install-mcp (creates MCP
  connection with env vars) and install-skill (downloads tar.gz, extracts to
  container via gRPC)
- Register handler in FX wiring, generate Swagger docs and TypeScript SDK

Frontend:
- Add /settings/supermarket route with Store icon in sidebar
- Create supermarket page with search, tag filtering, MCP and Skill sections
- Add MCP/Skill card components with tag badges and install buttons
- Add install dialogs: MCP (bot selector + env var form), Skill (bot selector)
- Add i18n entries for en.json and zh.json

* fix: improve supermarket install UX

- Create BotSelect component with avatar + name using UI Select
- Replace NativeSelect in install dialogs and usage page with BotSelect
- Change MCP install flow: navigate to bot detail MCP tab with pre-filled
  draft instead of direct install, letting users review before saving
- Move Supermarket sidebar entry between Browser and Usage

* web: remove supermarket page top tag selector bar

Drop the horizontal tag chips and getSupermarketTags fetch; keep
search and tag filter via card tag clicks with clearable badge.

* web: add homepage link to supermarket MCP and Skill cards

Show an external-link icon next to the card title when homepage is
available, opening in a new tab on click.
This commit is contained in:
Acbox Liu
2026-03-31 02:22:39 +08:00
committed by Acbox
parent 49e5f3d8ae
commit faaf13a0e9
25 changed files with 3168 additions and 24 deletions
@@ -0,0 +1,86 @@
<template>
<Select
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
>
<SelectTrigger :class="triggerClass">
<SelectValue :placeholder="placeholder || $t('supermarket.selectBotPlaceholder')">
<div
v-if="selectedBot"
class="flex items-center gap-2"
>
<Avatar class="size-5 shrink-0">
<AvatarImage
v-if="selectedBot.avatar_url"
:src="selectedBot.avatar_url"
:alt="selectedBot.display_name"
/>
<AvatarFallback class="text-[9px]">
{{ initials(selectedBot.display_name || selectedBot.id || '') }}
</AvatarFallback>
</Avatar>
<span class="truncate text-xs">{{ selectedBot.display_name || selectedBot.id }}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="bot in bots"
:key="bot.id"
:value="bot.id!"
>
<div class="flex items-center gap-2">
<Avatar class="size-5 shrink-0">
<AvatarImage
v-if="bot.avatar_url"
:src="bot.avatar_url"
:alt="bot.display_name"
/>
<AvatarFallback class="text-[9px]">
{{ initials(bot.display_name || bot.id || '') }}
</AvatarFallback>
</Avatar>
<span class="truncate text-xs">{{ bot.display_name || bot.id }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQuery } from '@pinia/colada'
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
Avatar, AvatarImage, AvatarFallback,
} from '@memohai/ui'
import { getBotsQuery } from '@memohai/sdk/colada'
import type { BotsBot } from '@memohai/sdk'
const props = defineProps<{
modelValue: string
placeholder?: string
triggerClass?: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const { data: botsData } = useQuery(getBotsQuery())
const bots = computed<BotsBot[]>(() => botsData.value?.items ?? [])
const selectedBot = computed(() =>
bots.value.find((b) => b.id === props.modelValue),
)
function initials(name: string): string {
return name
.split(/[\s_-]+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase()
}
</script>
@@ -50,7 +50,7 @@ import { computed, type Component } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ChevronLeft, Bot, Boxes, Globe, Brain, Volume2, Mail, AppWindow, ChartLine, User } from 'lucide-vue-next'
import { ChevronLeft, Bot, Boxes, Globe, Brain, Volume2, Mail, AppWindow, ChartLine, User, Store } from 'lucide-vue-next'
import { useChatSelectionStore } from '@/store/chat-selection'
import {
Sidebar,
@@ -125,6 +125,11 @@ const navItems = computed<{ title: string; name: string; icon: Component }[]>(()
name: 'browser',
icon: AppWindow,
},
{
title: t('sidebar.supermarket'),
name: 'supermarket',
icon: Store,
},
{
title: t('sidebar.usage'),
name: 'usage',
+31 -1
View File
@@ -69,7 +69,8 @@
"mcp": "MCP",
"platform": "Platform",
"usage": "Usage",
"browser": "Browser"
"browser": "Browser",
"supermarket": "Supermarket"
},
"breadcrumb": {
"main": "Home"
@@ -1140,5 +1141,34 @@
"dateTo": "To",
"chartPie": "Pie",
"chartBar": "Bar"
},
"supermarket": {
"title": "Supermarket",
"searchPlaceholder": "Search MCPs and Skills...",
"mcpSection": "MCP Servers",
"skillsSection": "Skills",
"noResults": "No results found",
"noMcpResults": "No MCP servers found",
"noSkillResults": "No skills found",
"install": "Install",
"installToBot": "Install to Bot",
"selectBot": "Select a bot",
"selectBotPlaceholder": "Choose a bot to install to...",
"envVariables": "Environment Variables",
"envDescription": "Configure the required environment variables",
"installSuccess": "Installed successfully",
"installFailed": "Installation failed",
"installing": "Installing...",
"filterByTag": "Filter by tag: {tag}",
"clearFilter": "Clear filter",
"allTags": "All Tags",
"author": "Author",
"transport": "Transport",
"homepage": "Homepage",
"files": "Files",
"viewDetails": "View details",
"mcpInstallTitle": "Install MCP Server",
"skillInstallTitle": "Install Skill",
"loadError": "Failed to load from Supermarket"
}
}
+31 -1
View File
@@ -69,7 +69,8 @@
"mcp": "MCP",
"platform": "接入平台",
"usage": "用量统计",
"browser": "浏览器"
"browser": "浏览器",
"supermarket": "市场"
},
"breadcrumb": {
"main": "主页"
@@ -1136,5 +1137,34 @@
"dateTo": "结束日期",
"chartPie": "饼图",
"chartBar": "柱状图"
},
"supermarket": {
"title": "市场",
"searchPlaceholder": "搜索 MCP 和 Skills...",
"mcpSection": "MCP 服务",
"skillsSection": "Skills",
"noResults": "未找到结果",
"noMcpResults": "未找到 MCP 服务",
"noSkillResults": "未找到 Skill",
"install": "安装",
"installToBot": "安装到 Bot",
"selectBot": "选择 Bot",
"selectBotPlaceholder": "选择要安装到的 Bot...",
"envVariables": "环境变量",
"envDescription": "配置所需的环境变量",
"installSuccess": "安装成功",
"installFailed": "安装失败",
"installing": "安装中...",
"filterByTag": "按标签筛选: {tag}",
"clearFilter": "清除筛选",
"allTags": "所有标签",
"author": "作者",
"transport": "传输协议",
"homepage": "主页",
"files": "文件",
"viewDetails": "查看详情",
"mcpInstallTitle": "安装 MCP 服务",
"skillInstallTitle": "安装 Skill",
"loadError": "从市场加载失败"
}
}
+51 -2
View File
@@ -705,6 +705,7 @@ import type {
} from '@memohai/sdk'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { useClipboard } from '@/composables/useClipboard'
import { useSupermarketMcpDraft } from '@/stores/supermarket-mcp-draft'
interface McpItem {
id: string
@@ -1243,10 +1244,58 @@ watch(connectionType, (mode) => {
}
})
watch(() => props.botId, () => {
const { consumePendingDraft } = useSupermarketMcpDraft()
function applyPendingDraft() {
const entry = consumePendingDraft()
if (!entry) return
removeDraft()
const isStdio = entry.transport === 'stdio'
const cfg: Record<string, unknown> = {}
if (isStdio) {
if (entry.command) cfg.command = entry.command
if (entry.args?.length) cfg.args = entry.args
const envMap: Record<string, string> = {}
if (entry.env) {
for (const e of entry.env) {
if (e.key) envMap[e.key] = e.defaultValue ?? ''
}
}
if (Object.keys(envMap).length) cfg.env = envMap
} else {
if (entry.url) cfg.url = entry.url
const headerMap: Record<string, string> = {}
if (entry.headers) {
for (const h of entry.headers) {
if (h.key) headerMap[h.key] = h.defaultValue ?? ''
}
}
if (Object.keys(headerMap).length) cfg.headers = headerMap
}
const draft: McpItem = {
id: DRAFT_ID,
name: entry.name ?? '',
type: isStdio ? 'stdio' : (entry.transport === 'sse' ? 'sse' : 'http'),
config: cfg,
is_active: true,
status: 'unknown',
tools_cache: [],
last_probed_at: null,
status_message: '',
auth_type: 'none',
}
items.value = [draft, ...items.value.filter((i) => i.id !== DRAFT_ID)]
selectItem(draft)
}
watch(() => props.botId, async () => {
if (props.botId) {
selectedItem.value = null
loadList()
await loadList()
applyPendingDraft()
}
}, { immediate: true })
</script>
@@ -0,0 +1,98 @@
<template>
<Dialog
:open="open"
@update:open="$emit('update:open', $event)"
>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ $t('supermarket.mcpInstallTitle') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<label class="text-xs font-medium">{{ $t('supermarket.selectBot') }}</label>
<BotSelect
v-model="selectedBotId"
trigger-class="w-full"
/>
</div>
<div
v-if="mcp"
class="rounded-md border border-border p-3 space-y-1"
>
<div class="flex items-center gap-2">
<p class="text-xs font-medium">
{{ mcp.name }}
</p>
<Badge
v-if="mcp.transport"
variant="outline"
size="sm"
>
{{ mcp.transport }}
</Badge>
</div>
<p class="text-[11px] text-muted-foreground line-clamp-2">
{{ mcp.description }}
</p>
</div>
</div>
<DialogFooter>
<DialogClose as-child>
<Button variant="outline">
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
:disabled="!selectedBotId"
@click="handleNavigate"
>
{{ $t('supermarket.install') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
Button, Badge,
} from '@memohai/ui'
import type { HandlersSupermarketMcpEntry } from '@memohai/sdk'
import BotSelect from '@/components/bot-select/index.vue'
import { useSupermarketMcpDraft } from '@/stores/supermarket-mcp-draft'
const props = defineProps<{
open: boolean
mcp: HandlersSupermarketMcpEntry | null
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
}>()
const router = useRouter()
const { setPendingDraft } = useSupermarketMcpDraft()
const selectedBotId = ref('')
watch(() => props.open, (open) => {
if (!open) {
selectedBotId.value = ''
}
})
function handleNavigate() {
if (!selectedBotId.value || !props.mcp) return
setPendingDraft(props.mcp)
emit('update:open', false)
router.push({
name: 'bot-detail',
params: { botId: selectedBotId.value },
query: { tab: 'mcp' },
})
}
</script>
@@ -0,0 +1,118 @@
<template>
<Dialog
:open="open"
@update:open="$emit('update:open', $event)"
>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ $t('supermarket.skillInstallTitle') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<label class="text-xs font-medium">{{ $t('supermarket.selectBot') }}</label>
<BotSelect
v-model="selectedBotId"
trigger-class="w-full"
/>
</div>
<div
v-if="skill"
class="rounded-md border border-border p-3 space-y-1"
>
<p class="text-xs font-medium">
{{ skill.name }}
</p>
<p class="text-[11px] text-muted-foreground line-clamp-3">
{{ skill.description }}
</p>
<p
v-if="skill.files?.length"
class="text-[11px] text-muted-foreground"
>
{{ $t('supermarket.files') }}: {{ skill.files.length }}
</p>
</div>
</div>
<DialogFooter>
<DialogClose as-child>
<Button
variant="outline"
:disabled="installing"
>
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
:disabled="!selectedBotId || installing"
@click="handleInstall"
>
<Spinner
v-if="installing"
class="mr-2 size-4"
/>
{{ installing ? $t('supermarket.installing') : $t('supermarket.install') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
Button, Spinner,
} from '@memohai/ui'
import {
postBotsByBotIdSupermarketInstallSkill,
type HandlersSupermarketSkillEntry,
} from '@memohai/sdk'
import { resolveApiErrorMessage } from '@/utils/api-error'
import BotSelect from '@/components/bot-select/index.vue'
const props = defineProps<{
open: boolean
skill: HandlersSupermarketSkillEntry | null
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
'installed': []
}>()
const { t } = useI18n()
const selectedBotId = ref('')
const installing = ref(false)
watch(() => props.open, (open) => {
if (!open) {
selectedBotId.value = ''
installing.value = false
}
})
async function handleInstall() {
if (!selectedBotId.value || !props.skill?.id) return
installing.value = true
try {
await postBotsByBotIdSupermarketInstallSkill({
path: { bot_id: selectedBotId.value },
body: {
skill_id: props.skill.id,
},
throwOnError: true,
})
toast.success(t('supermarket.installSuccess'))
emit('update:open', false)
emit('installed')
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('supermarket.installFailed')))
} finally {
installing.value = false
}
}
</script>
@@ -0,0 +1,100 @@
<template>
<Card class="flex flex-col">
<CardHeader class="pb-3">
<div class="flex items-start gap-3">
<div
v-if="mcp.icon"
class="size-9 shrink-0 rounded-md bg-accent flex items-center justify-center overflow-hidden"
>
<img
:src="mcp.icon"
:alt="mcp.name"
class="size-5 object-contain"
>
</div>
<div
v-else
class="size-9 shrink-0 rounded-md bg-accent flex items-center justify-center"
>
<Plug class="size-4 text-muted-foreground" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<CardTitle
class="text-sm truncate"
:title="mcp.name"
>
{{ mcp.name }}
</CardTitle>
<a
v-if="mcp.homepage"
:href="mcp.homepage"
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
@click.stop
>
<ExternalLink class="size-3" />
</a>
</div>
<div class="flex items-center gap-1.5 mt-1">
<Badge
variant="outline"
size="sm"
>
{{ mcp.transport }}
</Badge>
<span
v-if="mcp.author?.name"
class="text-[11px] text-muted-foreground truncate"
>
{{ mcp.author.name }}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent class="flex-1 pb-3">
<p class="text-xs text-muted-foreground line-clamp-2">
{{ mcp.description }}
</p>
</CardContent>
<CardFooter class="pt-0 flex items-center justify-between gap-2">
<div class="flex flex-wrap gap-1 min-w-0 overflow-hidden">
<Badge
v-for="tag in mcp.tags?.slice(0, 3)"
:key="tag"
variant="secondary"
size="sm"
class="cursor-pointer hover:bg-foreground hover:text-background transition-colors"
@click.stop="$emit('tag-click', tag)"
>
{{ tag }}
</Badge>
</div>
<Button
size="sm"
class="shrink-0"
@click.stop="$emit('install', mcp)"
>
<Download class="size-3.5 mr-1.5" />
{{ $t('supermarket.install') }}
</Button>
</CardFooter>
</Card>
</template>
<script setup lang="ts">
import { Plug, Download, ExternalLink } from 'lucide-vue-next'
import { Card, CardHeader, CardTitle, CardContent, CardFooter, Badge, Button } from '@memohai/ui'
import type { HandlersSupermarketMcpEntry } from '@memohai/sdk'
defineProps<{
mcp: HandlersSupermarketMcpEntry
}>()
defineEmits<{
'tag-click': [tag: string]
'install': [mcp: HandlersSupermarketMcpEntry]
}>()
</script>
@@ -0,0 +1,81 @@
<template>
<Card class="flex flex-col">
<CardHeader class="pb-3">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<div class="size-9 shrink-0 rounded-md bg-accent flex items-center justify-center">
<Zap class="size-4 text-muted-foreground" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<CardTitle
class="text-sm truncate"
:title="skill.name"
>
{{ skill.name }}
</CardTitle>
<a
v-if="skill.metadata?.homepage"
:href="skill.metadata.homepage"
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
@click.stop
>
<ExternalLink class="size-3" />
</a>
</div>
<span
v-if="skill.metadata?.author?.name"
class="text-[11px] text-muted-foreground"
>
{{ skill.metadata.author.name }}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent class="flex-1 pb-3">
<p class="text-xs text-muted-foreground line-clamp-2">
{{ skill.description }}
</p>
</CardContent>
<CardFooter class="pt-0 flex items-center justify-between gap-2">
<div class="flex flex-wrap gap-1 min-w-0 overflow-hidden">
<Badge
v-for="tag in skill.metadata?.tags?.slice(0, 3)"
:key="tag"
variant="secondary"
size="sm"
class="cursor-pointer hover:bg-foreground hover:text-background transition-colors"
@click.stop="$emit('tag-click', tag)"
>
{{ tag }}
</Badge>
</div>
<Button
size="sm"
class="shrink-0"
@click.stop="$emit('install', skill)"
>
<Download class="size-3.5 mr-1.5" />
{{ $t('supermarket.install') }}
</Button>
</CardFooter>
</Card>
</template>
<script setup lang="ts">
import { Zap, Download, ExternalLink } from 'lucide-vue-next'
import { Card, CardHeader, CardTitle, CardContent, CardFooter, Badge, Button } from '@memohai/ui'
import type { HandlersSupermarketSkillEntry } from '@memohai/sdk'
defineProps<{
skill: HandlersSupermarketSkillEntry
}>()
defineEmits<{
'tag-click': [tag: string]
'install': [skill: HandlersSupermarketSkillEntry]
}>()
</script>
+234
View File
@@ -0,0 +1,234 @@
<template>
<div class="p-6 max-w-6xl mx-auto space-y-6">
<!-- Header + Search -->
<div class="space-y-4">
<h1 class="text-lg font-semibold">
{{ $t('supermarket.title') }}
</h1>
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
v-model="searchInput"
:placeholder="$t('supermarket.searchPlaceholder')"
class="pl-9"
@keydown.enter="applySearch"
/>
</div>
</div>
<!-- Active tag filter (e.g. from card tag click) -->
<div
v-if="activeTag"
class="flex items-center gap-2"
>
<Badge
variant="secondary"
class="gap-1"
>
{{ $t('supermarket.filterByTag', { tag: activeTag }) }}
<button
class="ml-1 hover:text-destructive"
@click="clearTag"
>
<X class="size-3" />
</button>
</Badge>
</div>
</div>
<!-- MCP Section -->
<section class="space-y-3">
<h2 class="text-sm font-semibold">
{{ $t('supermarket.mcpSection') }}
</h2>
<div
v-if="mcpLoading"
class="flex items-center justify-center py-8 text-xs text-muted-foreground"
>
<Spinner class="mr-2" />
{{ $t('common.loading') }}
</div>
<div
v-else-if="!mcps.length"
class="py-8 text-center text-xs text-muted-foreground"
>
{{ $t('supermarket.noMcpResults') }}
</div>
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<McpCard
v-for="mcp in mcps"
:key="mcp.id"
:mcp="mcp"
@tag-click="setTag"
@install="openMcpInstall"
/>
</div>
</section>
<!-- Skills Section -->
<section class="space-y-3">
<h2 class="text-sm font-semibold">
{{ $t('supermarket.skillsSection') }}
</h2>
<div
v-if="skillsLoading"
class="flex items-center justify-center py-8 text-xs text-muted-foreground"
>
<Spinner class="mr-2" />
{{ $t('common.loading') }}
</div>
<div
v-else-if="!skills.length"
class="py-8 text-center text-xs text-muted-foreground"
>
{{ $t('supermarket.noSkillResults') }}
</div>
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<SkillCard
v-for="skill in skills"
:key="skill.id"
:skill="skill"
@tag-click="setTag"
@install="openSkillInstall"
/>
</div>
</section>
<!-- Install Dialogs -->
<InstallMcpDialog
v-model:open="mcpDialogOpen"
:mcp="selectedMcp"
/>
<InstallSkillDialog
v-model:open="skillDialogOpen"
:skill="selectedSkill"
@installed="refreshAll"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Search, X } from 'lucide-vue-next'
import { Input, Badge, Spinner } from '@memohai/ui'
import {
getSupermarketMcps,
getSupermarketSkills,
type HandlersSupermarketMcpEntry,
type HandlersSupermarketSkillEntry,
} from '@memohai/sdk'
import { toast } from 'vue-sonner'
import { resolveApiErrorMessage } from '@/utils/api-error'
import McpCard from './components/mcp-card.vue'
import SkillCard from './components/skill-card.vue'
import InstallMcpDialog from './components/install-mcp-dialog.vue'
import InstallSkillDialog from './components/install-skill-dialog.vue'
const { t } = useI18n()
const searchInput = ref('')
const searchQuery = ref('')
const activeTag = ref('')
const mcps = ref<HandlersSupermarketMcpEntry[]>([])
const skills = ref<HandlersSupermarketSkillEntry[]>([])
const mcpLoading = ref(false)
const skillsLoading = ref(false)
const mcpDialogOpen = ref(false)
const skillDialogOpen = ref(false)
const selectedMcp = ref<HandlersSupermarketMcpEntry | null>(null)
const selectedSkill = ref<HandlersSupermarketSkillEntry | null>(null)
function applySearch() {
searchQuery.value = searchInput.value.trim()
}
let searchDebounce: ReturnType<typeof setTimeout> | undefined
watch(searchInput, () => {
clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => {
searchQuery.value = searchInput.value.trim()
}, 300)
})
function setTag(tag: string) {
activeTag.value = tag
}
function clearTag() {
activeTag.value = ''
}
function openMcpInstall(mcp: HandlersSupermarketMcpEntry) {
selectedMcp.value = mcp
mcpDialogOpen.value = true
}
function openSkillInstall(skill: HandlersSupermarketSkillEntry) {
selectedSkill.value = skill
skillDialogOpen.value = true
}
async function loadMcps() {
mcpLoading.value = true
try {
const { data } = await getSupermarketMcps({
query: {
q: searchQuery.value || undefined,
tag: activeTag.value || undefined,
limit: 50,
},
throwOnError: true,
})
mcps.value = data.data ?? []
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('supermarket.loadError')))
} finally {
mcpLoading.value = false
}
}
async function loadSkills() {
skillsLoading.value = true
try {
const { data } = await getSupermarketSkills({
query: {
q: searchQuery.value || undefined,
tag: activeTag.value || undefined,
limit: 50,
},
throwOnError: true,
})
skills.value = data.data ?? []
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('supermarket.loadError')))
} finally {
skillsLoading.value = false
}
}
function refreshAll() {
loadMcps()
loadSkills()
}
watch([searchQuery, activeTag], () => {
loadMcps()
loadSkills()
}, { immediate: true })
</script>
+6 -14
View File
@@ -22,20 +22,11 @@
<div class="flex flex-wrap items-end gap-4">
<div class="space-y-1.5">
<Label>{{ $t('usage.selectBot') }}</Label>
<Select v-model="selectedBotId">
<SelectTrigger class="w-56">
<SelectValue :placeholder="$t('usage.selectBotPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="bot in botList"
:key="bot.id"
:value="bot.id!"
>
{{ bot.display_name || bot.id }}
</SelectItem>
</SelectContent>
</Select>
<BotSelect
v-model="selectedBotId"
trigger-class="w-56"
:placeholder="$t('usage.selectBotPlaceholder')"
/>
</div>
<div class="space-y-1.5">
@@ -307,6 +298,7 @@ import {
} from '@memohai/ui'
import { getBotsQuery } from '@memohai/sdk/colada'
import { getBotsByBotIdTokenUsage } from '@memohai/sdk'
import BotSelect from '@/components/bot-select/index.vue'
import type { HandlersDailyTokenUsage, HandlersModelTokenUsage } from '@memohai/sdk'
import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam'
+8
View File
@@ -129,6 +129,14 @@ const routes = [
breadcrumb: i18nRef('sidebar.platform'),
},
},
{
name: 'supermarket',
path: 'supermarket',
component: () => import('@/pages/supermarket/index.vue'),
meta: {
breadcrumb: i18nRef('sidebar.supermarket'),
},
},
],
},
{
@@ -0,0 +1,18 @@
import { ref } from 'vue'
import type { HandlersSupermarketMcpEntry } from '@memohai/sdk'
const pendingDraft = ref<HandlersSupermarketMcpEntry | null>(null)
export function useSupermarketMcpDraft() {
function setPendingDraft(entry: HandlersSupermarketMcpEntry) {
pendingDraft.value = entry
}
function consumePendingDraft(): HandlersSupermarketMcpEntry | null {
const draft = pendingDraft.value
pendingDraft.value = null
return draft
}
return { pendingDraft, setPendingDraft, consumePendingDraft }
}