mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(web): bots page
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
import { RouterView } from 'vue-router'
|
||||
import { Toaster } from '@memoh/ui'
|
||||
import 'vue-sonner/style.css'
|
||||
import { useSettingsStore } from '@/store/settings'
|
||||
|
||||
// 初始化设置(主题、语言),确保在任何页面进入时都已应用
|
||||
useSettingsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -80,10 +80,15 @@ const sidebarInfo = computed(() => [
|
||||
name: 'chat',
|
||||
icon: ['far', 'comments'],
|
||||
},
|
||||
{
|
||||
title: t('sidebar.bots'),
|
||||
name: 'bots',
|
||||
icon: ['fas', 'robot'],
|
||||
},
|
||||
{
|
||||
title: t('sidebar.models'),
|
||||
name: 'models',
|
||||
icon: ['fas', 'robot'],
|
||||
icon: ['fas', 'cubes'],
|
||||
},
|
||||
{
|
||||
title: t('sidebar.settings'),
|
||||
|
||||
@@ -71,10 +71,15 @@ import { computed } from 'vue'
|
||||
const route = useRoute()
|
||||
|
||||
const curBreadcrumb = computed(() => {
|
||||
return route.matched.map(routeItem => ({
|
||||
path: routeItem.path,
|
||||
breadcrumb: routeItem.meta['breadcrumb']
|
||||
}))
|
||||
return route.matched
|
||||
.filter(routeItem => routeItem.meta['breadcrumb'])
|
||||
.map(routeItem => {
|
||||
const raw = routeItem.meta['breadcrumb']
|
||||
return {
|
||||
path: routeItem.path,
|
||||
breadcrumb: typeof raw === 'function' ? raw(route) : raw,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { fetchApi } from '@/utils/request'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export interface BotSettings {
|
||||
chat_model_id: string
|
||||
memory_model_id: string
|
||||
embedding_model_id: string
|
||||
max_context_load_time: number
|
||||
language: string
|
||||
allow_guest: boolean
|
||||
}
|
||||
|
||||
export interface UpsertBotSettingsRequest {
|
||||
chat_model_id?: string
|
||||
memory_model_id?: string
|
||||
embedding_model_id?: string
|
||||
max_context_load_time?: number
|
||||
language?: string
|
||||
allow_guest?: boolean
|
||||
}
|
||||
|
||||
// ---- Query ----
|
||||
|
||||
export function useBotSettings(botId: Ref<string>) {
|
||||
return useQuery({
|
||||
key: () => ['bot-settings', botId.value],
|
||||
query: () => fetchApi<BotSettings>(`/bots/${botId.value}/settings`),
|
||||
enabled: () => !!botId.value,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Mutation ----
|
||||
|
||||
export function useUpdateBotSettings(botId: Ref<string>) {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (data: UpsertBotSettingsRequest) => fetchApi<BotSettings>(
|
||||
`/bots/${botId.value}/settings`,
|
||||
{ method: 'PUT', body: data },
|
||||
),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['bot-settings', botId.value] }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { fetchApi } from '@/utils/request'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export interface BotInfo {
|
||||
id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
type: string
|
||||
is_active: boolean
|
||||
owner_user_id: string
|
||||
metadata: Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ListBotsResponse {
|
||||
items: BotInfo[]
|
||||
}
|
||||
|
||||
export interface CreateBotRequest {
|
||||
display_name: string
|
||||
avatar_url?: string
|
||||
type?: string
|
||||
is_active?: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UpdateBotRequest {
|
||||
display_name?: string
|
||||
avatar_url?: string
|
||||
is_active?: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ---- Query: 获取 Bot 列表 ----
|
||||
|
||||
export function useBotList() {
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
const query = useQuery({
|
||||
key: ['bots'],
|
||||
query: async (): Promise<BotInfo[]> => {
|
||||
const res = await fetchApi<ListBotsResponse>('/bots')
|
||||
return res.items ?? []
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
invalidate: () => queryCache.invalidateQueries({ key: ['bots'] }),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Query: 获取单个 Bot 详情 ----
|
||||
|
||||
export function useBotDetail(botId: Ref<string>) {
|
||||
return useQuery({
|
||||
key: () => ['bot', botId.value],
|
||||
query: () => fetchApi<BotInfo>(`/bots/${botId.value}`),
|
||||
enabled: () => !!botId.value,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Mutations ----
|
||||
|
||||
export function useCreateBot() {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (data: CreateBotRequest) => fetchApi<BotInfo>('/bots', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['bots'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteBot() {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (botId: string) => fetchApi(`/bots/${botId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['bots'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateBot() {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: ({ id, ...data }: UpdateBotRequest & { id: string }) => fetchApi<BotInfo>(`/bots/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['bots'] }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { fetchApi, ApiError } from '@/utils/request'
|
||||
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export interface FieldSchema {
|
||||
title: string
|
||||
description?: string
|
||||
type: 'string' | 'secret' | 'bool' | 'number' | 'enum'
|
||||
required?: boolean
|
||||
enum?: string[]
|
||||
example?: unknown
|
||||
}
|
||||
|
||||
export interface ConfigSchema {
|
||||
version: number
|
||||
fields: Record<string, FieldSchema>
|
||||
}
|
||||
|
||||
export interface ChannelCapabilities {
|
||||
text: boolean
|
||||
markdown: boolean
|
||||
rich_text: boolean
|
||||
attachments: boolean
|
||||
media: boolean
|
||||
reactions: boolean
|
||||
buttons: boolean
|
||||
reply: boolean
|
||||
threads: boolean
|
||||
streaming: boolean
|
||||
polls: boolean
|
||||
edit: boolean
|
||||
unsend: boolean
|
||||
native_commands: boolean
|
||||
block_streaming: boolean
|
||||
chat_types?: string[]
|
||||
}
|
||||
|
||||
export interface ChannelMeta {
|
||||
type: string
|
||||
display_name: string
|
||||
configless: boolean
|
||||
capabilities: ChannelCapabilities
|
||||
config_schema: ConfigSchema
|
||||
user_config_schema: ConfigSchema
|
||||
target_spec: { format: string; description: string }
|
||||
}
|
||||
|
||||
export interface ChannelConfig {
|
||||
id: string
|
||||
botID: string
|
||||
channelType: string
|
||||
credentials: Record<string, unknown>
|
||||
externalIdentity: string
|
||||
selfIdentity: Record<string, unknown>
|
||||
routing: Record<string, unknown>
|
||||
status: string
|
||||
verifiedAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface UpsertConfigRequest {
|
||||
credentials: Record<string, unknown>
|
||||
external_identity?: string
|
||||
self_identity?: Record<string, unknown>
|
||||
routing?: Record<string, unknown>
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface BotChannelItem {
|
||||
meta: ChannelMeta
|
||||
config: ChannelConfig | null
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
// ---- Query: 获取可用渠道类型元信息 ----
|
||||
|
||||
export function useChannelMetas() {
|
||||
return useQuery({
|
||||
key: ['channel-metas'],
|
||||
query: () => fetchApi<ChannelMeta[]>('/channels'),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Query: 获取某 Bot 的所有渠道配置(组合查询) ----
|
||||
|
||||
export function useBotChannels(botId: Ref<string>) {
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
const query = useQuery({
|
||||
key: () => ['bot-channels', botId.value],
|
||||
query: async (): Promise<BotChannelItem[]> => {
|
||||
// 1. 获取所有渠道元信息
|
||||
const metas = await fetchApi<ChannelMeta[]>('/channels')
|
||||
|
||||
// 2. 过滤掉 configless 的类型(cli / web 等本地渠道)
|
||||
const configurableTypes = metas.filter((m) => !m.configless)
|
||||
|
||||
// 3. 并行获取每种类型的 bot 配置
|
||||
const results = await Promise.all(
|
||||
configurableTypes.map(async (meta) => {
|
||||
try {
|
||||
const config = await fetchApi<ChannelConfig>(
|
||||
`/bots/${botId.value}/channel/${meta.type}`,
|
||||
)
|
||||
return { meta, config, configured: true } as BotChannelItem
|
||||
} catch (err) {
|
||||
// 404 = 尚未配置,其他错误也视为未配置
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
return { meta, config: null, configured: false } as BotChannelItem
|
||||
}
|
||||
return { meta, config: null, configured: false } as BotChannelItem
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return results
|
||||
},
|
||||
enabled: () => !!botId.value,
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
invalidate: () => queryCache.invalidateQueries({ key: ['bot-channels', botId.value] }),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mutation: 创建/更新 Bot 渠道配置 ----
|
||||
|
||||
export function useUpsertBotChannel(botId: Ref<string>) {
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
return useMutation({
|
||||
mutation: ({ platform, data }: { platform: string; data: UpsertConfigRequest }) =>
|
||||
fetchApi<ChannelConfig>(`/bots/${botId.value}/channel/${platform}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
onSettled: () => queryCache.invalidateQueries({ key: ['bot-channels', botId.value] }),
|
||||
})
|
||||
}
|
||||
@@ -33,6 +33,15 @@ export function useModelList(providerId: Ref<string | undefined>) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Query: 获取所有模型(跨 Provider) ----
|
||||
|
||||
export function useAllModels() {
|
||||
return useQuery({
|
||||
key: ['all-models'],
|
||||
query: () => fetchApi<ModelInfo[]>('/models'),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Mutations ----
|
||||
|
||||
export function useCreateModel() {
|
||||
|
||||
@@ -28,6 +28,14 @@ export function useProviderList(clientType: Ref<string>) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取所有 Provider(无过滤) */
|
||||
export function useAllProviders() {
|
||||
return useQuery({
|
||||
key: ['all-providers'],
|
||||
query: () => fetchApi<ProviderWithId[]>('/providers'),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Mutations ----
|
||||
|
||||
export function useCreateProvider() {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"chat": "Chat",
|
||||
"bots": "Bots",
|
||||
"models": "Models",
|
||||
"settings": "Settings",
|
||||
"home": "Home",
|
||||
@@ -117,5 +118,63 @@
|
||||
},
|
||||
"home": {
|
||||
"title": "Home"
|
||||
},
|
||||
"bots": {
|
||||
"title": "Bots",
|
||||
"searchPlaceholder": "Search bots…",
|
||||
"createBot": "New Bot",
|
||||
"editBot": "Edit Bot",
|
||||
"deleteConfirm": "Are you sure you want to delete this bot?",
|
||||
"emptyTitle": "No Bots",
|
||||
"emptyDescription": "Click the button above to create your first bot",
|
||||
"displayName": "Name",
|
||||
"displayNamePlaceholder": "Give your bot a name",
|
||||
"avatarUrl": "Avatar URL",
|
||||
"avatarUrlPlaceholder": "Enter avatar image URL",
|
||||
"type": "Type",
|
||||
"typePlaceholder": "Select bot type",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"createdAt": "Created at",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"memory": "Memory",
|
||||
"channels": "Channels",
|
||||
"mcp": "MCP",
|
||||
"subagents": "Subagents",
|
||||
"history": "History",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"settings": {
|
||||
"chatModel": "Chat Model",
|
||||
"memoryModel": "Memory Model",
|
||||
"embeddingModel": "Embedding Model",
|
||||
"maxContextLoadTime": "Max Context Load Time",
|
||||
"language": "Language",
|
||||
"allowGuest": "Allow Guest Access",
|
||||
"searchModel": "Search models…",
|
||||
"noModel": "No models available",
|
||||
"saveSuccess": "Settings saved",
|
||||
"save": "Save Settings"
|
||||
},
|
||||
"channels": {
|
||||
"title": "Channels",
|
||||
"addChannel": "Add Channel",
|
||||
"selectType": "Select channel type",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"emptyTitle": "No Channels",
|
||||
"emptyDescription": "Click the button below to add a messaging channel",
|
||||
"credentials": "Credentials",
|
||||
"saveSuccess": "Channel configuration saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"requiredField": "{field} is required",
|
||||
"save": "Save Configuration",
|
||||
"status": "Status",
|
||||
"statusActive": "Active",
|
||||
"statusInactive": "Inactive",
|
||||
"deleteConfirm": "Are you sure you want to remove this channel?",
|
||||
"noAvailableTypes": "All channel types have been configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"chat": "对话",
|
||||
"bots": "Bots",
|
||||
"models": "模型管理",
|
||||
"settings": "设置",
|
||||
"home": "首页",
|
||||
@@ -117,5 +118,63 @@
|
||||
},
|
||||
"home": {
|
||||
"title": "首页"
|
||||
},
|
||||
"bots": {
|
||||
"title": "Bots",
|
||||
"searchPlaceholder": "搜索 Bot…",
|
||||
"createBot": "新建 Bot",
|
||||
"editBot": "编辑 Bot",
|
||||
"deleteConfirm": "确定要删除这个 Bot 吗?",
|
||||
"emptyTitle": "暂无 Bot",
|
||||
"emptyDescription": "点击右上角按钮创建你的第一个 Bot",
|
||||
"displayName": "名称",
|
||||
"displayNamePlaceholder": "给你的 Bot 起个名字",
|
||||
"avatarUrl": "头像链接",
|
||||
"avatarUrlPlaceholder": "输入头像图片地址",
|
||||
"type": "类型",
|
||||
"typePlaceholder": "选择 Bot 类型",
|
||||
"active": "运行中",
|
||||
"inactive": "未启用",
|
||||
"createdAt": "创建于",
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"memory": "记忆",
|
||||
"channels": "渠道",
|
||||
"mcp": "MCP",
|
||||
"subagents": "子智能体",
|
||||
"history": "对话历史",
|
||||
"settings": "设置"
|
||||
},
|
||||
"settings": {
|
||||
"chatModel": "对话模型",
|
||||
"memoryModel": "记忆模型",
|
||||
"embeddingModel": "向量模型",
|
||||
"maxContextLoadTime": "最大上下文加载时间",
|
||||
"language": "语言",
|
||||
"allowGuest": "允许游客访问",
|
||||
"searchModel": "搜索模型…",
|
||||
"noModel": "暂无可选模型",
|
||||
"saveSuccess": "设置已保存",
|
||||
"save": "保存设置"
|
||||
},
|
||||
"channels": {
|
||||
"title": "渠道",
|
||||
"addChannel": "添加渠道",
|
||||
"selectType": "选择渠道类型",
|
||||
"configured": "已配置",
|
||||
"notConfigured": "未配置",
|
||||
"emptyTitle": "暂无渠道",
|
||||
"emptyDescription": "点击下方按钮为 Bot 添加消息渠道",
|
||||
"credentials": "凭据配置",
|
||||
"saveSuccess": "渠道配置已保存",
|
||||
"saveFailed": "保存失败",
|
||||
"requiredField": "{field} 为必填项",
|
||||
"save": "保存配置",
|
||||
"status": "状态",
|
||||
"statusActive": "启用",
|
||||
"statusInactive": "停用",
|
||||
"deleteConfirm": "确定要移除这个渠道吗?",
|
||||
"noAvailableTypes": "所有渠道类型均已配置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSpinner,
|
||||
faCubes,
|
||||
faPenToSquare,
|
||||
faCheck,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import {
|
||||
faRectangleList,
|
||||
@@ -33,6 +38,11 @@ library.add(
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSpinner,
|
||||
faCubes,
|
||||
faPenToSquare,
|
||||
faCheck,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faRectangleList,
|
||||
faTrashCan,
|
||||
faComments,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<Card
|
||||
class="group relative transition-shadow hover:shadow-md cursor-pointer"
|
||||
@click="router.push({ name: 'bot-detail', params: { botId: bot.id } })"
|
||||
>
|
||||
<CardHeader class="flex flex-row items-start gap-4 space-y-0">
|
||||
<Avatar class="size-12 shrink-0">
|
||||
<AvatarImage
|
||||
v-if="bot.avatar_url"
|
||||
:src="bot.avatar_url"
|
||||
:alt="bot.display_name"
|
||||
/>
|
||||
<AvatarFallback class="text-lg">
|
||||
{{ avatarFallback }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<CardTitle class="text-base truncate">
|
||||
{{ bot.display_name || bot.id }}
|
||||
</CardTitle>
|
||||
<CardDescription class="mt-1 flex items-center gap-2">
|
||||
<Badge
|
||||
:variant="bot.is_active ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ bot.is_active ? $t('bots.active') : $t('bots.inactive') }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="bot.type"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ bot.type }}
|
||||
</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardFooter class="pt-0 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ $t('bots.createdAt') }} {{ formattedDate }}</span>
|
||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click.stop="$emit('edit', bot)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="['fas', 'pen-to-square']" />
|
||||
</Button>
|
||||
<ConfirmPopover
|
||||
:message="$t('bots.deleteConfirm')"
|
||||
:loading="deleteLoading"
|
||||
@confirm="$emit('delete', bot.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click.stop
|
||||
>
|
||||
<FontAwesomeIcon :icon="['far', 'trash-can']" />
|
||||
</Button>
|
||||
</template>
|
||||
</ConfirmPopover>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Badge,
|
||||
Button,
|
||||
} from '@memoh/ui'
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { BotInfo } from '@/composables/api/useBots'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
bot: BotInfo
|
||||
deleteLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [bot: BotInfo]
|
||||
delete: [id: string]
|
||||
}>()
|
||||
|
||||
const avatarFallback = computed(() => {
|
||||
const name = props.bot.display_name || props.bot.id
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.bot.created_at) return ''
|
||||
return new Date(props.bot.created_at).toLocaleDateString()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="flex gap-6 min-h-[400px]">
|
||||
<!-- Left: Channel list -->
|
||||
<div class="w-60 shrink-0 flex flex-col border rounded-lg">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-if="isLoading && configuredChannels.length === 0"
|
||||
class="flex items-center justify-center h-full p-4"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'spinner']"
|
||||
class="size-4 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div
|
||||
v-else-if="configuredChannels.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full p-4 text-center"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('bots.channels.emptyTitle') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ $t('bots.channels.emptyDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Configured channels -->
|
||||
<div
|
||||
v-else
|
||||
class="p-1"
|
||||
>
|
||||
<button
|
||||
v-for="item in configuredChannels"
|
||||
:key="item.meta.type"
|
||||
class="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors hover:bg-accent"
|
||||
:class="{ 'bg-accent': selectedType === item.meta.type }"
|
||||
@click="selectedType = item.meta.type"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-md text-xs font-bold uppercase"
|
||||
:class="channelBadgeClass(item.meta.type)"
|
||||
>
|
||||
{{ channelIcon(item.meta.type) }}
|
||||
</div>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="font-medium">
|
||||
{{ item.meta.display_name }}
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<span
|
||||
v-if="item.config?.status === 'active'"
|
||||
class="text-green-600 dark:text-green-400"
|
||||
>
|
||||
{{ $t('bots.channels.statusActive') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
{{ $t('bots.channels.configured') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add button -->
|
||||
<div class="border-t p-2">
|
||||
<Popover v-model:open="addPopoverOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
:disabled="unconfiguredChannels.length === 0 && !isLoading"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'plus']"
|
||||
class="mr-2 size-3"
|
||||
/>
|
||||
{{ $t('bots.channels.addChannel') }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
class="w-56 p-1"
|
||||
align="start"
|
||||
>
|
||||
<div
|
||||
v-if="unconfiguredChannels.length === 0"
|
||||
class="px-3 py-2 text-sm text-muted-foreground text-center"
|
||||
>
|
||||
{{ $t('bots.channels.noAvailableTypes') }}
|
||||
</div>
|
||||
<button
|
||||
v-for="item in unconfiguredChannels"
|
||||
:key="item.meta.type"
|
||||
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
@click="addChannel(item.meta.type)"
|
||||
>
|
||||
<div
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-md text-xs font-bold uppercase"
|
||||
:class="channelBadgeClass(item.meta.type)"
|
||||
>
|
||||
{{ channelIcon(item.meta.type) }}
|
||||
</div>
|
||||
<span>{{ item.meta.display_name }}</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Channel settings -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
v-if="!selectedType || !selectedItem"
|
||||
class="flex h-full items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ configuredChannels.length > 0 ? $t('bots.channels.selectType') : '' }}
|
||||
</div>
|
||||
|
||||
<ChannelSettingsPanel
|
||||
v-else
|
||||
:key="selectedType"
|
||||
:bot-id="botId"
|
||||
:channel-item="selectedItem"
|
||||
@saved="refetch()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from '@memoh/ui'
|
||||
import { useBotChannels, type BotChannelItem } from '@/composables/api/useChannels'
|
||||
import ChannelSettingsPanel from './channel-settings-panel.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
}>()
|
||||
|
||||
const botIdRef = computed(() => props.botId)
|
||||
const { data: channels, isLoading, refetch } = useBotChannels(botIdRef)
|
||||
|
||||
const selectedType = ref<string | null>(null)
|
||||
const addPopoverOpen = ref(false)
|
||||
|
||||
const allChannels = computed<BotChannelItem[]>(() => channels.value ?? [])
|
||||
const configuredChannels = computed(() => allChannels.value.filter((c) => c.configured))
|
||||
const unconfiguredChannels = computed(() => allChannels.value.filter((c) => !c.configured))
|
||||
|
||||
const selectedItem = computed(() =>
|
||||
allChannels.value.find((c) => c.meta.type === selectedType.value) ?? null,
|
||||
)
|
||||
|
||||
// 自动选中第一个已配置的渠道
|
||||
watch(configuredChannels, (list) => {
|
||||
if (list.length > 0 && !selectedType.value) {
|
||||
selectedType.value = list[0].meta.type
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function addChannel(type: string) {
|
||||
addPopoverOpen.value = false
|
||||
selectedType.value = type
|
||||
}
|
||||
|
||||
function channelIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
telegram: 'TG',
|
||||
feishu: '飞',
|
||||
}
|
||||
return icons[type] ?? type.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
function channelBadgeClass(type: string): string {
|
||||
const classes: Record<string, string> = {
|
||||
telegram: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
feishu: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300',
|
||||
}
|
||||
return classes[type] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<!-- Chat Model -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.settings.chatModel') }}</Label>
|
||||
<ModelSelect
|
||||
v-model="form.chat_model_id"
|
||||
:models="models"
|
||||
:providers="providers"
|
||||
model-type="chat"
|
||||
:placeholder="$t('bots.settings.chatModel')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Memory Model -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.settings.memoryModel') }}</Label>
|
||||
<ModelSelect
|
||||
v-model="form.memory_model_id"
|
||||
:models="models"
|
||||
:providers="providers"
|
||||
model-type="chat"
|
||||
:placeholder="$t('bots.settings.memoryModel')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Embedding Model -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.settings.embeddingModel') }}</Label>
|
||||
<ModelSelect
|
||||
v-model="form.embedding_model_id"
|
||||
:models="models"
|
||||
:providers="providers"
|
||||
model-type="embedding"
|
||||
:placeholder="$t('bots.settings.embeddingModel')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Max Context Load Time -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.settings.maxContextLoadTime') }}</Label>
|
||||
<Input
|
||||
v-model.number="form.max_context_load_time"
|
||||
type="number"
|
||||
:min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.settings.language') }}</Label>
|
||||
<Input
|
||||
v-model="form.language"
|
||||
type="text"
|
||||
/>
|
||||
</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 />
|
||||
|
||||
<!-- Save -->
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
:disabled="!hasChanges || isLoading"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Spinner v-if="isLoading" />
|
||||
{{ $t('bots.settings.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Label,
|
||||
Input,
|
||||
Switch,
|
||||
Button,
|
||||
Separator,
|
||||
Spinner,
|
||||
} from '@memoh/ui'
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ModelSelect from './model-select.vue'
|
||||
import { useBotSettings, useUpdateBotSettings, type BotSettings } from '@/composables/api/useBotSettings'
|
||||
import { useAllModels } from '@/composables/api/useModels'
|
||||
import { useAllProviders } from '@/composables/api/useProviders'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const botIdRef = computed(() => props.botId) as Ref<string>
|
||||
|
||||
// ---- Data ----
|
||||
const { data: settings } = useBotSettings(botIdRef)
|
||||
const { data: modelData } = useAllModels()
|
||||
const { data: providerData } = useAllProviders()
|
||||
const { mutateAsync: updateSettings, isLoading } = useUpdateBotSettings(botIdRef)
|
||||
|
||||
const models = computed(() => modelData.value ?? [])
|
||||
const providers = computed(() => providerData.value ?? [])
|
||||
|
||||
// ---- Form ----
|
||||
const form = reactive<BotSettings>({
|
||||
chat_model_id: '',
|
||||
memory_model_id: '',
|
||||
embedding_model_id: '',
|
||||
max_context_load_time: 0,
|
||||
language: '',
|
||||
allow_guest: false,
|
||||
})
|
||||
|
||||
// 同步服务端数据到表单
|
||||
watch(settings, (val) => {
|
||||
if (val) {
|
||||
form.chat_model_id = val.chat_model_id ?? ''
|
||||
form.memory_model_id = val.memory_model_id ?? ''
|
||||
form.embedding_model_id = val.embedding_model_id ?? ''
|
||||
form.max_context_load_time = val.max_context_load_time ?? 0
|
||||
form.language = val.language ?? ''
|
||||
form.allow_guest = val.allow_guest ?? false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (!settings.value) return true
|
||||
const s = settings.value
|
||||
return (
|
||||
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)
|
||||
)
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await updateSettings({ ...form })
|
||||
toast.success(t('bots.settings.saveSuccess'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ channelItem.meta.display_name }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ channelItem.meta.type }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge :variant="channelItem.configured ? 'default' : 'secondary'">
|
||||
{{ channelItem.configured ? $t('bots.channels.configured') : $t('bots.channels.notConfigured') }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Credentials form (dynamic from config_schema) -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-medium">
|
||||
{{ $t('bots.channels.credentials') }}
|
||||
</h4>
|
||||
|
||||
<div
|
||||
v-for="(field, key) in orderedFields"
|
||||
:key="key"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>
|
||||
{{ field.title || key }}
|
||||
<span
|
||||
v-if="!field.required"
|
||||
class="text-xs text-muted-foreground ml-1"
|
||||
>({{ $t('common.optional') }})</span>
|
||||
</Label>
|
||||
<p
|
||||
v-if="field.description"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ field.description }}
|
||||
</p>
|
||||
|
||||
<!-- Secret field -->
|
||||
<div
|
||||
v-if="field.type === 'secret'"
|
||||
class="relative"
|
||||
>
|
||||
<Input
|
||||
v-model="form.credentials[key]"
|
||||
:type="visibleSecrets[key] ? 'text' : 'password'"
|
||||
:placeholder="field.example ? String(field.example) : ''"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
@click="visibleSecrets[key] = !visibleSecrets[key]"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', visibleSecrets[key] ? 'eye-slash' : 'eye']"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Boolean field -->
|
||||
<Switch
|
||||
v-else-if="field.type === 'bool'"
|
||||
:model-value="!!form.credentials[key]"
|
||||
@update:model-value="(val) => form.credentials[key] = !!val"
|
||||
/>
|
||||
|
||||
<!-- Number field -->
|
||||
<Input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model.number="form.credentials[key]"
|
||||
type="number"
|
||||
:placeholder="field.example ? String(field.example) : ''"
|
||||
/>
|
||||
|
||||
<!-- Enum field -->
|
||||
<Select
|
||||
v-else-if="field.type === 'enum' && field.enum"
|
||||
:model-value="String(form.credentials[key] || '')"
|
||||
@update:model-value="(val) => form.credentials[key] = val"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="field.title" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in field.enum"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- String field (default) -->
|
||||
<Input
|
||||
v-else
|
||||
v-model="form.credentials[key]"
|
||||
type="text"
|
||||
:placeholder="field.example ? String(field.example) : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ $t('bots.channels.status') }}</Label>
|
||||
<Switch
|
||||
:model-value="form.status === 'active'"
|
||||
@update:model-value="(val) => form.status = val ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save -->
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
:disabled="isLoading"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Spinner v-if="isLoading" />
|
||||
{{ $t('bots.channels.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Separator,
|
||||
Switch,
|
||||
Spinner,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@memoh/ui'
|
||||
import { reactive, watch, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useUpsertBotChannel,
|
||||
type BotChannelItem,
|
||||
type FieldSchema,
|
||||
} from '@/composables/api/useChannels'
|
||||
import { ApiError } from '@/utils/request'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
channelItem: BotChannelItem
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const botIdRef = computed(() => props.botId) as Ref<string>
|
||||
const { mutateAsync: upsertChannel, isLoading } = useUpsertBotChannel(botIdRef)
|
||||
|
||||
// ---- Form state ----
|
||||
|
||||
const form = reactive<{
|
||||
credentials: Record<string, unknown>
|
||||
status: string
|
||||
}>({
|
||||
credentials: {},
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const visibleSecrets = reactive<Record<string, boolean>>({})
|
||||
|
||||
// Schema fields sorted: required first
|
||||
const orderedFields = computed(() => {
|
||||
const fields = props.channelItem.meta.config_schema?.fields ?? {}
|
||||
const entries = Object.entries(fields)
|
||||
entries.sort(([, a], [, b]) => {
|
||||
if (a.required && !b.required) return -1
|
||||
if (!a.required && b.required) return 1
|
||||
return 0
|
||||
})
|
||||
return Object.fromEntries(entries) as Record<string, FieldSchema>
|
||||
})
|
||||
|
||||
// 初始化表单
|
||||
function initForm() {
|
||||
const schema = props.channelItem.meta.config_schema?.fields ?? {}
|
||||
const existingCredentials = props.channelItem.config?.credentials ?? {}
|
||||
|
||||
const creds: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(schema)) {
|
||||
creds[key] = existingCredentials[key] ?? ''
|
||||
}
|
||||
form.credentials = creds
|
||||
form.status = props.channelItem.config?.status ?? 'active'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.channelItem,
|
||||
() => initForm(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 客户端校验必填字段
|
||||
function validateRequired(): boolean {
|
||||
const schema = props.channelItem.meta.config_schema?.fields ?? {}
|
||||
for (const [key, field] of Object.entries(schema)) {
|
||||
if (field.required) {
|
||||
const val = form.credentials[key]
|
||||
if (!val || (typeof val === 'string' && val.trim() === '')) {
|
||||
toast.error(t('bots.channels.requiredField', { field: field.title || key }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!validateRequired()) return
|
||||
|
||||
try {
|
||||
const credentials: Record<string, unknown> = {}
|
||||
for (const [key, val] of Object.entries(form.credentials)) {
|
||||
if (val !== '' && val !== undefined && val !== null) {
|
||||
credentials[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
await upsertChannel({
|
||||
platform: props.channelItem.meta.type,
|
||||
data: {
|
||||
credentials,
|
||||
status: form.status,
|
||||
},
|
||||
})
|
||||
toast.success(t('bots.channels.saveSuccess'))
|
||||
emit('saved')
|
||||
} catch (err) {
|
||||
let detail = ''
|
||||
if (err instanceof ApiError && err.body) {
|
||||
const body = err.body as Record<string, unknown>
|
||||
detail = String(body.message || body.error || '')
|
||||
} else if (err instanceof Error) {
|
||||
detail = err.message
|
||||
}
|
||||
toast.error(detail ? `${t('bots.channels.saveFailed')}: ${detail}` : t('bots.channels.saveFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogTrigger as-child>
|
||||
<slot name="trigger">
|
||||
<Button variant="default">
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'plus']"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('bots.createBot') }}
|
||||
</Button>
|
||||
</slot>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<form @submit="handleSubmit">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isEdit ? $t('bots.editBot') : $t('bots.createBot') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Separator class="my-4" />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Display Name -->
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="display_name"
|
||||
>
|
||||
<FormItem>
|
||||
<Label class="mb-2">{{ $t('bots.displayName') }}</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="$t('bots.displayNamePlaceholder')"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Avatar URL -->
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="avatar_url"
|
||||
>
|
||||
<FormItem>
|
||||
<Label class="mb-2">
|
||||
{{ $t('bots.avatarUrl') }}
|
||||
<span class="text-muted-foreground text-xs ml-1">({{ $t('common.optional') }})</span>
|
||||
</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="$t('bots.avatarUrlPlaceholder')"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Type (only for create) -->
|
||||
<FormField
|
||||
v-if="!isEdit"
|
||||
v-slot="{ componentField }"
|
||||
name="type"
|
||||
>
|
||||
<FormItem>
|
||||
<Label class="mb-2">
|
||||
{{ $t('bots.type') }}
|
||||
<span class="text-muted-foreground text-xs ml-1">({{ $t('common.optional') }})</span>
|
||||
</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="$t('bots.typePlaceholder')"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Active (only for edit) -->
|
||||
<FormField
|
||||
v-if="isEdit"
|
||||
v-slot="{ componentField }"
|
||||
name="is_active"
|
||||
>
|
||||
<FormItem class="flex items-center justify-between">
|
||||
<Label>{{ $t('bots.active') }}</Label>
|
||||
<Switch
|
||||
v-model="componentField.modelValue"
|
||||
@update:model-value="componentField['onUpdate:modelValue']"
|
||||
/>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
<DialogClose as-child>
|
||||
<Button variant="outline">
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="!form.meta.value.valid || submitLoading"
|
||||
>
|
||||
<Spinner v-if="submitLoading" />
|
||||
{{ isEdit ? $t('common.save') : $t('bots.createBot') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
Button,
|
||||
FormField,
|
||||
FormControl,
|
||||
FormItem,
|
||||
Separator,
|
||||
Label,
|
||||
Spinner,
|
||||
Switch,
|
||||
} from '@memoh/ui'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import z from 'zod'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useCreateBot, useUpdateBot, type BotInfo } from '@/composables/api/useBots'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const editBot = defineModel<BotInfo | null>('editBot', { default: null })
|
||||
|
||||
const isEdit = computed(() => !!editBot.value)
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
display_name: z.string().min(1),
|
||||
avatar_url: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
is_active: z.coerce.boolean().optional(),
|
||||
}))
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
})
|
||||
|
||||
const { mutate: createBot, isLoading: createLoading } = useCreateBot()
|
||||
const { mutate: updateBot, isLoading: updateLoading } = useUpdateBot()
|
||||
|
||||
const submitLoading = computed(() => createLoading.value || updateLoading.value)
|
||||
|
||||
// 打开弹窗时,如果是编辑模式则填入数据,否则重置
|
||||
watch(open, (val) => {
|
||||
if (val && editBot.value) {
|
||||
form.resetForm({
|
||||
values: {
|
||||
display_name: editBot.value.display_name,
|
||||
avatar_url: editBot.value.avatar_url || '',
|
||||
is_active: editBot.value.is_active,
|
||||
},
|
||||
})
|
||||
} else if (!val) {
|
||||
form.resetForm()
|
||||
editBot.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
if (isEdit.value && editBot.value) {
|
||||
await updateBot({
|
||||
id: editBot.value.id,
|
||||
display_name: values.display_name,
|
||||
avatar_url: values.avatar_url || undefined,
|
||||
is_active: values.is_active,
|
||||
})
|
||||
} else {
|
||||
await createBot({
|
||||
display_name: values.display_name,
|
||||
avatar_url: values.avatar_url || undefined,
|
||||
type: values.type || undefined,
|
||||
is_active: true,
|
||||
})
|
||||
}
|
||||
open.value = false
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
class="w-full justify-between font-normal"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ displayLabel || placeholder }}
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'magnifying-glass']"
|
||||
class="ml-2 size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
class="w-[--reka-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<!-- Search input -->
|
||||
<div class="flex items-center border-b px-3">
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'magnifying-glass']"
|
||||
class="mr-2 size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
:placeholder="$t('bots.settings.searchModel')"
|
||||
class="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Model list -->
|
||||
<ScrollArea class="max-h-64">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('bots.settings.noModel') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.providerName"
|
||||
class="p-1"
|
||||
>
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{{ group.providerName }}
|
||||
</div>
|
||||
<button
|
||||
v-for="model in group.models"
|
||||
:key="model.model_id"
|
||||
class="relative flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
|
||||
:class="{ 'bg-accent': selected === model.model_id }"
|
||||
@click="selectModel(model.model_id)"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
v-if="selected === model.model_id"
|
||||
:icon="['fas', 'check']"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span class="truncate">{{ model.name || model.model_id }}</span>
|
||||
<span
|
||||
v-if="model.name"
|
||||
class="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{{ model.model_id }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
Button,
|
||||
ScrollArea,
|
||||
} from '@memoh/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ModelInfo } from '@memoh/shared'
|
||||
import type { ProviderWithId } from '@/composables/api/useProviders'
|
||||
|
||||
const props = defineProps<{
|
||||
models: ModelInfo[]
|
||||
providers: ProviderWithId[]
|
||||
modelType: 'chat' | 'embedding'
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const selected = defineModel<string>({ default: '' })
|
||||
const searchTerm = ref('')
|
||||
const open = ref(false)
|
||||
|
||||
// 打开时清空搜索
|
||||
watch(open, (val) => {
|
||||
if (val) searchTerm.value = ''
|
||||
})
|
||||
|
||||
const typeFilteredModels = computed(() =>
|
||||
props.models.filter((m) => m.type === props.modelType),
|
||||
)
|
||||
|
||||
const providerMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const p of props.providers) {
|
||||
map.set(p.id, p.name ?? p.id)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// 搜索过滤后按 Provider 分组
|
||||
const filteredGroups = computed(() => {
|
||||
const keyword = searchTerm.value.trim().toLowerCase()
|
||||
const models = keyword
|
||||
? typeFilteredModels.value.filter(
|
||||
(m) =>
|
||||
m.model_id.toLowerCase().includes(keyword)
|
||||
|| (m.name?.toLowerCase().includes(keyword) ?? false),
|
||||
)
|
||||
: typeFilteredModels.value
|
||||
|
||||
const groups = new Map<string, { providerName: string; models: ModelInfo[] }>()
|
||||
for (const model of models) {
|
||||
const pid = model.llm_provider_id
|
||||
const providerName = providerMap.value.get(pid) ?? pid
|
||||
if (!groups.has(pid)) {
|
||||
groups.set(pid, { providerName, models: [] })
|
||||
}
|
||||
groups.get(pid)!.models.push(model)
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
})
|
||||
|
||||
// 显示选中模型的名称
|
||||
const displayLabel = computed(() => {
|
||||
if (!selected.value) return ''
|
||||
const model = typeFilteredModels.value.find((m) => m.model_id === selected.value)
|
||||
return model?.name || model?.model_id || selected.value
|
||||
})
|
||||
|
||||
function selectModel(modelId: string) {
|
||||
selected.value = modelId
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<section class="p-6 max-w-7xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<Avatar class="size-16 shrink-0">
|
||||
<AvatarImage
|
||||
v-if="bot?.avatar_url"
|
||||
:src="bot.avatar_url"
|
||||
:alt="bot.display_name"
|
||||
/>
|
||||
<AvatarFallback class="text-xl">
|
||||
{{ avatarFallback }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold tracking-tight">
|
||||
{{ bot?.display_name || botId }}
|
||||
</h2>
|
||||
<div class="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge
|
||||
v-if="bot"
|
||||
:variant="bot.is_active ? 'default' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ bot.is_active ? $t('bots.active') : $t('bots.inactive') }}
|
||||
</Badge>
|
||||
<span v-if="bot?.type">{{ bot.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<Tabs
|
||||
v-model="activeTab"
|
||||
class="w-full"
|
||||
>
|
||||
<TabsList class="w-full justify-start">
|
||||
<TabsTrigger value="overview">
|
||||
{{ $t('bots.tabs.overview') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="memory">
|
||||
{{ $t('bots.tabs.memory') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="channels">
|
||||
{{ $t('bots.tabs.channels') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp">
|
||||
{{ $t('bots.tabs.mcp') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subagents">
|
||||
{{ $t('bots.tabs.subagents') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history">
|
||||
{{ $t('bots.tabs.history') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
{{ $t('bots.tabs.settings') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="overview"
|
||||
class="mt-6"
|
||||
>
|
||||
<!-- TODO: Overview content -->
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="memory"
|
||||
class="mt-6"
|
||||
>
|
||||
<!-- TODO: Memory content -->
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="channels"
|
||||
class="mt-6"
|
||||
>
|
||||
<BotChannels :bot-id="botId" />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="mcp"
|
||||
class="mt-6"
|
||||
>
|
||||
<!-- TODO: MCP content -->
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="subagents"
|
||||
class="mt-6"
|
||||
>
|
||||
<!-- TODO: Subagents content -->
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="history"
|
||||
class="mt-6"
|
||||
>
|
||||
<!-- TODO: History content -->
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="settings"
|
||||
class="mt-6"
|
||||
>
|
||||
<BotSettings :bot-id="botId" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Badge,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from '@memoh/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useBotDetail } from '@/composables/api/useBots'
|
||||
import BotSettings from './components/bot-settings.vue'
|
||||
import BotChannels from './components/bot-channels.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const botId = computed(() => route.params.botId as string)
|
||||
|
||||
const { data: bot } = useBotDetail(botId)
|
||||
|
||||
// 加载到 bot 数据后,用名称替换 breadcrumb 中的 botId
|
||||
watch(bot, (val) => {
|
||||
if (val?.display_name) {
|
||||
route.meta.breadcrumb = () => val.display_name
|
||||
}
|
||||
})
|
||||
|
||||
const activeTab = ref('overview')
|
||||
|
||||
const avatarFallback = computed(() => {
|
||||
const name = bot.value?.display_name || botId.value || ''
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<section class="p-6 max-w-7xl mx-auto">
|
||||
<!-- Header: search + create -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-semibold tracking-tight">
|
||||
{{ $t('bots.title') }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'magnifying-glass']"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground size-3.5"
|
||||
/>
|
||||
<Input
|
||||
v-model="searchText"
|
||||
:placeholder="$t('bots.searchPlaceholder')"
|
||||
class="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
<CreateBot
|
||||
v-model:open="dialogOpen"
|
||||
v-model:edit-bot="editingBot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bot grid -->
|
||||
<div
|
||||
v-if="filteredBots.length > 0"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
<BotCard
|
||||
v-for="bot in filteredBots"
|
||||
:key="bot.id"
|
||||
:bot="bot"
|
||||
:delete-loading="deleteLoading"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<Empty
|
||||
v-else-if="!isLoading"
|
||||
class="mt-20 flex flex-col items-center justify-center"
|
||||
>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FontAwesomeIcon :icon="['fas', 'robot']" />
|
||||
</EmptyMedia>
|
||||
</EmptyHeader>
|
||||
<EmptyTitle>{{ $t('bots.emptyTitle') }}</EmptyTitle>
|
||||
<EmptyDescription>{{ $t('bots.emptyDescription') }}</EmptyDescription>
|
||||
<EmptyContent />
|
||||
</Empty>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Input,
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@memoh/ui'
|
||||
import { ref, computed } from 'vue'
|
||||
import BotCard from './components/bot-card.vue'
|
||||
import CreateBot from './components/create-bot.vue'
|
||||
import { useBotList, useDeleteBot, type BotInfo } from '@/composables/api/useBots'
|
||||
|
||||
const searchText = ref('')
|
||||
const dialogOpen = ref(false)
|
||||
const editingBot = ref<BotInfo | null>(null)
|
||||
|
||||
const { data: botData, status } = useBotList()
|
||||
const { mutate: deleteBot, isLoading: deleteLoading } = useDeleteBot()
|
||||
|
||||
const isLoading = computed(() => status.value === 'loading')
|
||||
|
||||
const filteredBots = computed(() => {
|
||||
const list = botData.value ?? []
|
||||
const keyword = searchText.value.trim().toLowerCase()
|
||||
if (!keyword) return list
|
||||
return list.filter((bot) =>
|
||||
bot.display_name?.toLowerCase().includes(keyword)
|
||||
|| bot.id.toLowerCase().includes(keyword)
|
||||
|| bot.type?.toLowerCase().includes(keyword),
|
||||
)
|
||||
})
|
||||
|
||||
function handleEdit(bot: BotInfo) {
|
||||
editingBot.value = bot
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await deleteBot(id)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||
import { h } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { i18nRef } from './i18n'
|
||||
|
||||
const routes = [
|
||||
@@ -33,6 +35,27 @@ const routes = [
|
||||
meta: {
|
||||
breadcrumb: i18nRef('home.title')
|
||||
}
|
||||
}, {
|
||||
path: 'bots',
|
||||
component: { render: () => h(RouterView) },
|
||||
meta: {
|
||||
breadcrumb: i18nRef('sidebar.bots')
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'bots',
|
||||
path: '',
|
||||
component: () => import('@/pages/bots/index.vue'),
|
||||
},
|
||||
{
|
||||
name: 'bot-detail',
|
||||
path: ':botId',
|
||||
component: () => import('@/pages/bots/detail.vue'),
|
||||
meta: {
|
||||
breadcrumb: (route: RouteLocationNormalized) => route.params.botId,
|
||||
},
|
||||
},
|
||||
],
|
||||
}, {
|
||||
name: 'models',
|
||||
path: 'models',
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useColorMode, useStorage } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export interface Settings {
|
||||
language: Locale
|
||||
theme: 'light' | 'dark'
|
||||
language: Locale;
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
@@ -14,8 +14,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const language = useStorage<Locale>('language', 'zh')
|
||||
const theme = useStorage<'light' | 'dark'>('theme', 'light')
|
||||
|
||||
// 初始化时将 colorMode 与 theme 同步,避免 useColorMode 默认 'auto' 导致闪烁
|
||||
// 立即同步持久化的设置到运行时状态
|
||||
colorMode.value = theme.value
|
||||
i18n.locale.value = language.value
|
||||
|
||||
const setLanguage = (value: Locale) => {
|
||||
language.value = value
|
||||
@@ -26,11 +27,11 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
theme.value = value
|
||||
colorMode.value = value
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
language,
|
||||
theme,
|
||||
setLanguage,
|
||||
setTheme,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user