diff --git a/packages/web/src/components/loading-button/index.vue b/packages/web/src/components/loading-button/index.vue new file mode 100644 index 00000000..a6f2983e --- /dev/null +++ b/packages/web/src/components/loading-button/index.vue @@ -0,0 +1,33 @@ + + + diff --git a/packages/web/src/components/status-dot/index.vue b/packages/web/src/components/status-dot/index.vue new file mode 100644 index 00000000..69ef990b --- /dev/null +++ b/packages/web/src/components/status-dot/index.vue @@ -0,0 +1,25 @@ + + + diff --git a/packages/web/src/components/warning-banner/index.vue b/packages/web/src/components/warning-banner/index.vue new file mode 100644 index 00000000..05766e4a --- /dev/null +++ b/packages/web/src/components/warning-banner/index.vue @@ -0,0 +1,5 @@ + diff --git a/packages/web/src/composables/api/useChat.chat-api.ts b/packages/web/src/composables/api/useChat.chat-api.ts index bb5f9245..5fb3886a 100644 --- a/packages/web/src/composables/api/useChat.chat-api.ts +++ b/packages/web/src/composables/api/useChat.chat-api.ts @@ -1,5 +1,4 @@ -import { client } from '@memoh/sdk/client' -import { getBots } from '@memoh/sdk' +import { getBots, deleteBotsByBotIdMessages } from '@memoh/sdk' import type { Bot, ChatSummary } from './useChat.types' export async function fetchBots(): Promise { @@ -23,8 +22,7 @@ export async function deleteChat(botId: string, chatId: string): Promise { if (botId.trim() !== chatId.trim()) { throw new Error('chat id must match bot id') } - await client.delete({ - url: '/bots/{bot_id}/messages', + await deleteBotsByBotIdMessages({ path: { bot_id: botId }, throwOnError: true, }) diff --git a/packages/web/src/composables/api/useChat.message-api.ts b/packages/web/src/composables/api/useChat.message-api.ts index 2b7e455b..2887ada3 100644 --- a/packages/web/src/composables/api/useChat.message-api.ts +++ b/packages/web/src/composables/api/useChat.message-api.ts @@ -1,5 +1,5 @@ import { client } from '@memoh/sdk/client' -import { postBotsByBotIdWebMessages } from '@memoh/sdk' +import { getBotsByBotIdMessages, postBotsByBotIdWebMessages } from '@memoh/sdk' import type { ChannelAttachment, ChannelMessage } from '@memoh/sdk' import type { ChatAttachment, @@ -19,20 +19,16 @@ export async function fetchMessages( throw new Error('chat id must match bot id') } - const query: Record = {} - query.limit = String(options?.limit ?? 30) - if (options?.before?.trim()) { - query.before = options.before.trim() - } - - const { data } = await client.get({ - url: '/bots/{bot_id}/messages', + const { data } = await getBotsByBotIdMessages({ path: { bot_id: botId }, - query, + query: { + limit: options?.limit ?? 30, + ...(options?.before?.trim() ? { before: options.before.trim() } : {}), + }, throwOnError: true, - }) as { data: { items?: Message[] } } + }) - return data?.items ?? [] + return (data as unknown as { items?: Message[] })?.items ?? [] } export async function sendLocalChannelMessage( @@ -68,13 +64,14 @@ export async function streamLocalChannel( const id = botId.trim() if (!id) throw new Error('bot id is required') - const { data: body } = await client.get({ + const response = await client.get({ url: '/bots/{bot_id}/web/stream', path: { bot_id: id }, parseAs: 'stream', signal, throwOnError: true, - }) as { data: ReadableStream } + }) + const body = response.data as ReadableStream | null if (!body) throw new Error('No response body') @@ -96,14 +93,15 @@ export async function streamMessageEvents( const query: Record = {} if (since?.trim()) query.since = since.trim() - const { data: body } = await client.get({ + const response = await client.get({ url: '/bots/{bot_id}/messages/events', path: { bot_id: id }, query, parseAs: 'stream', signal, throwOnError: true, - }) as { data: ReadableStream } + }) + const body = response.data as ReadableStream | null if (!body) throw new Error('No response body') @@ -111,6 +109,6 @@ export async function streamMessageEvents( const parsed = parseStreamPayload(payload) if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) return if (typeof parsed.type !== 'string' || !parsed.type.trim()) return - onEvent(parsed as unknown as MessageStreamEvent) + onEvent(parsed as MessageStreamEvent) }) } diff --git a/packages/web/src/composables/api/usePlatform.ts b/packages/web/src/composables/api/usePlatform.ts index 717f74a5..d5abd700 100644 --- a/packages/web/src/composables/api/usePlatform.ts +++ b/packages/web/src/composables/api/usePlatform.ts @@ -21,10 +21,10 @@ export function usePlatformList() { return useQuery({ key: ['platform'], query: async () => { - const { data } = await client.get({ + const { data } = await client.get({ url: '/platform/', throwOnError: true, - }) as { data: PlatformItem[] } + }) return data }, }) @@ -36,7 +36,7 @@ export function useCreatePlatform() { const queryCache = useQueryCache() return useMutation({ mutation: (data: CreatePlatformRequest) => - client.post({ url: '/platform/', body: data, throwOnError: true }), + client.post({ url: '/platform/', body: data, throwOnError: true }), onSettled: () => queryCache.invalidateQueries({ key: ['platform'] }), }) } diff --git a/packages/web/src/pages/bots/components/bot-heartbeat.vue b/packages/web/src/pages/bots/components/bot-heartbeat.vue index 2c12e2cf..30fae096 100644 --- a/packages/web/src/pages/bots/components/bot-heartbeat.vue +++ b/packages/web/src/pages/bots/components/bot-heartbeat.vue @@ -191,7 +191,7 @@
@@ -243,8 +243,12 @@ import { } from '@memoh/ui' import ConfirmPopover from '@/components/confirm-popover/index.vue' import ModelSelect from './model-select.vue' -import { client } from '@memoh/sdk/client' -import { getBotsByBotIdSettings, putBotsByBotIdSettings, getModels, getProviders } from '@memoh/sdk' +import { + getBotsByBotIdSettings, putBotsByBotIdSettings, + getBotsByBotIdHeartbeatLogs, deleteBotsByBotIdHeartbeatLogs, + getModels, getProviders, +} from '@memoh/sdk' +import type { SettingsSettings, SettingsUpsertRequest, HeartbeatLog } from '@memoh/sdk' import { useQuery, useMutation, useQueryCache } from '@pinia/colada' import { resolveApiErrorMessage } from '@/utils/api-error' import { formatDateTime } from '@/utils/date-time' @@ -294,24 +298,24 @@ const settingsForm = reactive({ heartbeat_model_id: '', }) -watch(settings, (val) => { +watch(settings, (val: SettingsSettings | undefined) => { if (val) { - settingsForm.heartbeat_enabled = (val as any).heartbeat_enabled ?? false - settingsForm.heartbeat_interval = (val as any).heartbeat_interval ?? 30 - settingsForm.heartbeat_model_id = (val as any).heartbeat_model_id ?? '' + settingsForm.heartbeat_enabled = val.heartbeat_enabled ?? false + settingsForm.heartbeat_interval = val.heartbeat_interval ?? 30 + settingsForm.heartbeat_model_id = val.heartbeat_model_id ?? '' } }, { immediate: true }) const settingsChanged = computed(() => { if (!settings.value) return false - const s = settings.value as any + const s: SettingsSettings = settings.value return settingsForm.heartbeat_enabled !== (s.heartbeat_enabled ?? false) || settingsForm.heartbeat_interval !== (s.heartbeat_interval ?? 30) || settingsForm.heartbeat_model_id !== (s.heartbeat_model_id ?? '') }) const { mutateAsync: updateSettings, isLoading: isSaving } = useMutation({ - mutation: async (body: Record) => { + mutation: async (body: SettingsUpsertRequest) => { const { data } = await putBotsByBotIdSettings({ path: { bot_id: botIdRef.value }, body, @@ -331,17 +335,6 @@ async function handleSaveSettings() { } } -interface HeartbeatLog { - id: string - bot_id: string - status: 'ok' | 'alert' | 'error' - result_text: string - error_message: string - usage: any - started_at: string - completed_at: string | null -} - const isLoading = ref(false) const isClearing = ref(false) const logs = ref([]) @@ -356,32 +349,33 @@ const filteredLogs = computed(() => { return logs.value.filter(l => l.status === statusFilter.value) }) -function statusVariant(status: string) { +function statusVariant(status: string | undefined) { if (status === 'ok') return 'secondary' as const if (status === 'alert') return 'default' as const return 'destructive' as const } -function statusLabel(status: string) { +function statusLabel(status: string | undefined) { if (status === 'ok') return t('bots.heartbeat.statusOk') if (status === 'alert') return t('bots.heartbeat.statusAlert') return t('bots.heartbeat.statusError') } -function formatDuration(startedAt: string, completedAt: string | null) { - if (!completedAt) return '—' +function formatDuration(startedAt: string | undefined, completedAt: string | null | undefined) { + if (!startedAt || !completedAt) return '—' const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime() if (ms < 1000) return `${ms}ms` return `${(ms / 1000).toFixed(1)}s` } -function truncateText(text: string, maxLen = 80) { +function truncateText(text: string | undefined, maxLen = 80) { if (!text) return '' if (text === 'HEARTBEAT_OK') return 'HEARTBEAT_OK' return text.length > maxLen ? text.slice(0, maxLen) + '…' : text } -function toggleExpand(id: string) { +function toggleExpand(id: string | undefined) { + if (!id) return if (expandedIds.value.has(id)) { expandedIds.value.delete(id) } else { @@ -393,14 +387,12 @@ async function fetchLogs(before?: string) { if (!props.botId) return isLoading.value = true try { - const params = new URLSearchParams({ limit: String(PAGE_SIZE) }) - if (before) params.set('before', before) - const { data, error } = await client.get({ - url: `/bots/${props.botId}/heartbeat/logs`, + const { data } = await getBotsByBotIdHeartbeatLogs({ + path: { bot_id: props.botId }, query: { limit: PAGE_SIZE, ...(before ? { before } : {}) }, + throwOnError: true, }) - if (error) throw error - const items = (data as any)?.items ?? [] + const items = data?.items ?? [] if (!before) { logs.value = items } else { @@ -417,7 +409,7 @@ async function fetchLogs(before?: string) { async function loadMore() { if (logs.value.length === 0) return const lastLog = logs.value[logs.value.length - 1] - await fetchLogs(lastLog.started_at) + await fetchLogs(lastLog?.started_at) } async function handleRefresh() { @@ -428,10 +420,10 @@ async function handleRefresh() { async function handleClear() { isClearing.value = true try { - const { error } = await client.delete({ - url: `/bots/${props.botId}/heartbeat/logs`, + await deleteBotsByBotIdHeartbeatLogs({ + path: { bot_id: props.botId }, + throwOnError: true, }) - if (error) throw error logs.value = [] expandedIds.value.clear() toast.success(t('bots.heartbeat.clearSuccess')) diff --git a/packages/web/src/pages/bots/components/bot-mcp.vue b/packages/web/src/pages/bots/components/bot-mcp.vue index b2e5ccd8..a6dac14b 100644 --- a/packages/web/src/pages/bots/components/bot-mcp.vue +++ b/packages/web/src/pages/bots/components/bot-mcp.vue @@ -93,7 +93,7 @@ - + {{ editingItem ? $t('common.edit') : $t('common.add') }} MCP Server @@ -260,7 +260,7 @@ />
- +
= { +function buildRequestBody(): McpUpsertRequest { + const body: McpUpsertRequest = { name: formData.value.name.trim(), is_active: formData.value.active, } @@ -982,13 +983,13 @@ async function handleSubmit() { if (editingItem.value) { await putBotsByBotIdMcpById({ path: { bot_id: props.botId, id: editingItem.value.id }, - body: body as any, + body, throwOnError: true, }) } else { await postBotsByBotIdMcp({ path: { bot_id: props.botId }, - body: body as any, + body, throwOnError: true, }) } @@ -1046,12 +1047,13 @@ function handleBatchExport() { async function handleImport() { importSubmitting.value = true try { - let parsed = JSON.parse(importJson.value) + let parsed: McpImportRequest = JSON.parse(importJson.value) if (!parsed.mcpServers && typeof parsed === 'object') { - parsed = { mcpServers: parsed } + parsed = { mcpServers: parsed as McpImportRequest['mcpServers'] } } await client.put({ - url: `/bots/${props.botId}/mcp-ops/import`, + url: '/bots/{bot_id}/mcp-ops/import', + path: { bot_id: props.botId }, body: parsed, throwOnError: true, }) diff --git a/packages/web/src/pages/bots/components/bot-memory.vue b/packages/web/src/pages/bots/components/bot-memory.vue index 1a200918..b8527c1c 100644 --- a/packages/web/src/pages/bots/components/bot-memory.vue +++ b/packages/web/src/pages/bots/components/bot-memory.vue @@ -414,8 +414,8 @@ > {{ msg.role }} -

- {{ msg.content?.text || (typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)) }} +

+ {{ extractMessageText(msg.content) }}

@@ -578,6 +578,7 @@ import { postBotsByBotIdMemoryCompact, getBotsByBotIdMessages, } from '@memoh/sdk' +import type { MemoryCdfPoint, MemoryTopKBucket } from '@memoh/sdk' import { toast } from 'vue-sonner' import { useI18n } from 'vue-i18n' import ConfirmPopover from '@/components/confirm-popover/index.vue' @@ -593,12 +594,26 @@ interface MemoryItem { score?: number } +type MessageContentBlock = { type: string; text?: string } +type MessageContent = string | MessageContentBlock[] | unknown + interface Message { role: string - content: any + content: MessageContent created_at?: string } +function extractMessageText(content: MessageContent): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .filter((b): b is MessageContentBlock => typeof b === 'object' && b !== null) + .map(b => b.text ?? '') + .join('') + } + return JSON.stringify(content) +} + const props = defineProps<{ botId: string }>() @@ -627,7 +642,7 @@ const compactRatio = ref('0.5') const compactDecayDate = ref('') // Hover state for CDF chart -const hoveredCdfPoint = ref(null) +const hoveredCdfPoint = ref(null) const hoveredCdfIdx = ref(-1) const hoveredCdfX = computed(() => { if (!hoveredCdfPoint.value || !selectedMemory.value) return 0 @@ -640,13 +655,13 @@ const hoveredCdfY = computed(() => { }) const selectedTopKBuckets = computed(() => selectedMemory.value?.top_k_buckets ?? []) -const topKBucketValues = computed(() => selectedTopKBuckets.value.map((bucket: any) => bucket.value)) +const topKBucketValues = computed(() => selectedTopKBuckets.value.map((bucket: MemoryTopKBucket) => bucket.value ?? 0)) const topKMinValue = computed(() => Math.min(...topKBucketValues.value)) const topKMaxValue = computed(() => Math.max(...topKBucketValues.value)) const topKRange = computed(() => (topKMaxValue.value - topKMinValue.value) || 1) const topKBarHeights = computed(() => selectedTopKBuckets.value.map( - (bucket: any) => (((bucket.value - topKMinValue.value) / topKRange.value) * 80) + 20, + (bucket: MemoryTopKBucket) => ((((bucket.value ?? 0) - topKMinValue.value) / topKRange.value) * 80) + 20, ), ) @@ -846,7 +861,7 @@ async function handleCompact() { body: { ratio: parseFloat(compactRatio.value), decay_days: compactDecayDays.value || undefined, - } as any, + }, throwOnError: true, }) toast.success(t('bots.memory.compactSuccess')) @@ -887,7 +902,7 @@ watch(() => props.botId, () => { }) // Chart Helper: Generate smooth SVG path -function generateSmoothPath(data: any[], closePath: boolean = false) { +function generateSmoothPath(data: MemoryCdfPoint[], closePath: boolean = false) { if (!data || data.length < 2) return '' // Use a small margin (2%) to prevent clipping at boundaries @@ -895,7 +910,7 @@ function generateSmoothPath(data: any[], closePath: boolean = false) { const height = 100 - (margin * 2) const points = data.map((p, idx) => ({ x: (idx / (data.length - 1)) * 100, - y: (100 - margin) - (p.cumulative * height) + y: (100 - margin) - ((p.cumulative ?? 0) * height) })) let d = `M ${points[0].x},${points[0].y}` diff --git a/packages/web/src/pages/bots/components/channel-settings-panel.vue b/packages/web/src/pages/bots/components/channel-settings-panel.vue index 3876f33b..e97021a1 100644 --- a/packages/web/src/pages/bots/components/channel-settings-panel.vue +++ b/packages/web/src/pages/bots/components/channel-settings-panel.vue @@ -240,9 +240,8 @@ import { reactive, watch, computed, ref } from 'vue' import { toast } from 'vue-sonner' import { useI18n } from 'vue-i18n' import { useMutation, useQueryCache } from '@pinia/colada' -import { putBotsByIdChannelByPlatform } from '@memoh/sdk' -import { client } from '@memoh/sdk/client' -import type { HandlersChannelMeta, ChannelChannelConfig, ChannelFieldSchema } from '@memoh/sdk' +import { putBotsByIdChannelByPlatform, deleteBotsByIdChannelByPlatform, patchBotsByIdChannelByPlatformStatus } from '@memoh/sdk' +import type { HandlersChannelMeta, ChannelChannelConfig, ChannelFieldSchema, ChannelUpsertConfigRequest } from '@memoh/sdk' import ConfirmPopover from '@/components/confirm-popover/index.vue' interface BotChannelItem { @@ -264,10 +263,10 @@ const { t } = useI18n() const botIdRef = computed(() => props.botId) const queryCache = useQueryCache() const { mutateAsync: upsertChannel, isLoading } = useMutation({ - mutation: async ({ platform, data }: { platform: string; data: Record }) => { + mutation: async ({ platform, data }: { platform: string; data: ChannelUpsertConfigRequest }) => { const { data: result } = await putBotsByIdChannelByPlatform({ path: { id: botIdRef.value, platform }, - body: data as any, + body: data, throwOnError: true, }) return result @@ -276,12 +275,12 @@ const { mutateAsync: upsertChannel, isLoading } = useMutation({ }) const { mutateAsync: updateChannelStatus, isLoading: isStatusLoading } = useMutation({ mutation: async ({ platform, disabled }: { platform: string; disabled: boolean }) => { - const { data } = await client.patch({ - url: `/bots/${botIdRef.value}/channel/${platform}/status`, + const { data } = await patchBotsByIdChannelByPlatformStatus({ + path: { id: botIdRef.value, platform }, body: { disabled }, throwOnError: true, }) - return data as ChannelChannelConfig + return data }, onSettled: () => queryCache.invalidateQueries({ key: ['bot-channels', botIdRef.value] }), }) @@ -434,8 +433,8 @@ async function handleToggleDisabled() { async function handleDelete() { action.value = 'delete' try { - await client.delete({ - url: `/bots/${botIdRef.value}/channel/${props.channelItem.meta.type}`, + await deleteBotsByIdChannelByPlatform({ + path: { id: botIdRef.value, platform: props.channelItem.meta.type }, throwOnError: true, }) lastSavedConfigId.value = '' diff --git a/packages/web/src/pages/bots/detail.vue b/packages/web/src/pages/bots/detail.vue index 6b1484e2..ed466aa9 100644 --- a/packages/web/src/pages/bots/detail.vue +++ b/packages/web/src/pages/bots/detail.vue @@ -40,17 +40,14 @@ @keydown.enter.prevent="handleConfirmBotName" @keydown.esc.prevent="handleCancelBotName" /> - + +
-
+ {{ $t('bots.container.botNotReady') }} -
+
-
-
+
+

{{ $t('common.name') }}

@@ -22,7 +22,7 @@
-
+

{{ $t('provider.apiKey') }}

@@ -43,7 +43,7 @@
-
+

{{ $t('provider.url') }}

@@ -66,15 +66,15 @@
- +
- +
@@ -108,10 +108,7 @@ class="mt-4 rounded-lg border p-4 space-y-3 text-sm" >
- + {{ testResult.reachable ? $t('provider.reachable') : $t('provider.unreachable') }} @@ -164,6 +161,8 @@ import { Spinner, } from '@memoh/ui' import ConfirmPopover from '@/components/confirm-popover/index.vue' +import StatusDot from '@/components/status-dot/index.vue' +import LoadingButton from '@/components/loading-button/index.vue' import { computed, ref, watch } from 'vue' import { toTypedSchema } from '@vee-validate/zod' import z from 'zod' diff --git a/packages/web/src/pages/models/index.vue b/packages/web/src/pages/models/index.vue index a164c5de..f4a2ce99 100644 --- a/packages/web/src/pages/models/index.vue +++ b/packages/web/src/pages/models/index.vue @@ -52,7 +52,7 @@ const curFilterProvider = computed(() => { } const keyword = searchText.value.toLowerCase() return providerData.value.filter((provider: ProvidersGetResponse) => { - return (provider.name as string).toLowerCase().includes(keyword) + return (provider.name ?? '').toLowerCase().includes(keyword) }) }) @@ -107,8 +107,8 @@ const openStatus = reactive({ class="justify-start py-5! px-4" > - {{ $t('platform.platformLabel') }}: {{ platform.name }} - - {{ $t('platform.running') }} - - - -
    -
  1. - {{ key }}: {{ value }} -
  2. -
-
- - - - - - - - + {{ $t('platform.running') }} + + + +
    +
  1. + {{ key }}: {{ value }} +
  2. +
+
+ + + + + + +