mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
refactor(web): fix types, ui, error handling, unhealthy styles
This commit is contained in:
@@ -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 = ''
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user