feat(web): add sunagent, history, skills page

This commit is contained in:
Acbox
2026-02-23 15:49:28 +08:00
parent a440bf122b
commit 8590c53f3d
9 changed files with 1362 additions and 5 deletions
+11 -1
View File
@@ -315,7 +315,17 @@ func (h *MessageHandler) StreamMessageEvents(c echo.Context) error {
}
}
// DeleteMessages clears all persisted bot-level history messages.
// DeleteMessages godoc
// @Summary Delete all bot history messages
// @Description Clear all persisted bot-level history messages
// @Tags messages
// @Produce json
// @Param bot_id path string true "Bot ID"
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/messages [delete]
func (h *MessageHandler) DeleteMessages(c echo.Context) error {
channelIdentityID, err := h.requireChannelIdentityID(c)
if err != nil {
File diff suppressed because one or more lines are too long
+59
View File
@@ -308,6 +308,7 @@
"mcp": "MCP",
"subagents": "Subagents",
"history": "History",
"skills": "Skills",
"settings": "Settings"
},
"container": {
@@ -442,6 +443,64 @@
"noConversations": "No history conversations found",
"idCopied": "ID copied to clipboard",
"deleteConfirm": "Are you sure you want to delete this memory? This cannot be undone."
},
"skills": {
"title": "Skills",
"addSkill": "New Skill",
"emptyTitle": "No Skills",
"emptyDescription": "Click above to create a new skill",
"description": "Description",
"descriptionPlaceholder": "Enter skill description",
"content": "Content",
"contentPlaceholder": "Enter skill content/prompt",
"deleteConfirm": "Are you sure you want to delete this skill?",
"deleteSuccess": "Skill deleted",
"deleteFailed": "Failed to delete skill",
"saveSuccess": "Skill saved",
"saveFailed": "Failed to save skill",
"loadFailed": "Failed to load skills"
},
"history": {
"title": "History",
"loadFailed": "Failed to load history",
"empty": "No history messages",
"loadMore": "Load More",
"messageCount": "{count} messages",
"selectAll": "Select all",
"deleteSelectedConfirm": "Are you sure you want to delete the selected {count} message(s)? This will clear all history and cannot be undone.",
"deleteSuccess": "Messages deleted",
"deleteFailed": "Failed to delete messages",
"role": {
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System"
},
"filter": "Filter by role",
"filterAll": "All",
"expandContent": "Expand",
"collapseContent": "Collapse"
},
"subagents": {
"title": "Subagents",
"subtitle": "Manage autonomous subagents for this bot.",
"add": "Create Subagent",
"emptyTitle": "No Subagents",
"emptyDescription": "Click above to create a subagent",
"description": "Description",
"descriptionPlaceholder": "Enter subagent description",
"deleteConfirm": "Are you sure you want to delete this subagent?",
"deleteSuccess": "Subagent deleted",
"deleteFailed": "Failed to delete subagent",
"saveSuccess": "Subagent saved",
"saveFailed": "Failed to save subagent",
"loadFailed": "Failed to load subagents",
"viewContext": "View Context",
"contextTitle": "Subagent Context",
"contextEmpty": "No context messages",
"messagesCount": "{count} messages",
"usage": "Usage",
"skills": "Skills"
}
}
}
+59
View File
@@ -304,6 +304,7 @@
"mcp": "MCP",
"subagents": "子智能体",
"history": "对话历史",
"skills": "技能",
"settings": "设置"
},
"container": {
@@ -438,6 +439,64 @@
"noConversations": "暂无历史对话",
"idCopied": "ID 已复制",
"deleteConfirm": "确定要删除这条记忆吗?此操作无法撤销。"
},
"skills": {
"title": "技能",
"addSkill": "新建技能",
"emptyTitle": "暂无技能",
"emptyDescription": "点击上方按钮创建新技能",
"description": "描述",
"descriptionPlaceholder": "输入技能描述",
"content": "内容",
"contentPlaceholder": "输入技能内容/提示词",
"deleteConfirm": "确定要删除这个技能吗?",
"deleteSuccess": "技能已删除",
"deleteFailed": "删除技能失败",
"saveSuccess": "技能已保存",
"saveFailed": "保存技能失败",
"loadFailed": "加载技能失败"
},
"history": {
"title": "对话历史",
"loadFailed": "加载历史消息失败",
"empty": "暂无历史消息",
"loadMore": "加载更多",
"messageCount": "{count} 条消息",
"selectAll": "全选",
"deleteSelectedConfirm": "确定要删除选中的 {count} 条消息吗?此操作将清空所有历史记录,且无法撤销。",
"deleteSuccess": "消息已删除",
"deleteFailed": "删除消息失败",
"role": {
"user": "用户",
"assistant": "助手",
"tool": "工具",
"system": "系统"
},
"filter": "按角色筛选",
"filterAll": "全部",
"expandContent": "展开",
"collapseContent": "收起"
},
"subagents": {
"title": "子智能体",
"subtitle": "管理当前 Bot 的自治子智能体。",
"add": "创建子智能体",
"emptyTitle": "暂无子智能体",
"emptyDescription": "点击上方按钮创建子智能体",
"description": "描述",
"descriptionPlaceholder": "输入子智能体描述",
"deleteConfirm": "确定要删除这个子智能体吗?",
"deleteSuccess": "子智能体已删除",
"deleteFailed": "删除子智能体失败",
"saveSuccess": "子智能体已保存",
"saveFailed": "保存子智能体失败",
"loadFailed": "加载子智能体失败",
"viewContext": "查看上下文",
"contextTitle": "子智能体上下文",
"contextEmpty": "暂无上下文消息",
"messagesCount": "{count} 条消息",
"usage": "用量",
"skills": "技能"
}
}
}
@@ -0,0 +1,216 @@
<template>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h3 class="text-lg font-medium">
{{ $t('bots.history.title') }}
</h3>
<Badge
v-if="messages.length"
variant="secondary"
class="text-xs"
>
{{ $t('bots.history.messageCount', { count: messages.length }) }}
</Badge>
</div>
<div class="flex items-center gap-2">
<NativeSelect
v-model="roleFilter"
class="h-9 w-32 text-sm"
>
<option value="">
{{ $t('bots.history.filterAll') }}
</option>
<option value="user">
{{ $t('bots.history.role.user') }}
</option>
<option value="assistant">
{{ $t('bots.history.role.assistant') }}
</option>
<option value="tool">
{{ $t('bots.history.role.tool') }}
</option>
<option value="system">
{{ $t('bots.history.role.system') }}
</option>
</NativeSelect>
<Button
variant="outline"
size="sm"
:disabled="isLoading"
@click="handleRefresh"
>
<Spinner
v-if="isLoading"
class="mr-2 size-4"
/>
{{ $t('common.refresh') }}
</Button>
</div>
</div>
<!-- Loading -->
<div
v-if="isLoading && messages.length === 0"
class="flex items-center justify-center py-8 text-sm text-muted-foreground"
>
<Spinner class="mr-2" />
{{ $t('common.loading') }}
</div>
<!-- Empty -->
<div
v-else-if="!isLoading && messages.length === 0"
class="flex flex-col items-center justify-center py-12 text-center"
>
<div class="rounded-full bg-muted p-3 mb-4">
<FontAwesomeIcon
:icon="['fas', 'message']"
class="size-6 text-muted-foreground"
/>
</div>
<p class="text-sm text-muted-foreground">
{{ $t('bots.history.empty') }}
</p>
</div>
<!-- Messages -->
<template v-else>
<MessageList :messages="pagedMessages" />
<!-- Pagination -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between pt-4"
>
<span class="text-sm text-muted-foreground">
{{ paginationSummary }}
</span>
<Pagination
:total="filteredMessages.length"
:items-per-page="PAGE_SIZE"
:sibling-count="1"
:page="currentPage"
show-edges
@update:page="currentPage = $event"
>
<PaginationContent v-slot="{ items }">
<PaginationFirst />
<PaginationPrevious />
<template
v-for="(item, index) in items"
:key="index"
>
<PaginationEllipsis
v-if="item.type === 'ellipsis'"
:index="index"
/>
<PaginationItem
v-else
:value="item.value"
/>
</template>
<PaginationNext />
<PaginationLast />
</PaginationContent>
</Pagination>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import {
Button, Badge, Spinner, NativeSelect,
Pagination, PaginationContent, PaginationEllipsis,
PaginationFirst, PaginationItem, PaginationLast,
PaginationNext, PaginationPrevious,
} from '@memoh/ui'
import MessageList from './message-list.vue'
import {
getBotsByBotIdMessages,
} from '@memoh/sdk'
import type { MessageMessage } from '@memoh/sdk'
import { resolveApiErrorMessage } from '@/utils/api-error'
const props = defineProps<{
botId: string
}>()
const { t } = useI18n()
const isLoading = ref(false)
const messages = ref<MessageMessage[]>([])
const roleFilter = ref('')
const currentPage = ref(1)
const PAGE_SIZE = 20
const filteredMessages = computed(() => {
if (!roleFilter.value) return messages.value
return messages.value.filter(m => m.role === roleFilter.value)
})
const totalPages = computed(() => Math.ceil(filteredMessages.value.length / PAGE_SIZE))
const pagedMessages = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE
return filteredMessages.value.slice(start, start + PAGE_SIZE)
})
const paginationSummary = computed(() => {
const total = filteredMessages.value.length
if (total === 0) return ''
const start = (currentPage.value - 1) * PAGE_SIZE + 1
const end = Math.min(currentPage.value * PAGE_SIZE, total)
return `${start}-${end} / ${total}`
})
watch(roleFilter, () => {
currentPage.value = 1
})
async function fetchAllHistory() {
if (!props.botId) return
isLoading.value = true
messages.value = []
try {
let before: string | undefined
let hasMore = true
while (hasMore) {
const { data } = await getBotsByBotIdMessages({
path: { bot_id: props.botId },
query: { limit: 100, before },
throwOnError: true,
})
const items = data?.items || []
if (items.length === 0) {
hasMore = false
} else {
messages.value.push(...items)
before = items[items.length - 1]?.created_at
hasMore = items.length >= 100
}
}
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.history.loadFailed')))
} finally {
isLoading.value = false
}
}
async function handleRefresh() {
currentPage.value = 1
await fetchAllHistory()
}
onMounted(() => {
fetchAllHistory()
})
</script>
@@ -0,0 +1,306 @@
<template>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium">
{{ $t('bots.skills.title') }}
</h3>
</div>
<Button
size="sm"
@click="handleCreate"
>
<FontAwesomeIcon
:icon="['fas', 'plus']"
class="mr-2"
/>
{{ $t('bots.skills.addSkill') }}
</Button>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-8 text-sm text-muted-foreground"
>
<Spinner class="mr-2" />
{{ $t('common.loading') }}
</div>
<!-- Empty State -->
<div
v-else-if="!skills.length"
class="flex flex-col items-center justify-center py-12 text-center"
>
<div class="rounded-full bg-muted p-3 mb-4">
<FontAwesomeIcon
:icon="['fas', 'bolt']"
class="size-6 text-muted-foreground"
/>
</div>
<h3 class="text-lg font-medium">
{{ $t('bots.skills.emptyTitle') }}
</h3>
<p class="text-sm text-muted-foreground mt-1">
{{ $t('bots.skills.emptyDescription') }}
</p>
</div>
<!-- Skills Grid -->
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<Card
v-for="skill in skills"
:key="skill.name"
class="flex flex-col"
>
<CardHeader class="pb-3">
<div class="flex items-start justify-between gap-2">
<CardTitle
class="text-base truncate"
:title="skill.name"
>
{{ skill.name }}
</CardTitle>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
class="size-8 p-0"
:title="$t('common.edit')"
@click="handleEdit(skill)"
>
<FontAwesomeIcon
:icon="['fas', 'pen-to-square']"
class="size-3.5"
/>
</Button>
<ConfirmPopover
:message="$t('bots.skills.deleteConfirm')"
:loading="isDeleting && deletingName === skill.name"
@confirm="handleDelete(skill.name)"
>
<template #trigger>
<Button
variant="ghost"
size="sm"
class="size-8 p-0 text-destructive hover:text-destructive"
:disabled="isDeleting"
:title="$t('common.delete')"
>
<FontAwesomeIcon
:icon="['fas', 'trash']"
class="size-3.5"
/>
</Button>
</template>
</ConfirmPopover>
</div>
</div>
<CardDescription
class="line-clamp-2"
:title="skill.description"
>
{{ skill.description || '-' }}
</CardDescription>
</CardHeader>
<CardContent class="pb-4 grow">
<div class="rounded-md bg-muted p-2 text-xs font-mono text-muted-foreground line-clamp-4 break-all">
{{ skill.content }}
</div>
</CardContent>
</Card>
</div>
<!-- Edit Dialog -->
<Dialog v-model:open="isDialogOpen">
<DialogContent class="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{{ isEditing ? $t('common.edit') : $t('bots.skills.addSkill') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label>{{ $t('common.name') }}</Label>
<Input
v-model="draftSkill.name"
:placeholder="$t('common.namePlaceholder')"
:disabled="isEditing || isSaving"
/>
</div>
<div class="space-y-2">
<Label>{{ $t('bots.skills.description') }}</Label>
<Input
v-model="draftSkill.description"
:placeholder="$t('bots.skills.descriptionPlaceholder')"
:disabled="isSaving"
/>
</div>
<div class="space-y-2">
<Label>{{ $t('bots.skills.content') }}</Label>
<Textarea
v-model="draftSkill.content"
:placeholder="$t('bots.skills.contentPlaceholder')"
:disabled="isSaving"
class="min-h-[150px] font-mono text-sm"
/>
</div>
</div>
<DialogFooter>
<DialogClose as-child>
<Button
variant="outline"
:disabled="isSaving"
>
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
:disabled="!canSave || isSaving"
@click="handleSave"
>
<Spinner
v-if="isSaving"
class="mr-2 size-4"
/>
{{ $t('common.save') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import {
Button, Card, CardHeader, CardTitle, CardDescription, CardContent,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
Input, Textarea, Label, Spinner,
} from '@memoh/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import {
getBotsByBotIdContainerSkills,
postBotsByBotIdContainerSkills,
deleteBotsByBotIdContainerSkills,
type HandlersSkillItem,
} from '@memoh/sdk'
import { resolveApiErrorMessage } from '@/utils/api-error'
const props = defineProps<{
botId: string
}>()
const { t } = useI18n()
const isLoading = ref(false)
const isSaving = ref(false)
const isDeleting = ref(false)
const deletingName = ref('')
const skills = ref<HandlersSkillItem[]>([])
const isDialogOpen = ref(false)
const isEditing = ref(false)
const draftSkill = ref<HandlersSkillItem>({
name: '',
description: '',
content: '',
})
const canSave = computed(() => {
return (draftSkill.value.name || '').trim() && (draftSkill.value.content || '').trim()
})
async function fetchSkills() {
if (!props.botId) return
isLoading.value = true
try {
const { data } = await getBotsByBotIdContainerSkills({
path: { bot_id: props.botId },
throwOnError: true,
})
skills.value = data.skills || []
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.skills.loadFailed')))
} finally {
isLoading.value = false
}
}
function handleCreate() {
isEditing.value = false
draftSkill.value = {
name: '',
description: '',
content: '',
}
isDialogOpen.value = true
}
function handleEdit(skill: HandlersSkillItem) {
isEditing.value = true
draftSkill.value = {
name: skill.name || '',
description: skill.description || '',
content: skill.content || '',
metadata: skill.metadata,
}
isDialogOpen.value = true
}
async function handleSave() {
if (!canSave.value) return
isSaving.value = true
try {
await postBotsByBotIdContainerSkills({
path: { bot_id: props.botId },
body: {
skills: [{
name: draftSkill.value.name?.trim(),
description: draftSkill.value.description?.trim(),
content: draftSkill.value.content?.trim(),
metadata: draftSkill.value.metadata,
}],
},
throwOnError: true,
})
toast.success(t('bots.skills.saveSuccess'))
isDialogOpen.value = false
await fetchSkills()
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.skills.saveFailed')))
} finally {
isSaving.value = false
}
}
async function handleDelete(name?: string) {
if (!name) return
isDeleting.value = true
deletingName.value = name
try {
await deleteBotsByBotIdContainerSkills({
path: { bot_id: props.botId },
body: {
names: [name],
},
throwOnError: true,
})
toast.success(t('bots.skills.deleteSuccess'))
await fetchSkills()
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.skills.deleteFailed')))
} finally {
isDeleting.value = false
deletingName.value = ''
}
}
onMounted(() => {
fetchSkills()
})
</script>
@@ -0,0 +1,460 @@
<template>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium">
{{ $t('bots.subagents.title') }}
</h3>
<p class="text-sm text-muted-foreground">
{{ $t('bots.subagents.subtitle') }}
</p>
</div>
<Button
size="sm"
@click="handleCreate"
>
<FontAwesomeIcon
:icon="['fas', 'plus']"
class="mr-2"
/>
{{ $t('bots.subagents.add') }}
</Button>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-8 text-sm text-muted-foreground"
>
<Spinner class="mr-2" />
{{ $t('common.loading') }}
</div>
<!-- Empty State -->
<div
v-else-if="!subagents.length"
class="flex flex-col items-center justify-center py-12 text-center"
>
<div class="rounded-full bg-muted p-3 mb-4">
<FontAwesomeIcon
:icon="['fas', 'robot']"
class="size-6 text-muted-foreground"
/>
</div>
<h3 class="text-lg font-medium">
{{ $t('bots.subagents.emptyTitle') }}
</h3>
<p class="text-sm text-muted-foreground mt-1">
{{ $t('bots.subagents.emptyDescription') }}
</p>
</div>
<!-- Subagents List -->
<div
v-else
class="space-y-4"
>
<Card
v-for="agent in subagents"
:key="agent.id"
class="flex flex-col"
>
<CardHeader class="pb-3">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1 min-w-0">
<CardTitle class="text-lg flex items-center gap-2">
{{ agent.name }}
<Badge
variant="outline"
class="font-normal text-xs font-mono"
>
{{ agent.id }}
</Badge>
</CardTitle>
<CardDescription>{{ agent.description || '-' }}</CardDescription>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="outline"
size="sm"
class="mr-2"
@click="handleViewContext(agent)"
>
<FontAwesomeIcon
:icon="['fas', 'eye']"
class="mr-2"
/>
{{ $t('bots.subagents.viewContext') }}
<Badge
v-if="agent.messages && agent.messages.length"
variant="secondary"
class="ml-1.5 text-[10px]"
>
{{ agent.messages.length }}
</Badge>
</Button>
<Button
variant="ghost"
size="sm"
class="size-8 p-0"
:title="$t('common.edit')"
@click="handleEdit(agent)"
>
<FontAwesomeIcon
:icon="['fas', 'pen-to-square']"
class="size-3.5"
/>
</Button>
<ConfirmPopover
:message="$t('bots.subagents.deleteConfirm')"
:loading="isDeleting && deletingId === agent.id"
@confirm="handleDelete(agent.id)"
>
<template #trigger>
<Button
variant="ghost"
size="sm"
class="size-8 p-0 text-destructive hover:text-destructive"
:disabled="isDeleting"
:title="$t('common.delete')"
>
<FontAwesomeIcon
:icon="['fas', 'trash']"
class="size-3.5"
/>
</Button>
</template>
</ConfirmPopover>
</div>
</div>
</CardHeader>
<CardContent class="pb-4 space-y-3">
<!-- Skills -->
<div
v-if="agent.skills && agent.skills.length > 0"
class="space-y-1"
>
<span class="text-xs font-medium text-muted-foreground uppercase">{{ $t('bots.subagents.skills') }}</span>
<div class="flex flex-wrap gap-1 mt-1">
<Badge
v-for="skill in agent.skills"
:key="skill"
variant="secondary"
>
{{ skill }}
</Badge>
</div>
</div>
<!-- Usage summary -->
<div
v-if="hasUsageData(agent.usage)"
class="space-y-1"
>
<span class="text-xs font-medium text-muted-foreground uppercase">{{ $t('bots.subagents.usage') }}</span>
<div class="flex flex-wrap items-center gap-3 mt-1 text-xs text-muted-foreground">
<span
v-for="(val, key) in flattenUsage(agent.usage)"
:key="key"
class="inline-flex items-center gap-1 bg-muted px-2 py-0.5 rounded"
>
<span class="font-medium">{{ key }}:</span> {{ val }}
</span>
</div>
</div>
<!-- Meta -->
<div class="flex items-center gap-4 text-xs text-muted-foreground pt-2">
<span v-if="agent.created_at">
{{ $t('common.createdAt') }}: {{ formatDateTime(agent.created_at) }}
</span>
<span v-if="agent.messages">
{{ $t('bots.subagents.messagesCount', { count: agent.messages.length }) }}
</span>
</div>
</CardContent>
</Card>
</div>
<!-- Edit Dialog -->
<Dialog v-model:open="isDialogOpen">
<DialogContent class="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{{ isEditing ? $t('common.edit') : $t('bots.subagents.add') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label>{{ $t('common.name') }}</Label>
<Input
v-model="draftAgent.name"
:placeholder="$t('common.namePlaceholder')"
:disabled="isSaving"
/>
</div>
<div class="space-y-2">
<Label>{{ $t('bots.subagents.description') }}</Label>
<Textarea
v-model="draftAgent.description"
:placeholder="$t('bots.subagents.descriptionPlaceholder')"
:disabled="isSaving"
class="min-h-[100px]"
/>
</div>
</div>
<DialogFooter>
<DialogClose as-child>
<Button
variant="outline"
:disabled="isSaving"
>
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
:disabled="!canSave || isSaving"
@click="handleSave"
>
<Spinner
v-if="isSaving"
class="mr-2 size-4"
/>
{{ $t('common.save') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Context Dialog -->
<Dialog v-model:open="isContextDialogOpen">
<DialogContent class="max-w-3xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{{ $t('bots.subagents.contextTitle') }}
<Badge
v-if="contextMessages.length"
variant="secondary"
class="ml-2 text-xs"
>
{{ $t('bots.subagents.messagesCount', { count: contextMessages.length }) }}
</Badge>
</DialogTitle>
</DialogHeader>
<!-- Usage info -->
<div
v-if="hasUsageData(contextUsage)"
class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
>
<FontAwesomeIcon
:icon="['fas', 'chart-bar']"
class="size-3"
/>
<span
v-for="(val, key) in flattenUsage(contextUsage)"
:key="key"
class="bg-muted px-2 py-0.5 rounded"
>
{{ key }}: {{ val }}
</span>
</div>
<!-- Messages list -->
<ScrollArea class="flex-1 min-h-0 mt-2">
<div
v-if="contextMessages.length === 0"
class="flex flex-col items-center justify-center py-8 text-center"
>
<p class="text-sm text-muted-foreground">
{{ $t('bots.subagents.contextEmpty') }}
</p>
</div>
<MessageList
v-else
:messages="contextMessages"
/>
</ScrollArea>
<DialogFooter class="pt-4">
<DialogClose as-child>
<Button variant="outline">
{{ $t('common.cancel') }}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import {
Button, Card, CardHeader, CardTitle, CardDescription, CardContent,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
Input, Textarea, Label, Spinner, Badge, ScrollArea
} from '@memoh/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import MessageList from './message-list.vue'
import {
getBotsByBotIdSubagents,
postBotsByBotIdSubagents,
putBotsByBotIdSubagentsById,
deleteBotsByBotIdSubagentsById,
type SubagentSubagent
} from '@memoh/sdk'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { formatDateTime } from '@/utils/date-time'
const props = defineProps<{
botId: string
}>()
const { t } = useI18n()
const isLoading = ref(false)
const isSaving = ref(false)
const isDeleting = ref(false)
const deletingId = ref('')
const subagents = ref<SubagentSubagent[]>([])
const isDialogOpen = ref(false)
const isEditing = ref(false)
const draftAgent = ref<SubagentSubagent>({
name: '',
description: '',
})
const isContextDialogOpen = ref(false)
const contextMessages = ref<Array<Record<string, unknown>>>([])
const contextUsage = ref<Record<string, unknown>>({})
const canSave = computed(() => {
return (draftAgent.value.name || '').trim() && (draftAgent.value.description || '').trim()
})
function hasUsageData(usage: unknown): boolean {
if (!usage) return false
if (typeof usage === 'object' && Object.keys(usage as object).length > 0) return true
return false
}
function flattenUsage(usage: unknown): Record<string, unknown> {
if (!usage || typeof usage !== 'object') return {}
return usage as Record<string, unknown>
}
async function fetchSubagents() {
if (!props.botId) return
isLoading.value = true
try {
const { data } = await getBotsByBotIdSubagents({
path: { bot_id: props.botId },
throwOnError: true,
})
subagents.value = data.items || []
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.subagents.loadFailed')))
} finally {
isLoading.value = false
}
}
function handleCreate() {
isEditing.value = false
draftAgent.value = {
name: '',
description: '',
}
isDialogOpen.value = true
}
function handleEdit(agent: SubagentSubagent) {
isEditing.value = true
draftAgent.value = {
id: agent.id,
name: agent.name || '',
description: agent.description || '',
metadata: agent.metadata,
}
isDialogOpen.value = true
}
async function handleSave() {
if (!canSave.value) return
isSaving.value = true
try {
if (isEditing.value && draftAgent.value.id) {
await putBotsByBotIdSubagentsById({
path: { bot_id: props.botId, id: draftAgent.value.id },
body: {
name: draftAgent.value.name?.trim(),
description: draftAgent.value.description?.trim(),
metadata: draftAgent.value.metadata,
},
throwOnError: true,
})
} else {
await postBotsByBotIdSubagents({
path: { bot_id: props.botId },
body: {
name: draftAgent.value.name?.trim(),
description: draftAgent.value.description?.trim(),
metadata: draftAgent.value.metadata,
skills: [],
messages: [],
},
throwOnError: true,
})
}
toast.success(t('bots.subagents.saveSuccess'))
isDialogOpen.value = false
await fetchSubagents()
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.subagents.saveFailed')))
} finally {
isSaving.value = false
}
}
async function handleDelete(id?: string) {
if (!id) return
isDeleting.value = true
deletingId.value = id
try {
await deleteBotsByBotIdSubagentsById({
path: { bot_id: props.botId, id },
throwOnError: true,
})
toast.success(t('bots.subagents.deleteSuccess'))
await fetchSubagents()
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('bots.subagents.deleteFailed')))
} finally {
isDeleting.value = false
deletingId.value = ''
}
}
function handleViewContext(agent: SubagentSubagent) {
const msgs = agent.messages || []
contextMessages.value = msgs.map((m: Record<string, unknown>, idx: number) => ({
id: String(idx),
role: (m.role as string) || 'unknown',
content: m.content,
sender_display_name: m.sender_display_name as string | undefined,
platform: m.platform as string | undefined,
created_at: m.created_at as string | undefined,
usage: m.usage,
metadata: m.metadata as Record<string, unknown> | undefined,
}))
contextUsage.value = (agent.usage as Record<string, unknown>) || {}
isContextDialogOpen.value = true
}
onMounted(() => {
fetchSubagents()
})
</script>
@@ -0,0 +1,235 @@
<template>
<div class="space-y-1">
<div
v-for="(msg, idx) in messages"
:key="msg.id || idx"
class="group relative rounded-lg border p-3 transition-colors hover:bg-muted/50"
>
<div class="flex items-start gap-3">
<!-- Role Icon -->
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full"
:class="roleIconClass(msg.role)"
>
<FontAwesomeIcon
:icon="roleIcon(msg.role)"
class="size-3.5 text-white"
/>
</div>
<!-- Content -->
<div class="min-w-0 flex-1 space-y-1.5">
<!-- Top row -->
<div class="flex flex-wrap items-center gap-2 text-sm">
<Badge
:variant="roleBadgeVariant(msg.role)"
class="text-xs font-medium"
>
{{ roleLabel(msg.role) }}
</Badge>
<span
v-if="msg.sender_display_name"
class="font-medium truncate max-w-[200px]"
>
{{ msg.sender_display_name }}
</span>
<Badge
v-if="msg.platform"
variant="outline"
class="text-[10px] uppercase h-5"
>
{{ msg.platform }}
</Badge>
<span class="text-xs text-muted-foreground ml-auto shrink-0">
{{ formatTime(msg.created_at) }}
</span>
</div>
<!-- Message content -->
<div
class="text-sm leading-relaxed"
:class="{ 'font-mono text-xs': msg.role === 'tool' || msg.role === 'system' }"
>
<div
class="whitespace-pre-wrap wrap-break-word"
:class="{ 'line-clamp-4': !expandedIds.includes(msgKey(msg, idx)) }"
>
{{ formatContent(msg.content) }}
</div>
<button
v-if="isContentLong(msg.content)"
class="mt-1 text-xs text-primary hover:underline"
@click="toggleExpand(msgKey(msg, idx))"
>
{{ expandedIds.includes(msgKey(msg, idx)) ? collapseLabel : expandLabel }}
</button>
</div>
<!-- Usage -->
<div
v-if="hasUsage(msg.usage)"
class="flex items-center gap-3 text-xs text-muted-foreground pt-1"
>
<span class="inline-flex items-center gap-1">
<FontAwesomeIcon
:icon="['fas', 'chart-bar']"
class="size-3"
/>
{{ formatUsage(msg.usage) }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Badge } from '@memoh/ui'
import { formatDateTime } from '@/utils/date-time'
export interface MessageItem {
id?: string
role?: string
content?: unknown
sender_display_name?: string
sender_avatar_url?: string
platform?: string
created_at?: string
usage?: unknown
metadata?: Record<string, unknown>
[key: string]: unknown
}
defineProps<{
messages: MessageItem[]
}>()
const { t } = useI18n()
const expandedIds = ref<string[]>([])
const expandLabel = computed(() => t('bots.history.expandContent'))
const collapseLabel = computed(() => t('bots.history.collapseContent'))
function msgKey(msg: MessageItem, idx: number): string {
return msg.id || String(idx)
}
function toggleExpand(id: string) {
if (expandedIds.value.includes(id)) {
expandedIds.value = expandedIds.value.filter(v => v !== id)
} else {
expandedIds.value = [...expandedIds.value, id]
}
}
function roleIcon(role?: string): [string, string] {
switch (role) {
case 'user': return ['fas', 'user']
case 'assistant': return ['fas', 'robot']
case 'tool': return ['fas', 'wrench']
case 'system': return ['fas', 'laptop-code']
default: return ['fas', 'circle-question']
}
}
function roleIconClass(role?: string): string {
switch (role) {
case 'user': return 'bg-blue-500'
case 'assistant': return 'bg-emerald-500'
case 'tool': return 'bg-amber-500'
case 'system': return 'bg-slate-500'
default: return 'bg-gray-400'
}
}
function roleBadgeVariant(role?: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (role) {
case 'user': return 'default'
case 'assistant': return 'secondary'
case 'tool': return 'outline'
case 'system': return 'outline'
default: return 'outline'
}
}
function roleLabel(role?: string): string {
const key = `bots.history.role.${role || 'system'}`
const val = t(key)
return val !== key ? val : (role || 'unknown')
}
function formatTime(val?: string): string {
return formatDateTime(val, { fallback: '-' })
}
function formatContent(content: unknown): string {
if (!content) return ''
try {
if (Array.isArray(content)) {
const decoder = new TextDecoder()
const str = decoder.decode(new Uint8Array(content as number[]))
try {
const parsed = JSON.parse(str)
if (typeof parsed === 'string') return parsed
return JSON.stringify(parsed, null, 2)
} catch {
return str
}
}
if (typeof content === 'string') return content
if (typeof content === 'object') return JSON.stringify(content, null, 2)
return String(content)
} catch {
return String(content)
}
}
function isContentLong(content: unknown): boolean {
const text = formatContent(content)
return text.length > 300 || text.split('\n').length > 4
}
function hasUsage(usage: unknown): boolean {
if (!usage) return false
if (Array.isArray(usage) && usage.length > 0) return true
if (typeof usage === 'object' && Object.keys(usage as object).length > 0) return true
return false
}
function formatUsage(usage: unknown): string {
if (!usage) return ''
try {
if (Array.isArray(usage)) {
const decoder = new TextDecoder()
const str = decoder.decode(new Uint8Array(usage as number[]))
try {
const parsed = JSON.parse(str)
if (typeof parsed === 'object' && parsed !== null) {
const parts: string[] = []
for (const [k, v] of Object.entries(parsed)) {
parts.push(`${k}: ${v}`)
}
return parts.join(' | ')
}
return str
} catch {
return str
}
}
if (typeof usage === 'object') {
const parts: string[] = []
for (const [k, v] of Object.entries(usage as Record<string, unknown>)) {
parts.push(`${k}: ${v}`)
}
return parts.join(' | ')
}
return String(usage)
} catch {
return ''
}
}
</script>
+14 -2
View File
@@ -128,6 +128,9 @@
<TabsTrigger value="history">
{{ $t('bots.tabs.history') }}
</TabsTrigger>
<TabsTrigger value="skills">
{{ $t('bots.tabs.skills') }}
</TabsTrigger>
<TabsTrigger value="settings">
{{ $t('bots.tabs.settings') }}
</TabsTrigger>
@@ -499,13 +502,19 @@
value="subagents"
class="mt-6"
>
<!-- TODO: Subagents content -->
<BotSubagents :bot-id="botId" />
</TabsContent>
<TabsContent
value="history"
class="mt-6"
>
<!-- TODO: History content -->
<BotHistory :bot-id="botId" />
</TabsContent>
<TabsContent
value="skills"
class="mt-6"
>
<BotSkills :bot-id="botId" />
</TabsContent>
<TabsContent
value="settings"
@@ -614,6 +623,9 @@ import BotSettings from './components/bot-settings.vue'
import BotChannels from './components/bot-channels.vue'
import BotMcp from './components/bot-mcp.vue'
import BotMemory from './components/bot-memory.vue'
import BotSkills from './components/bot-skills.vue'
import BotHistory from './components/bot-history.vue'
import BotSubagents from './components/bot-subagents.vue'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { formatDateTime } from '@/utils/date-time'
import { useAvatarInitials } from '@/composables/useAvatarInitials'