mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "从市场加载失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user