refactor(web): fix types, ui, error handling, unhealthy styles

This commit is contained in:
Acbox
2026-02-27 18:02:23 +08:00
parent bf0eeb0e80
commit b47258b15e
19 changed files with 260 additions and 199 deletions
@@ -0,0 +1,33 @@
<template>
<Button
v-bind="$attrs"
:disabled="loading || disabled"
:variant="variant"
:size="size"
>
<Spinner
v-if="loading"
class="size-4"
/>
<slot />
</Button>
</template>
<script setup lang="ts">
import { Button, Spinner } from '@memoh/ui'
import type { ButtonVariants } from '@memoh/ui'
defineOptions({ inheritAttrs: false })
withDefaults(defineProps<{
loading?: boolean
disabled?: boolean
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
}>(), {
loading: false,
disabled: false,
variant: 'default',
size: 'default',
})
</script>
@@ -0,0 +1,25 @@
<template>
<span
class="inline-block size-2 rounded-full"
:class="colorClass"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
status: 'success' | 'error' | 'warning' | 'idle'
}>(), {
status: 'idle',
})
const colorClass = computed(() => {
switch (props.status) {
case 'success': return 'bg-green-500'
case 'error': return 'bg-red-500'
case 'warning': return 'bg-yellow-500'
default: return 'bg-muted-foreground'
}
})
</script>
@@ -0,0 +1,5 @@
<template>
<div class="rounded-md border border-yellow-300/50 bg-yellow-50/70 p-3 text-sm text-yellow-800 dark:border-yellow-800/50 dark:bg-yellow-900/10 dark:text-yellow-200">
<slot />
</div>
</template>
@@ -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<Bot[]> {
@@ -23,8 +22,7 @@ export async function deleteChat(botId: string, chatId: string): Promise<void> {
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,
})
@@ -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<string, string> = {}
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<Uint8Array> }
})
const body = response.data as ReadableStream<Uint8Array> | null
if (!body) throw new Error('No response body')
@@ -96,14 +93,15 @@ export async function streamMessageEvents(
const query: Record<string, string> = {}
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<Uint8Array> }
})
const body = response.data as ReadableStream<Uint8Array> | 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)
})
}
@@ -21,10 +21,10 @@ export function usePlatformList() {
return useQuery({
key: ['platform'],
query: async () => {
const { data } = await client.get({
const { data } = await client.get<PlatformItem[]>({
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<void>({ url: '/platform/', body: data, throwOnError: true }),
onSettled: () => queryCache.invalidateQueries({ key: ['platform'] }),
})
}
@@ -191,7 +191,7 @@
<!-- Expanded detail -->
<div
v-for="log in filteredLogs.filter(l => expandedIds.has(l.id))"
v-for="log in filteredLogs.filter(l => l.id && expandedIds.has(l.id))"
:key="'detail-' + log.id"
class="rounded-md border bg-muted/20 p-4 text-sm whitespace-pre-wrap wrap-break-word"
>
@@ -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<string, unknown>) => {
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<HeartbeatLog[]>([])
@@ -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'))
@@ -93,7 +93,7 @@
<!-- Add dialog: tabs (single | import). Edit dialog: two columns (form | json) with sync -->
<Dialog v-model:open="formDialogOpen">
<DialogContent :class="editingItem ? 'sm:max-w-4xl max-h-[90vh] flex flex-col w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto sm:max-w-full' : 'sm:max-w-[28rem] w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto'">
<DialogContent :class="editingItem ? 'sm:max-w-4xl max-h-[90vh] flex flex-col w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto' : 'sm:max-w-md w-[calc(100vw-2rem)] max-w-[calc(100vw-2rem)] sm:w-auto'">
<DialogHeader>
<DialogTitle>{{ editingItem ? $t('common.edit') : $t('common.add') }} MCP Server</DialogTitle>
</DialogHeader>
@@ -260,7 +260,7 @@
/>
</div>
</div>
<DialogFooter class="mt-4 flex-shrink-0 flex-row flex-wrap items-center gap-2 sm:justify-between">
<DialogFooter class="mt-4 shrink-0 flex-row flex-wrap items-center gap-2 sm:justify-between">
<div class="flex items-center gap-2">
<Label class="text-sm font-normal">{{ $t('mcp.active') }}</Label>
<Switch
@@ -589,6 +589,7 @@ import {
deleteBotsByBotIdMcpById,
postBotsByBotIdMcpOpsBatchDelete,
} from '@memoh/sdk'
import type { McpUpsertRequest, McpImportRequest } from '@memoh/sdk'
import { client } from '@memoh/sdk/client'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import { resolveApiErrorMessage } from '@/utils/api-error'
@@ -955,8 +956,8 @@ function syncEditJsonToForm() {
editSyncFromJson = false
}
function buildRequestBody() {
const body: Record<string, unknown> = {
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,
})
@@ -414,8 +414,8 @@
>
{{ msg.role }}
</Badge>
<p class="text-xs text-foreground break-words line-clamp-3">
{{ msg.content?.text || (typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)) }}
<p class="text-xs text-foreground wrap-break-word line-clamp-3">
{{ extractMessageText(msg.content) }}
</p>
</div>
</button>
@@ -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<any>(null)
const hoveredCdfPoint = ref<MemoryCdfPoint | null>(null)
const hoveredCdfIdx = ref<number>(-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}`
@@ -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<string, unknown> }) => {
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 = ''
+11 -19
View File
@@ -40,17 +40,14 @@
@keydown.enter.prevent="handleConfirmBotName"
@keydown.esc.prevent="handleCancelBotName"
/>
<Button
<LoadingButton
size="sm"
:disabled="isSavingBotName || !canConfirmBotName"
:loading="isSavingBotName"
:disabled="!canConfirmBotName"
@click="handleConfirmBotName"
>
<Spinner
v-if="isSavingBotName"
class="mr-1.5"
/>
{{ $t('common.confirm') }}
</Button>
</LoadingButton>
<Button
variant="ghost"
size="sm"
@@ -154,18 +151,14 @@
{{ $t('bots.checks.subtitle') }}
</p>
</div>
<Button
<LoadingButton
variant="outline"
size="sm"
:disabled="checksLoading"
:loading="checksLoading"
@click="handleRefreshChecks"
>
<Spinner
v-if="checksLoading"
class="mr-1.5"
/>
{{ $t('common.refresh') }}
</Button>
</LoadingButton>
</div>
<div class="mt-3 flex items-center gap-2 text-sm">
<Badge
@@ -319,12 +312,9 @@
</div>
</div>
<div
v-if="botLifecyclePending"
class="rounded-md border border-yellow-300/50 bg-yellow-50/70 p-3 text-sm text-yellow-800 dark:border-yellow-800/50 dark:bg-yellow-900/10 dark:text-yellow-200"
>
<WarningBanner v-if="botLifecyclePending">
{{ $t('bots.container.botNotReady') }}
</div>
</WarningBanner>
<div
v-if="containerLoading && !containerInfo && !containerMissing"
@@ -632,6 +622,8 @@ import type {
} from '@memoh/sdk'
import { useCapabilitiesStore } from '@/store/capabilities'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import WarningBanner from '@/components/warning-banner/index.vue'
import LoadingButton from '@/components/loading-button/index.vue'
import BotSettings from './components/bot-settings.vue'
import BotChannels from './components/bot-channels.vue'
import BotMcp from './components/bot-mcp.vue'
@@ -1,7 +1,7 @@
<template>
<form @submit="editProvider">
<div class="**:[input]:mt-3 **:[input]:mb-4">
<section>
<div class="space-y-4">
<section class="space-y-2">
<h4 class="scroll-m-20 font-semibold tracking-tight">
{{ $t('common.name') }}
</h4>
@@ -22,7 +22,7 @@
</FormField>
</section>
<section>
<section class="space-y-2">
<h4 class="scroll-m-20 font-semibold tracking-tight">
{{ $t('provider.apiKey') }}
</h4>
@@ -43,7 +43,7 @@
</FormField>
</section>
<section>
<section class="space-y-2">
<h4 class="scroll-m-20 font-semibold tracking-tight">
{{ $t('provider.url') }}
</h4>
@@ -66,15 +66,15 @@
</div>
<section class="flex justify-between items-center mt-4">
<Button
<LoadingButton
type="button"
variant="outline"
:disabled="testLoading || !props.provider?.id"
:loading="testLoading"
:disabled="!props.provider?.id"
@click="runTest"
>
<Spinner v-if="testLoading" />
{{ $t('provider.testConnection') }}
</Button>
</LoadingButton>
<div class="flex gap-4">
<ConfirmPopover
@@ -93,13 +93,13 @@
</template>
</ConfirmPopover>
<Button
<LoadingButton
type="submit"
:loading="editLoading"
:disabled="!hasChanges || !form.meta.value.valid"
>
<Spinner v-if="editLoading" />
{{ $t('provider.saveChanges') }}
</Button>
</LoadingButton>
</div>
</section>
@@ -108,10 +108,7 @@
class="mt-4 rounded-lg border p-4 space-y-3 text-sm"
>
<div class="flex items-center gap-2">
<span
class="inline-block size-2 rounded-full"
:class="testResult.reachable ? 'bg-green-500' : 'bg-red-500'"
/>
<StatusDot :status="testResult.reachable ? 'success' : 'error'" />
<span class="font-medium">
{{ testResult.reachable ? $t('provider.reachable') : $t('provider.unreachable') }}
</span>
@@ -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'
+3 -3
View File
@@ -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"
>
<Toggle
:class="`py-4 border border-transparent ${curProvider?.name === providerItem.name ? 'border-inherit' : ''}`"
:model-value="selectProvider(providerItem.name as string).value"
:class="['py-4 border', curProvider?.name === providerItem.name ? 'border-border' : 'border-transparent']"
:model-value="selectProvider(providerItem.name ?? '').value"
@update:model-value="(isSelect) => {
if (isSelect) {
curProvider = providerItem
@@ -1,50 +1,45 @@
<template>
<li>
<Card>
<CardHeader>
<CardTitle class="text-muted-foreground flex justify-between">
<span>{{ $t('platform.platformLabel') }}: {{ platform.name }}</span>
<Badge
v-if="platform.active"
variant="outline"
>
{{ $t('platform.running') }}
</Badge>
</CardTitle>
<CardContent class="mt-4 p-0">
<ol
class="[&>li]:mt-2"
type="1"
>
<li
v-for="(value, key) in platform.config"
:key="key"
>
{{ key }}: {{ value }}
</li>
</ol>
</CardContent>
</CardHeader>
<CardFooter class="flex gap-4">
<Switch
:model-value="platform.active"
:aria-label="`Toggle ${platform.name}`"
/>
<Button
class="ml-auto"
@click="$emit('edit', platform)"
<Card class="h-full flex flex-col">
<CardHeader>
<CardTitle class="text-muted-foreground flex justify-between">
<span>{{ $t('platform.platformLabel') }}: {{ platform.name }}</span>
<Badge
v-if="platform.active"
variant="outline"
>
{{ $t('common.edit') }}
</Button>
<Button
variant="destructive"
@click="$emit('delete', platform)"
>
{{ $t('common.delete') }}
</Button>
</CardFooter>
</Card>
</li>
{{ $t('platform.running') }}
</Badge>
</CardTitle>
<CardContent class="px-0 pb-0">
<ol class="space-y-2 text-sm">
<li
v-for="(value, key) in platform.config"
:key="key"
>
{{ key }}: {{ value }}
</li>
</ol>
</CardContent>
</CardHeader>
<CardFooter class="flex gap-4 mt-auto">
<Switch
:model-value="platform.active"
:aria-label="`Toggle ${platform.name}`"
/>
<Button
class="ml-auto"
@click="$emit('edit', platform)"
>
{{ $t('common.edit') }}
</Button>
<Button
variant="destructive"
@click="$emit('delete', platform)"
>
{{ $t('common.delete') }}
</Button>
</CardFooter>
</Card>
</template>
<script setup lang="ts">
+2 -2
View File
@@ -2,14 +2,14 @@
<section class="p-4">
<AddPlatform v-model:open="open" />
<menu class="grid grid-cols-4 gap-4 [&_li>*]:h-full">
<div class="grid grid-cols-4 gap-4">
<PlatformCard
v-for="item in platformList"
:key="item.name"
:platform="item"
@edit="() => { open = true }"
/>
</menu>
</div>
</section>
</template>
@@ -14,7 +14,7 @@
<Separator class="mt-4 mb-6" />
<form @submit="editProvider">
<div class="**:[input]:mt-3 **:[input]:mb-4">
<div class="space-y-4">
<section>
<FormField
v-slot="{ componentField }"
@@ -137,13 +137,12 @@
</template>
</ConfirmPopover>
<Button
<LoadingButton
type="submit"
:disabled="editLoading"
:loading="editLoading"
>
<Spinner v-if="editLoading" />
{{ $t('provider.saveChanges') }}
</Button>
</LoadingButton>
</section>
</form>
</div>
@@ -156,7 +155,6 @@ import {
FormControl,
FormField,
FormItem,
Spinner,
Separator,
Select,
SelectTrigger,
@@ -167,6 +165,7 @@ import {
Label,
} from '@memoh/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import LoadingButton from '@/components/loading-button/index.vue'
import BraveSettings from './brave-settings.vue'
import BingSettings from './bing-settings.vue'
import GoogleSettings from './google-settings.vue'
@@ -186,7 +185,7 @@ import z from 'zod'
import { useForm } from 'vee-validate'
import { useMutation, useQueryCache } from '@pinia/colada'
import { putSearchProvidersById, deleteSearchProvidersById } from '@memoh/sdk'
import type { SearchprovidersGetResponse } from '@memoh/sdk'
import type { SearchprovidersGetResponse, SearchprovidersUpdateRequest } from '@memoh/sdk'
const PROVIDER_TYPES = ['brave', 'bing', 'google', 'tavily', 'sogou', 'serper', 'searxng', 'jina', 'exa', 'bocha', 'duckduckgo', 'yandex'] as const
@@ -227,11 +226,11 @@ watch(curProvider, (newVal) => {
// ---- mutations ----
const { mutate: submitUpdate, isLoading: editLoading } = useMutation({
mutation: async (data: { name: string; provider: string; config: Record<string, unknown> }) => {
mutation: async (data: SearchprovidersUpdateRequest) => {
if (!curProviderId.value) return
const { data: result } = await putSearchProvidersById({
path: { id: curProviderId.value },
body: data as any,
body: data,
throwOnError: true,
})
return result
@@ -250,7 +249,7 @@ const { mutate: deleteProvider, isLoading: deleteLoading } = useMutation({
const editProvider = form.handleSubmit(async (values) => {
submitUpdate({
name: values.name,
provider: values.provider,
provider: values.provider as SearchprovidersUpdateRequest['provider'],
config: configData.value,
})
})
@@ -66,7 +66,7 @@ const curFilterProvider = computed(() => {
}
const keyword = searchText.value.toLowerCase()
return providerData.value.filter((p: SearchprovidersGetResponse) => {
return (p.name as string).toLowerCase().includes(keyword)
return (p.name ?? '').toLowerCase().includes(keyword)
})
})
+2 -2
View File
@@ -413,14 +413,14 @@ async function onGenerateBindCode() {
generatingBindCode.value = true
try {
const ttl = Number.isFinite(bindForm.ttlSeconds) ? Math.max(60, Number(bindForm.ttlSeconds)) : 3600
const { data } = await client.post({
const { data } = await client.post<IssueBindCodeResponse>({
url: '/users/me/bind_codes',
body: {
platform: bindForm.platform || undefined,
ttl_seconds: ttl,
},
throwOnError: true,
}) as { data: IssueBindCodeResponse }
})
bindCode.value = data
toast.success(t('settings.bindCodeGenerated'))
} catch (error) {
+23 -14
View File
@@ -10,6 +10,7 @@ import {
type ChatSummary,
type Message,
type StreamEvent,
type MessageStreamEvent,
fetchBots,
fetchMessages,
fetchChats,
@@ -45,9 +46,18 @@ export interface ToolCallBlock {
done: boolean
}
export interface AttachmentItem {
type: string
content_hash: string
bot_id: string
mime: string
size: number
storage_key: string
}
export interface AttachmentBlock {
type: 'attachment'
attachments: Array<Record<string, unknown>>
attachments: AttachmentItem[]
}
export type ContentBlock = TextBlock | ThinkingBlock | ToolCallBlock | AttachmentBlock
@@ -389,24 +399,24 @@ export const useChatStore = defineStore('chat', () => {
function handleLocalStreamEvent(event: StreamEvent) {
// Cross-channel events arrive without a pending session. Detect them via
// source_channel metadata injected by the RouteHubBroadcaster.
const meta = (event as Record<string, unknown>).metadata as Record<string, unknown> | undefined
const meta = event.metadata as Record<string, unknown> | undefined
const sourceChannel = meta?.source_channel as string | undefined
const isCrossChannel = !!sourceChannel
// Cross-channel user message (the inbound message from Telegram/Feishu user).
if (isCrossChannel && (event.type ?? '').toLowerCase() === 'final' && meta?.role === 'user') {
const finalPayload = (event as Record<string, unknown>).final as Record<string, unknown> | undefined
const finalPayload = event.final as Record<string, unknown> | undefined
const msg = finalPayload?.message as Record<string, unknown> | undefined
if (msg) {
const text = String(msg.text ?? '').trim()
const msgMeta = (msg.metadata as Record<string, unknown> | undefined)
const senderName = (msgMeta?.sender_display_name as string) ?? sourceChannel
const msgMeta = msg.metadata as Record<string, unknown> | undefined
const senderName = (msgMeta?.sender_display_name as string | undefined) ?? sourceChannel
const senderUserId = String(meta?.sender_user_id ?? '').trim()
const blocks: ContentBlock[] = []
if (text) blocks.push({ type: 'text', content: text })
const rawAtts = (msg.attachments ?? msg.Attachments) as Array<Record<string, unknown>> | undefined
if (Array.isArray(rawAtts) && rawAtts.length > 0) {
const items = rawAtts.map((a) => ({
const items: AttachmentItem[] = rawAtts.map((a) => ({
type: mediaTypeFromMime(String(a.mime ?? '')),
content_hash: String(a.content_hash ?? ''),
bot_id: currentBotId.value ?? '',
@@ -633,14 +643,13 @@ export const useChatStore = defineStore('chat', () => {
if (chatId.value) touchChat(chatId.value)
}
function handleStreamEvent(targetBotId: string, event: Record<string, unknown>) {
if (String(event.type ?? '').toLowerCase() !== 'message_created') return
const eBotId = String(event.bot_id ?? '').trim()
function handleStreamEvent(targetBotId: string, event: MessageStreamEvent) {
if ((event.type ?? '').toLowerCase() !== 'message_created') return
const eBotId = (event.bot_id ?? '').trim()
if (eBotId && eBotId !== targetBotId) return
const payload = event.message
if (!payload || typeof payload !== 'object') return
const raw = payload as Message
const pBotId = String(raw.bot_id ?? '').trim()
const raw = event.message
if (!raw) return
const pBotId = (raw.bot_id ?? '').trim()
if (pBotId && pBotId !== targetBotId) return
appendRealtimeMessage(raw)
}
@@ -654,7 +663,7 @@ export const useChatStore = defineStore('chat', () => {
await streamMessageEvents(
bid,
signal,
(e) => handleStreamEvent(bid, e as unknown as Record<string, unknown>),
(e) => handleStreamEvent(bid, e),
messageEventsSince || undefined,
)
})