feat: add media asset system, channel lifecycle refactor, and chat attachments (#54)

This commit is contained in:
BBQ
2026-02-17 19:06:46 +08:00
committed by GitHub
parent 0bdc31311c
commit df7876a30c
106 changed files with 7942 additions and 1274 deletions
@@ -105,22 +105,26 @@
</FormItem>
</FormField>
<!-- Multimodal (chat only) -->
<FormField
v-if="selectedType === 'chat'"
v-slot="{ componentField }"
name="is_multimodal"
>
<FormItem class="flex items-center justify-between">
<Label>
{{ $t('models.multimodal') }}
</Label>
<Switch
v-model="componentField.modelValue"
@update:model-value="componentField['onUpdate:modelValue']"
/>
</FormItem>
</FormField>
<!-- Input Modalities (chat only) -->
<div v-if="selectedType === 'chat'">
<Label class="mb-2">
{{ $t('models.inputModalities') }}
</Label>
<div class="flex flex-wrap gap-3 mt-2">
<label
v-for="mod in availableInputModalities"
:key="mod"
class="flex items-center gap-1.5 text-sm"
>
<Checkbox
:model-value="selectedModalities.includes(mod)"
:disabled="mod === 'text'"
@update:model-value="(val: boolean) => toggleModality(mod, val)"
/>
{{ $t(`models.modality.${mod}`) }}
</label>
</div>
</div>
</div>
<DialogFooter class="mt-4">
<DialogClose as-child>
@@ -163,7 +167,7 @@ import {
SelectTrigger,
SelectValue,
FormItem,
Switch,
Checkbox,
Separator,
Label,
Spinner,
@@ -176,12 +180,14 @@ import { useMutation, useQueryCache } from '@pinia/colada'
import { postModels, putModelsModelByModelId } from '@memoh/sdk'
import type { ModelsGetResponse } from '@memoh/sdk'
const availableInputModalities = ['text', 'image', 'audio', 'video', 'file'] as const
const selectedModalities = ref<string[]>(['text'])
const formSchema = toTypedSchema(z.object({
type: z.string().min(1),
model_id: z.string().min(1),
name: z.string().optional(),
dimensions: z.coerce.number().min(1).optional(),
is_multimodal: z.coerce.boolean().optional(),
}))
const form = useForm({
@@ -202,13 +208,19 @@ const canSubmit = computed(() => {
return !!type && !!model_id
})
// 新建时的空值
function toggleModality(mod: string, checked: boolean) {
if (checked) {
selectedModalities.value = [...selectedModalities.value, mod]
} else {
selectedModalities.value = selectedModalities.value.filter(m => m !== mod)
}
}
const emptyValues = {
type: '' as string,
model_id: '' as string,
name: '' as string,
dimensions: undefined as number | undefined,
is_multimodal: undefined as boolean | undefined,
}
// Display Name 自动跟随 Model ID,除非用户主动修改过
@@ -263,7 +275,6 @@ async function addModel(e: Event) {
const model_id = form.values.model_id || (isEdit ? fallback!.model_id : '')
const name = form.values.name ?? (isEdit ? fallback!.name : '')
const dimensions = form.values.dimensions ?? (isEdit ? fallback!.dimensions : undefined)
const is_multimodal = form.values.is_multimodal ?? (isEdit ? fallback!.is_multimodal : undefined)
if (!type || !model_id) return
@@ -283,7 +294,7 @@ async function addModel(e: Event) {
}
if (type === 'chat') {
payload.is_multimodal = is_multimodal ?? false
payload.input_modalities = selectedModalities.value.length > 0 ? selectedModalities.value : ['text']
}
if (isEdit) {
@@ -308,13 +319,13 @@ watch(open, async () => {
await nextTick()
if (editInfo?.value) {
const { type, model_id, name, dimensions, is_multimodal } = editInfo.value
form.resetForm({ values: { type, model_id, name, dimensions, is_multimodal } })
// 编辑时,如果已有 name 且与 model_id 不同,视为用户自定义
const { type, model_id, name, dimensions, input_modalities } = editInfo.value
form.resetForm({ values: { type, model_id, name, dimensions } })
selectedModalities.value = input_modalities ?? ['text']
userEditedName.value = !!(name && name !== model_id)
} else {
// 新建模式:显式传空值,避免复用上次编辑数据
form.resetForm({ values: { ...emptyValues } })
selectedModalities.value = ['text']
userEditedName.value = false
}
}, {
+31 -1
View File
@@ -18,6 +18,20 @@ export interface ChatSummary {
last_observed_at?: string
}
export interface MessageAsset {
asset_id: string
role: string
ordinal: number
media_type: string
mime: string
size_bytes: number
storage_key: string
original_name?: string
width?: number
height?: number
duration_ms?: number
}
export interface Message {
id: string
bot_id: string
@@ -32,6 +46,7 @@ export interface Message {
role: string
content?: unknown
metadata?: Record<string, unknown>
assets?: MessageAsset[]
created_at?: string
}
@@ -40,13 +55,16 @@ export interface StreamEvent {
| 'text_start' | 'text_delta' | 'text_end'
| 'reasoning_start' | 'reasoning_delta' | 'reasoning_end'
| 'tool_call_start' | 'tool_call_end'
| 'attachment_delta'
| 'agent_start' | 'agent_end'
| 'processing_started' | 'processing_completed' | 'processing_failed'
| 'error'
delta?: string
toolCallId?: string
toolName?: string
input?: unknown
result?: unknown
attachments?: Array<Record<string, unknown>>
error?: string
message?: string
[key: string]: unknown
@@ -199,6 +217,13 @@ export async function fetchMessages(
* Stream a chat message via SSE. Sends parsed StreamEvents to onEvent callback.
* Returns an abort function.
*/
export interface ChatAttachment {
type: string
base64: string
mime?: string
name?: string
}
export function streamMessage(
botId: string,
_chatId: string,
@@ -206,15 +231,20 @@ export function streamMessage(
onEvent: StreamEventHandler,
onDone: () => void,
onError: (err: Error) => void,
attachments?: ChatAttachment[],
): () => void {
const controller = new AbortController()
;(async () => {
try {
const reqBody: Record<string, unknown> = { query: text, current_channel: 'web', channels: ['web'] }
if (attachments?.length) {
reqBody.attachments = attachments
}
const { data: body } = await client.post({
url: '/bots/{bot_id}/messages/stream',
path: { bot_id: botId },
body: { query: text, current_channel: 'web', channels: ['web'] },
body: reqBody,
parseAs: 'stream',
signal: controller.signal,
throwOnError: true,
+25 -2
View File
@@ -149,7 +149,15 @@
"displayNamePlaceholder": "Custom display name",
"dimensions": "Dimensions",
"dimensionsPlaceholder": "e.g. 1536",
"multimodal": "Multimodal"
"multimodal": "Multimodal",
"inputModalities": "Input Modalities",
"modality": {
"text": "Text",
"image": "Image",
"audio": "Audio",
"video": "Video",
"file": "File"
}
},
"provider": {
"add": "Add Provider",
@@ -250,13 +258,22 @@
},
"checks": {
"title": "Runtime Checks",
"subtitle": "Resource health is evaluated at request time.",
"subtitle": "View current health status and issue details.",
"ok": "No issues",
"hasIssue": "Has issues",
"issueCount": "{count} issues",
"empty": "No check items",
"loadFailed": "Failed to load runtime checks",
"actions": {},
"titles": {
"containerInit": "Container initialization",
"containerRecord": "Container record",
"containerTask": "Container task",
"containerDataPath": "Container data path",
"botDelete": "Bot deletion",
"mcpConnection": "MCP connection",
"channelConnection": "Channel connection"
},
"keys": {
"containerInit": "Container initialization",
"containerRecord": "Container record",
@@ -363,7 +380,13 @@
"save": "Save Platform Configuration",
"statusActive": "Active",
"statusInactive": "Inactive",
"actionEnable": "Enable",
"actionDisable": "Disable",
"saveOnly": "Save only",
"saveAndEnable": "Save and enable",
"deleteConfirm": "Are you sure you want to remove this platform?",
"deleteSuccess": "Platform removed",
"deleteFailed": "Failed to remove platform",
"noAvailableTypes": "All platform types have been configured",
"types": {
"feishu": "Feishu",
+25 -2
View File
@@ -145,7 +145,15 @@
"displayNamePlaceholder": "自定义显示名称",
"dimensions": "向量维度",
"dimensionsPlaceholder": "例如 1536",
"multimodal": "支持多模态"
"multimodal": "支持多模态",
"inputModalities": "输入模态",
"modality": {
"text": "文本",
"image": "图片",
"audio": "音频",
"video": "视频",
"file": "文件"
}
},
"provider": {
"add": "添加服务商",
@@ -246,13 +254,22 @@
},
"checks": {
"title": "运行时检查",
"subtitle": "附属资源健康状态在请求时实时检查。",
"subtitle": "查看当前健康状态与异常详情。",
"ok": "无异常",
"hasIssue": "存在异常",
"issueCount": "{count} 个异常",
"empty": "暂无检查项",
"loadFailed": "加载运行时检查失败",
"actions": {},
"titles": {
"containerInit": "容器初始化",
"containerRecord": "容器记录",
"containerTask": "容器任务",
"containerDataPath": "容器数据路径",
"botDelete": "Bot 删除",
"mcpConnection": "MCP 连接",
"channelConnection": "平台连接"
},
"keys": {
"containerInit": "容器初始化",
"containerRecord": "容器记录",
@@ -359,7 +376,13 @@
"save": "保存平台配置",
"statusActive": "启用",
"statusInactive": "停用",
"actionEnable": "启用",
"actionDisable": "停用",
"saveOnly": "仅保存",
"saveAndEnable": "立即启用",
"deleteConfirm": "确定要移除这个平台吗?",
"deleteSuccess": "平台已移除",
"deleteFailed": "移除平台失败",
"noAvailableTypes": "所有平台类型均已配置",
"types": {
"feishu": "飞书",
+14
View File
@@ -28,6 +28,7 @@ import {
faCheck,
faEye,
faEyeSlash,
faChevronLeft,
faChevronRight,
faChevronDown,
faEllipsisVertical,
@@ -45,6 +46,12 @@ import {
faBrain,
faCopy,
faCompress,
faPaperclip,
faXmark,
faImage,
faFile,
faMusic,
faVideo,
} from '@fortawesome/free-solid-svg-icons'
import {
faRectangleList,
@@ -66,6 +73,7 @@ library.add(
faCheck,
faEye,
faEyeSlash,
faChevronLeft,
faChevronRight,
faChevronDown,
faEllipsisVertical,
@@ -83,6 +91,12 @@ library.add(
faBrain,
faCopy,
faCompress,
faPaperclip,
faXmark,
faImage,
faFile,
faMusic,
faVideo,
faRectangleList,
faTrashCan,
faComments,
@@ -51,7 +51,7 @@
</div>
<div class="text-xs">
<span
v-if="item.config?.status === 'active' || item.config?.status === 'verified'"
v-if="!item.config?.disabled"
class="text-green-600 dark:text-green-400"
>
{{ $t('bots.channels.statusActive') }}
@@ -142,7 +142,7 @@ import {
PopoverTrigger,
PopoverContent,
} from '@memoh/ui'
import { useQuery, useQueryCache } from '@pinia/colada'
import { useQuery } from '@pinia/colada'
import { getChannels, getBotsByIdChannelByPlatform } from '@memoh/sdk'
import type { HandlersChannelMeta, ChannelChannelConfig } from '@memoh/sdk'
import ChannelSettingsPanel from './channel-settings-panel.vue'
@@ -190,13 +190,13 @@ const addPopoverOpen = ref(false)
const allChannels = computed<BotChannelItem[]>(() => channels.value ?? [])
const configuredChannels = computed(() => allChannels.value.filter((c) => c.configured))
const unconfiguredChannels = computed(() => allChannels.value.filter((c) => !c.configured))
const selectedItem = computed(() =>
allChannels.value.find((c) => c.meta.type === selectedType.value) ?? null,
)
// 自动选中第一个已配置的渠道
watch(configuredChannels, (list) => {
if (list.length > 0 && !selectedType.value) {
selectedType.value = list[0].meta.type
@@ -10,9 +10,41 @@
{{ channelItem.meta.type }}
</p>
</div>
<Badge :variant="channelItem.configured ? 'default' : 'secondary'">
{{ channelItem.configured ? $t('bots.channels.configured') : $t('bots.channels.notConfigured') }}
</Badge>
<div class="flex items-center gap-2">
<template v-if="isEditMode">
<Button
variant="outline"
size="sm"
:disabled="isBusy"
@click="handleToggleDisabled"
>
<Spinner
v-if="action === 'toggle'"
class="mr-1.5"
/>
{{ form.disabled ? $t('bots.channels.actionEnable') : $t('bots.channels.actionDisable') }}
</Button>
<ConfirmPopover
:message="$t('bots.channels.deleteConfirm')"
:loading="action === 'delete'"
@confirm="handleDelete"
>
<template #trigger>
<Button
variant="destructive"
size="sm"
:disabled="isBusy"
>
<Spinner
v-if="action === 'delete'"
class="mr-1.5"
/>
{{ $t('common.delete') }}
</Button>
</template>
</ConfirmPopover>
</template>
</div>
</div>
<Separator />
@@ -111,31 +143,48 @@
<Separator />
<!-- Status -->
<div class="flex items-center justify-between">
<Label>{{ $t('common.status') }}</Label>
<Switch
:model-value="form.status === 'active'"
@update:model-value="(val) => form.status = val ? 'active' : 'inactive'"
/>
</div>
<!-- Save -->
<div class="flex justify-end">
<Button
:disabled="isLoading"
@click="handleSave"
>
<Spinner v-if="isLoading" />
{{ $t('bots.channels.save') }}
</Button>
<div class="flex justify-end gap-2">
<template v-if="isEditMode">
<Button
:disabled="isBusy"
@click="handleEditSave"
>
<Spinner
v-if="action === 'save'"
class="mr-1.5"
/>
{{ $t('common.save') }}
</Button>
</template>
<template v-else>
<Button
variant="outline"
:disabled="isBusy"
@click="handleCreateSaveOnly"
>
<Spinner
v-if="action === 'save'"
class="mr-1.5"
/>
{{ $t('bots.channels.saveOnly') }}
</Button>
<Button
:disabled="isBusy"
@click="handleCreateSaveAndEnable"
>
<Spinner
v-if="action === 'save'"
class="mr-1.5"
/>
{{ $t('bots.channels.saveAndEnable') }}
</Button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {
Badge,
Button,
Input,
Label,
@@ -148,13 +197,14 @@ import {
SelectContent,
SelectItem,
} from '@memoh/ui'
import { reactive, watch, computed } from 'vue'
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 type { Ref } from 'vue'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
interface BotChannelItem {
meta: HandlersChannelMeta
@@ -172,7 +222,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const botIdRef = computed(() => props.botId) as Ref<string>
const botIdRef = computed(() => props.botId)
const queryCache = useQueryCache()
const { mutateAsync: upsertChannel, isLoading } = useMutation({
mutation: async ({ platform, data }: { platform: string; data: Record<string, unknown> }) => {
@@ -185,23 +235,37 @@ const { mutateAsync: upsertChannel, isLoading } = useMutation({
},
onSettled: () => queryCache.invalidateQueries({ key: ['bot-channels', botIdRef.value] }),
})
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`,
body: { disabled },
throwOnError: true,
})
return data as ChannelChannelConfig
},
onSettled: () => queryCache.invalidateQueries({ key: ['bot-channels', botIdRef.value] }),
})
const action = ref<'save' | 'toggle' | 'delete' | ''>('')
const isBusy = computed(() => isLoading.value || isStatusLoading.value || action.value !== '')
const isEditMode = computed(() => props.channelItem.configured)
// ---- Form state ----
const form = reactive<{
credentials: Record<string, unknown>
status: string
disabled: boolean
}>({
credentials: {},
status: 'inactive',
disabled: false,
})
const visibleSecrets = reactive<Record<string, boolean>>({})
// Schema fields sorted: required first
// Schema fields sorted: required first. Exclude "status"/"disabled" from credential form.
const orderedFields = computed(() => {
const fields = props.channelItem.meta.config_schema?.fields ?? {}
const entries = Object.entries(fields)
const entries = Object.entries(fields).filter(([key]) => key !== 'status' && key !== 'disabled')
entries.sort(([, a], [, b]) => {
if (a.required && !b.required) return -1
if (!a.required && b.required) return 1
@@ -210,7 +274,6 @@ const orderedFields = computed(() => {
return Object.fromEntries(entries) as Record<string, ChannelFieldSchema>
})
//
function initForm() {
const schema = props.channelItem.meta.config_schema?.fields ?? {}
const existingCredentials = props.channelItem.config?.credentials ?? {}
@@ -220,8 +283,7 @@ function initForm() {
creds[key] = existingCredentials[key] ?? ''
}
form.credentials = creds
const rawStatus = props.channelItem.config?.status ?? 'inactive'
form.status = (rawStatus === 'active' || rawStatus === 'verified') ? 'active' : 'inactive'
form.disabled = props.channelItem.config?.disabled ?? false
}
watch(
@@ -230,7 +292,6 @@ watch(
{ immediate: true },
)
//
function validateRequired(): boolean {
const schema = props.channelItem.meta.config_schema?.fields ?? {}
for (const [key, field] of Object.entries(schema)) {
@@ -245,24 +306,28 @@ function validateRequired(): boolean {
return true
}
async function handleSave() {
function buildCredentials(): Record<string, unknown> {
const credentials: Record<string, unknown> = {}
for (const [key, val] of Object.entries(form.credentials)) {
if (key === 'status' || key === 'disabled') continue
if (val === '' || val === undefined || val === null) continue
credentials[key] = val
}
return credentials
}
async function saveChannel(disabled: boolean, nextAction: 'save' | 'toggle') {
if (!validateRequired()) return
action.value = nextAction
try {
const credentials: Record<string, unknown> = {}
for (const [key, val] of Object.entries(form.credentials)) {
if (val !== '' && val !== undefined && val !== null) {
credentials[key] = val
}
}
await upsertChannel({
platform: props.channelItem.meta.type,
data: {
credentials,
status: form.status,
credentials: buildCredentials(),
disabled,
},
})
form.disabled = disabled
toast.success(t('bots.channels.saveSuccess'))
emit('saved')
} catch (err) {
@@ -271,6 +336,56 @@ async function handleSave() {
detail = err.message
}
toast.error(detail ? `${t('bots.channels.saveFailed')}: ${detail}` : t('bots.channels.saveFailed'))
} finally {
action.value = ''
}
}
async function handleCreateSaveOnly() {
await saveChannel(true, 'save')
}
async function handleCreateSaveAndEnable() {
await saveChannel(false, 'save')
}
async function handleEditSave() {
await saveChannel(form.disabled, 'save')
}
async function handleToggleDisabled() {
action.value = 'toggle'
try {
const nextDisabled = !form.disabled
const result = await updateChannelStatus({
platform: props.channelItem.meta.type,
disabled: nextDisabled,
})
form.disabled = !!result?.disabled
toast.success(t('bots.channels.saveSuccess'))
emit('saved')
} catch (err) {
const detail = err instanceof Error ? err.message : ''
toast.error(detail ? `${t('bots.channels.saveFailed')}: ${detail}` : t('bots.channels.saveFailed'))
} finally {
action.value = ''
}
}
async function handleDelete() {
action.value = 'delete'
try {
await client.delete({
url: `/bots/${botIdRef.value}/channel/${props.channelItem.meta.type}`,
throwOnError: true,
})
toast.success(t('bots.channels.deleteSuccess'))
emit('saved')
} catch (err) {
const detail = err instanceof Error ? err.message : ''
toast.error(detail ? `${t('bots.channels.deleteFailed')}: ${detail}` : t('bots.channels.deleteFailed'))
} finally {
action.value = ''
}
}
</script>
+26 -82
View File
@@ -186,37 +186,33 @@
>
<li
v-for="item in checks"
:key="item.check_key"
:key="item.id"
class="py-3 first:pt-0 last:pb-0"
>
<div class="flex items-center justify-between gap-2">
<p class="font-mono text-xs">{{ checkKeyLabel(item.check_key) }}</p>
<div v-if="isCheckLoading(item)">
<Spinner class="size-3.5" />
<div class="min-w-0">
<p class="font-mono text-xs">{{ checkTitleLabel(item) }}</p>
<p
v-if="item.subtitle"
class="mt-0.5 text-xs text-muted-foreground"
>
{{ item.subtitle }}
</p>
</div>
<Badge
v-else
:variant="checkStatusVariant(item.status)"
class="text-[10px]"
>
{{ checkStatusLabel(item.status) }}
</Badge>
</div>
<p class="mt-2 text-sm">{{ item.summary }}</p>
<p
v-if="isCheckLoading(item)"
class="mt-2 text-sm text-muted-foreground"
v-if="item.detail"
class="mt-1 text-xs text-muted-foreground break-all"
>
{{ $t('common.loading') }}
{{ item.detail }}
</p>
<template v-else>
<p class="mt-2 text-sm">{{ item.summary }}</p>
<p
v-if="item.detail"
class="mt-1 text-xs text-muted-foreground break-all"
>
{{ item.detail }}
</p>
</template>
</li>
</ul>
</div>
@@ -553,11 +549,11 @@ import { useI18n } from 'vue-i18n'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import {
getBotsById, putBotsById,
getBotsByIdChecks,
getBotsByBotIdContainer, postBotsByBotIdContainer, deleteBotsByBotIdContainer,
postBotsByBotIdContainerStart, postBotsByBotIdContainerStop,
getBotsByBotIdContainerSnapshots, postBotsByBotIdContainerSnapshots,
} from '@memoh/sdk'
import { client } from '@memoh/sdk/client'
import type {
BotsBotCheck, HandlersGetContainerResponse,
HandlersListSnapshotsResponse,
@@ -598,20 +594,9 @@ const { mutateAsync: updateBot, isLoading: updateBotLoading } = useMutation({
},
})
async function fetchCheckKeys(id: string): Promise<string[]> {
const { data } = await client.get({
url: `/bots/${id}/checks/keys`,
throwOnError: true,
}) as { data: { keys: string[] } }
return data.keys ?? []
}
async function fetchSingleCheck(id: string, key: string): Promise<BotCheck> {
const { data } = await client.get({
url: `/bots/${id}/checks/run/${key}`,
throwOnError: true,
}) as { data: BotCheck }
return data
async function fetchChecks(id: string): Promise<BotCheck[]> {
const { data } = await getBotsByIdChecks({ path: { id }, throwOnError: true })
return data?.items ?? []
}
const isEditingBotName = ref(false)
@@ -704,13 +689,6 @@ const botLifecyclePending = computed(() => (
const checks = ref<BotCheck[]>([])
const checksLoading = ref(false)
const checkKeyI18nKeys: Record<string, string> = {
'container.init': 'bots.checks.keys.containerInit',
'container.record': 'bots.checks.keys.containerRecord',
'container.task': 'bots.checks.keys.containerTask',
'container.data_path': 'bots.checks.keys.containerDataPath',
'bot.delete': 'bots.checks.keys.botDelete',
}
const checksSummaryText = computed(() => {
const issueCount = checks.value.filter((item) => item.status === 'warn' || item.status === 'error').length
if (issueCount > 0) {
@@ -873,56 +851,22 @@ function checkStatusLabel(status: BotCheck['status']): string {
return t('bots.checks.status.ok')
}
function isCheckLoading(item: BotCheck): boolean {
return item.status === 'unknown' && !item.summary
}
function checkKeyLabel(checkKey: string): string {
const key = checkKeyI18nKeys[checkKey]
if (!key) {
return checkKey
function checkTitleLabel(item: BotCheck): string {
const titleKey = (item.title_key ?? '').trim()
if (titleKey) {
const translated = t(titleKey)
if (translated !== titleKey) {
return translated
}
}
return t(key)
return (item.type ?? '').trim() || (item.id ?? '').trim() || '-'
}
async function loadChecks(showToast: boolean) {
checksLoading.value = true
checks.value = []
try {
const keys = await fetchCheckKeys(botId.value)
if (keys.length === 0) return
// Maintain key order: pre-fill placeholders, replace as results arrive.
const keyOrder = new Map(keys.map((k, i) => [k, i]))
checks.value = keys.map((key) => ({
check_key: key,
status: 'unknown' as BotCheck['status'],
summary: '',
}))
const pending = keys.map(async (key) => {
try {
const result = await fetchSingleCheck(botId.value, key)
const idx = keyOrder.get(key)
if (idx !== undefined) {
const updated = [...checks.value]
updated[idx] = result
checks.value = updated
}
} catch {
const idx = keyOrder.get(key)
if (idx !== undefined) {
const updated = [...checks.value]
updated[idx] = {
check_key: key,
status: 'error' as BotCheck['status'],
summary: 'Check failed',
}
checks.value = updated
}
}
})
await Promise.all(pending)
checks.value = await fetchChecks(botId.value)
} catch (error) {
if (showToast) {
toast.error(resolveErrorMessage(error, t('bots.checks.loadFailed')))
@@ -0,0 +1,105 @@
<template>
<div class="flex flex-wrap gap-2">
<template
v-for="(att, i) in block.attachments"
:key="i"
>
<!-- Image / video thumbnail -->
<button
v-if="isImage(att) || isVideo(att)"
type="button"
class="block w-48 h-48 rounded-lg overflow-hidden border bg-muted/20 hover:ring-2 ring-primary/40 transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary/40"
@click="handleMediaClick(att)"
>
<img
v-if="isImage(att)"
:src="getUrl(att)"
:alt="String(att.name ?? 'image')"
class="w-full h-full object-contain pointer-events-none"
loading="lazy"
/>
<video
v-else
:src="getUrl(att)"
class="w-full h-full object-contain pointer-events-none"
preload="metadata"
muted
playsinline
/>
</button>
<!-- Downloadable file -->
<a
v-else-if="getUrl(att)"
:href="getUrl(att)"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-3 py-2 rounded-lg border bg-muted/30 hover:bg-muted/60 transition-colors text-sm"
>
<FontAwesomeIcon
:icon="['fas', fileIcon(att)]"
class="size-4 text-muted-foreground"
/>
<span class="truncate max-w-[200px]">
{{ String(att.name ?? 'file') }}
</span>
</a>
<!-- Non-accessible attachment -->
<div
v-else
class="flex items-center gap-2 px-3 py-2 rounded-lg border bg-muted/30 text-sm text-muted-foreground"
>
<FontAwesomeIcon
:icon="['fas', fileIcon(att)]"
class="size-4"
/>
<span class="truncate max-w-[200px]">
{{ String(att.name ?? att.storage_key ?? 'attachment') }}
</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { AttachmentBlock } from '@/store/chat-list'
import { resolveUrl, isMediaType } from '../composables/useMediaGallery'
const props = defineProps<{
block: AttachmentBlock
onOpenMedia?: (src: string) => void
}>()
function getUrl(att: Record<string, unknown>): string {
return resolveUrl(att)
}
function isImage(att: Record<string, unknown>): boolean {
const type = String(att.type ?? '').toLowerCase()
if (type === 'image' || type === 'gif') return true
const mime = String(att.mime ?? '').toLowerCase()
return mime.startsWith('image/')
}
function isVideo(att: Record<string, unknown>): boolean {
const type = String(att.type ?? '').toLowerCase()
if (type === 'video') return true
const mime = String(att.mime ?? '').toLowerCase()
return mime.startsWith('video/')
}
function handleMediaClick(att: Record<string, unknown>) {
const src = getUrl(att)
if (src && props.onOpenMedia) {
props.onOpenMedia(src)
}
}
function fileIcon(att: Record<string, unknown>): string {
const type = String(att.type ?? '').toLowerCase()
if (type === 'audio' || type === 'voice') return 'music'
if (type === 'video') return 'video'
return 'file'
}
</script>
@@ -74,37 +74,88 @@
v-for="msg in messages"
:key="msg.id"
:message="msg"
:on-open-media="galleryOpenBySrc"
/>
</div>
</div>
<!-- Media gallery lightbox -->
<MediaGalleryLightbox
:items="galleryItems"
:open-index="galleryOpenIndex"
@update:open-index="gallerySetOpenIndex"
/>
<!-- Input -->
<div class="border-t p-4">
<div class="max-w-3xl mx-auto relative">
<Textarea
v-model="inputText"
class="pr-16 min-h-[60px] max-h-[200px] resize-none"
:placeholder="activeChatReadOnly ? $t('chat.readonlyHint') : $t('chat.inputPlaceholder')"
:disabled="!currentBotId || activeChatReadOnly"
@keydown.enter.exact="handleKeydown"
/>
<div class="absolute right-2 bottom-2">
<Button
v-if="!streaming"
size="sm"
:disabled="!inputText.trim() || !currentBotId || activeChatReadOnly"
@click="handleSend"
<div class="max-w-3xl mx-auto">
<!-- Pending attachment previews -->
<div
v-if="pendingFiles.length"
class="flex flex-wrap gap-2 mb-2"
>
<div
v-for="(file, i) in pendingFiles"
:key="i"
class="relative group flex items-center gap-1.5 px-2 py-1 rounded-md border bg-muted/40 text-xs"
>
<FontAwesomeIcon :icon="['fas', 'paper-plane']" class="size-3.5" />
</Button>
<Button
v-else
size="sm"
variant="destructive"
@click="chatStore.abort()"
>
<FontAwesomeIcon :icon="['fas', 'spinner']" class="size-3.5 animate-spin" />
</Button>
<FontAwesomeIcon
:icon="['fas', file.type.startsWith('image/') ? 'image' : 'file']"
class="size-3 text-muted-foreground"
/>
<span class="truncate max-w-[120px]">{{ file.name }}</span>
<button
class="ml-1 text-muted-foreground hover:text-foreground"
@click="pendingFiles.splice(i, 1)"
>
<FontAwesomeIcon :icon="['fas', 'xmark']" class="size-3" />
</button>
</div>
</div>
<div class="relative">
<Textarea
v-model="inputText"
class="pr-24 min-h-[60px] max-h-[200px] resize-none"
:placeholder="activeChatReadOnly ? $t('chat.readonlyHint') : $t('chat.inputPlaceholder')"
:disabled="!currentBotId || activeChatReadOnly"
@keydown.enter.exact="handleKeydown"
@paste="handlePaste"
/>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.zip"
@change="handleFileSelect"
/>
<div class="absolute right-2 bottom-2 flex items-center gap-1">
<Button
v-if="!streaming"
size="sm"
variant="ghost"
:disabled="!currentBotId || activeChatReadOnly"
@click="fileInput?.click()"
>
<FontAwesomeIcon :icon="['fas', 'paperclip']" class="size-3.5" />
</Button>
<Button
v-if="!streaming"
size="sm"
:disabled="(!inputText.trim() && !pendingFiles.length) || !currentBotId || activeChatReadOnly"
@click="handleSend"
>
<FontAwesomeIcon :icon="['fas', 'paper-plane']" class="size-3.5" />
</Button>
<Button
v-else
size="sm"
variant="destructive"
@click="chatStore.abort()"
>
<FontAwesomeIcon :icon="['fas', 'spinner']" class="size-3.5 animate-spin" />
</Button>
</div>
</div>
</div>
</div>
@@ -118,8 +169,13 @@ import { Textarea, Button, Avatar, AvatarImage, AvatarFallback, Badge } from '@m
import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
import MessageItem from './message-item.vue'
import MediaGalleryLightbox from './media-gallery-lightbox.vue'
import { useMediaGallery } from '../composables/useMediaGallery'
import type { ChatAttachment } from '@/composables/api/useChat'
const chatStore = useChatStore()
const fileInput = ref<HTMLInputElement | null>(null)
const pendingFiles = ref<File[]>([])
const {
messages,
streaming,
@@ -131,6 +187,13 @@ const {
hasMoreOlder,
} = storeToRefs(chatStore)
const {
items: galleryItems,
openIndex: galleryOpenIndex,
setOpenIndex: gallerySetOpenIndex,
openBySrc: galleryOpenBySrc,
} = useMediaGallery(messages)
const inputText = ref('')
const scrollContainer = ref<HTMLElement>()
@@ -146,14 +209,11 @@ onMounted(() => {
let userScrolledUp = false
function scrollToBottom(smooth = true) {
function scrollToBottom(instant = false) {
nextTick(() => {
const el = scrollContainer.value
if (!el) return
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? 'smooth' : 'instant',
})
el.scrollTo({ top: el.scrollHeight, behavior: instant ? 'instant' : 'smooth' })
})
}
@@ -163,7 +223,6 @@ function handleScroll() {
const distanceFromBottom = el.scrollHeight - el.clientHeight - el.scrollTop
userScrolledUp = distanceFromBottom > 50
// Load older messages when scrolled near top
if (el.scrollTop < 200 && hasMoreOlder.value && !loadingOlder.value) {
const prevHeight = el.scrollHeight
chatStore.loadOlderMessages().then((count) => {
@@ -176,6 +235,14 @@ function handleScroll() {
}
}
// After full load (initial / chat switch), instantly jump to bottom
watch(loadingChats, (cur, prev) => {
if (prev && !cur) {
userScrolledUp = false
scrollToBottom(true)
}
})
// Stream content auto-scroll
watch(
() => {
@@ -191,10 +258,11 @@ watch(
},
)
// New message auto-scroll
// New realtime message auto-scroll
watch(
() => messages.value.length,
() => {
if (loadingChats.value) return
userScrolledUp = false
scrollToBottom()
},
@@ -206,10 +274,54 @@ function handleKeydown(e: KeyboardEvent) {
handleSend()
}
function handleSend() {
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) {
pendingFiles.value.push(...Array.from(input.files))
}
input.value = ''
}
function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) pendingFiles.value.push(file)
}
}
}
async function fileToAttachment(file: File): Promise<ChatAttachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve({
type: file.type.startsWith('image/') ? 'image' : 'file',
base64: reader.result as string,
mime: file.type || 'application/octet-stream',
name: file.name,
})
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
async function handleSend() {
const text = inputText.value.trim()
if (!text || streaming.value || activeChatReadOnly.value) return
const files = [...pendingFiles.value]
if ((!text && !files.length) || streaming.value || activeChatReadOnly.value) return
inputText.value = ''
chatStore.sendMessage(text)
pendingFiles.value = []
let attachments: ChatAttachment[] | undefined
if (files.length) {
attachments = await Promise.all(files.map(fileToAttachment))
}
chatStore.sendMessage(text, attachments)
}
</script>
@@ -0,0 +1,144 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="isOpen"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/90"
@click.self="close"
>
<!-- Close -->
<button
class="absolute right-4 top-4 z-10 rounded-full p-2 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Close"
@click="close"
>
<FontAwesomeIcon :icon="['fas', 'xmark']" class="size-6" />
</button>
<!-- Prev -->
<button
v-if="items.length > 1"
class="absolute left-4 top-1/2 -translate-y-1/2 z-10 rounded-full p-3 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Previous"
@click.stop="prev"
>
<FontAwesomeIcon :icon="['fas', 'chevron-left']" class="size-6" />
</button>
<!-- Next -->
<button
v-if="items.length > 1"
class="absolute right-4 top-1/2 -translate-y-1/2 z-10 rounded-full p-3 text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Next"
@click.stop="next"
>
<FontAwesomeIcon :icon="['fas', 'chevron-right']" class="size-6" />
</button>
<!-- Media content -->
<div class="max-w-[90vw] max-h-[90vh] flex items-center justify-center">
<img
v-if="currentItem?.type === 'image'"
:src="currentItem.src"
:alt="currentItem.name || 'image'"
class="max-w-full max-h-[90vh] object-contain select-none"
draggable="false"
@click.stop
/>
<video
v-else-if="currentItem?.type === 'video'"
:src="currentItem.src"
controls
class="max-w-full max-h-[90vh] object-contain"
@click.stop
/>
</div>
<!-- Counter -->
<div
v-if="items.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-black/50 text-white/90 text-sm"
>
{{ (openIndex ?? 0) + 1 }} / {{ items.length }}
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watchEffect, onUnmounted } from 'vue'
export interface MediaGalleryItem {
src: string
type: 'image' | 'video'
name?: string
}
const props = defineProps<{
items: MediaGalleryItem[]
openIndex: number | null
}>()
const emit = defineEmits<{
'update:openIndex': [value: number | null]
}>()
const isOpen = computed(() => props.openIndex !== null && props.items.length > 0)
const currentItem = computed(() => {
const idx = props.openIndex
if (idx === null || idx < 0 || idx >= props.items.length) return null
return props.items[idx] ?? null
})
function close() {
emit('update:openIndex', null)
}
function prev() {
if (props.openIndex === null) return
const idx = props.openIndex <= 0 ? props.items.length - 1 : props.openIndex - 1
emit('update:openIndex', idx)
}
function next() {
if (props.openIndex === null) return
const idx = props.openIndex >= props.items.length - 1 ? 0 : props.openIndex + 1
emit('update:openIndex', idx)
}
function handleKeydown(e: KeyboardEvent) {
if (props.openIndex === null) return
if (e.key === 'Escape') close()
else if (e.key === 'ArrowLeft') prev()
else if (e.key === 'ArrowRight') next()
}
let removeListener: (() => void) | null = null
watchEffect(() => {
if (isOpen.value) {
window.addEventListener('keydown', handleKeydown)
removeListener = () => window.removeEventListener('keydown', handleKeydown)
} else if (removeListener) {
removeListener()
removeListener = null
}
})
onUnmounted(() => {
removeListener?.()
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -52,6 +52,7 @@
<div
class="min-w-0"
:class="contentClass"
data-chat-content
>
<!-- Sender name for non-self user messages -->
<p
@@ -64,12 +65,27 @@
<!-- User message -->
<div
v-if="message.role === 'user'"
class="rounded-2xl px-4 py-2.5 text-sm whitespace-pre-wrap"
:class="isSelf
? 'rounded-tr-sm bg-primary text-primary-foreground'
: 'rounded-tl-sm bg-accent/60 text-foreground'"
class="space-y-2"
>
{{ (message.blocks[0] as TextBlock)?.content }}
<div
v-for="(block, i) in message.blocks"
:key="i"
>
<div
v-if="block.type === 'text' && cleanUserText(block.content)"
class="rounded-2xl px-4 py-2.5 text-sm whitespace-pre-wrap"
:class="isSelf
? 'rounded-tr-sm bg-primary text-primary-foreground'
: 'rounded-tl-sm bg-accent/60 text-foreground'"
>
{{ cleanUserText(block.content) }}
</div>
<AttachmentBlock
v-else-if="block.type === 'attachment'"
:block="(block as AttachmentBlockType)"
:on-open-media="onOpenMedia"
/>
</div>
</div>
<!-- Assistant message blocks -->
@@ -112,6 +128,13 @@
custom-id="chat-msg"
/>
</div>
<!-- Attachment block -->
<AttachmentBlock
v-else-if="block.type === 'attachment'"
:block="(block as AttachmentBlockType)"
:on-open-media="onOpenMedia"
/>
</template>
<!-- Streaming indicator -->
@@ -157,6 +180,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@memoh/ui'
import MarkdownRender, { enableKatex, enableMermaid } from 'markstream-vue'
import ThinkingBlock from './thinking-block.vue'
import ToolCallBlock from './tool-call-block.vue'
import AttachmentBlock from './attachment-block.vue'
import ChannelBadge from '@/components/chat-list/channel-badge/index.vue'
import { useUserStore } from '@/store/user'
import { useChatStore } from '@/store/chat-list'
@@ -164,9 +188,9 @@ import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import type {
ChatMessage,
TextBlock,
ThinkingBlock as ThinkingBlockType,
ToolCallBlock as ToolCallBlockType,
AttachmentBlock as AttachmentBlockType,
} from '@/store/chat-list'
enableKatex()
@@ -174,6 +198,7 @@ enableMermaid()
const props = defineProps<{
message: ChatMessage
onOpenMedia?: (src: string) => void
}>()
const userStore = useUserStore()
@@ -189,7 +214,6 @@ const currentBot = computed(() =>
const botAvatarUrl = computed(() => currentBot.value?.avatar_url ?? '')
const botName = computed(() => currentBot.value?.display_name ?? '')
// For isSelf messages: prefer channel avatar/name over web platform avatar
const selfAvatarUrl = computed(() =>
props.message.senderAvatarUrl || userStore.userInfo.avatarUrl || '',
)
@@ -216,10 +240,17 @@ const senderFallback = computed(() => {
return name.slice(0, 2).toUpperCase() || '?'
})
function cleanUserText(content?: string): string {
if (!content) return ''
return content
.split('\n')
.filter((line) => !/^\[attachment:\w+\]\s/.test(line.trim()))
.join('\n')
.trim()
}
const contentClass = computed(() => {
if (props.message.role === 'user') {
return isSelf.value ? 'max-w-[80%]' : 'max-w-[80%]'
}
if (props.message.role === 'user') return 'max-w-[80%]'
return 'flex-1 max-w-full'
})
</script>
@@ -27,7 +27,7 @@
</div>
<!-- Input (collapsible) -->
<Collapsible v-if="block.input">
<Collapsible v-if="block.input" v-model:open="inputOpen">
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
@@ -42,7 +42,7 @@
</Collapsible>
<!-- Result (collapsible) -->
<Collapsible v-if="block.done && block.result != null">
<Collapsible v-if="block.done && block.result != null" v-model:open="resultOpen">
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full border-t border-muted">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
@@ -0,0 +1,83 @@
import { computed, ref, type Ref } from 'vue'
import type { ChatMessage } from '@/store/chat-list'
import type { MediaGalleryItem } from '../components/media-gallery-lightbox.vue'
function isMediaType(att: Record<string, unknown>): boolean {
const type = String(att.type ?? '').toLowerCase()
if (type === 'image' || type === 'gif' || type === 'video') return true
const mime = String(att.mime ?? '').toLowerCase()
return mime.startsWith('image/') || mime.startsWith('video/')
}
function resolveUrl(att: Record<string, unknown>): string {
const url = String(att.url ?? '').trim()
if (url) return url
const assetId = String(att.asset_id ?? '').trim()
if (!assetId) return ''
const botId = String(att.bot_id ?? '').trim()
if (!botId) return ''
const token = localStorage.getItem('token') || ''
return `/api/bots/${botId}/media/${assetId}?token=${encodeURIComponent(token)}`
}
function normalizeSrc(src: string): string {
if (!src || src.startsWith('data:')) return src
try {
const u = new URL(src, window.location.origin)
return u.pathname + u.search
} catch {
return src
}
}
export function useMediaGallery(messages: Ref<ChatMessage[]>) {
const openIndex = ref<number | null>(null)
const items = computed((): MediaGalleryItem[] => {
const result: MediaGalleryItem[] = []
for (const msg of messages.value) {
for (const block of msg.blocks) {
if (block.type !== 'attachment') continue
for (const att of block.attachments) {
if (!isMediaType(att)) continue
const src = resolveUrl(att)
if (!src) continue
const type = String(att.type ?? '').toLowerCase()
result.push({
src,
type: type === 'video' ? 'video' : 'image',
name: String(att.name ?? '').trim() || undefined,
})
}
}
}
return result
})
function findIndexBySrc(src: string): number {
const norm = normalizeSrc(src)
if (!norm) return -1
return items.value.findIndex((item) => normalizeSrc(item.src) === norm)
}
function openBySrc(src: string) {
const idx = findIndexBySrc(src)
if (idx >= 0) {
openIndex.value = idx
}
}
function setOpenIndex(v: number | null) {
openIndex.value = v
}
return {
items,
openIndex,
setOpenIndex,
openBySrc,
findIndexBySrc,
}
}
export { resolveUrl, isMediaType }
+104 -15
View File
@@ -17,6 +17,7 @@ import {
extractAllToolResults,
streamMessage,
streamMessageEvents,
type ChatAttachment,
} from '@/composables/api/useChat'
// ---- Message model (blocks-based, aligned with main branch) ----
@@ -34,13 +35,19 @@ export interface ThinkingBlock {
export interface ToolCallBlock {
type: 'tool_call'
toolCallId: string
toolName: string
input: unknown
result: unknown | null
done: boolean
}
export type ContentBlock = TextBlock | ThinkingBlock | ToolCallBlock
export interface AttachmentBlock {
type: 'attachment'
attachments: Array<Record<string, unknown>>
}
export type ContentBlock = TextBlock | ThinkingBlock | ToolCallBlock | AttachmentBlock
export interface ChatMessage {
id: string
@@ -108,11 +115,32 @@ export const useChatStore = defineStore('chat', () => {
// ---- Message adapter: convert server Message to ChatMessage ----
function buildAssetBlocks(raw: Message): AttachmentBlock[] {
if (!raw.assets?.length) return []
const items: Array<Record<string, unknown>> = raw.assets.map((a) => ({
type: a.media_type,
asset_id: a.asset_id,
bot_id: raw.bot_id,
mime: a.mime,
size: a.size_bytes,
name: a.original_name ?? '',
storage_key: a.storage_key,
width: a.width,
height: a.height,
}))
return [{ type: 'attachment', attachments: items }]
}
function messageToChat(raw: Message): ChatMessage | null {
if (raw.role !== 'user' && raw.role !== 'assistant') return null
const text = extractMessageText(raw)
if (!text) return null
const assetBlocks = buildAssetBlocks(raw)
if (!text && assetBlocks.length === 0) return null
const blocks: ContentBlock[] = []
if (text) blocks.push({ type: 'text', content: text })
blocks.push(...assetBlocks)
const createdAt = raw.created_at ? new Date(raw.created_at) : new Date()
const timestamp = Number.isNaN(createdAt.getTime()) ? new Date() : createdAt
@@ -126,7 +154,7 @@ export const useChatStore = defineStore('chat', () => {
return {
id: raw.id || nextId(),
role: 'user',
blocks: [{ type: 'text', content: text }],
blocks,
timestamp,
streaming: false,
isSelf,
@@ -138,7 +166,7 @@ export const useChatStore = defineStore('chat', () => {
return {
id: raw.id || nextId(),
role: 'assistant',
blocks: [{ type: 'text', content: text }],
blocks,
timestamp,
streaming: false,
...(channelTag && { platform: channelTag }),
@@ -201,6 +229,7 @@ export const useChatStore = defineStore('chat', () => {
for (const tc of toolCalls) {
const block: ToolCallBlock = {
type: 'tool_call',
toolCallId: tc.id ?? '',
toolName: tc.name,
input: tc.input,
result: null,
@@ -528,9 +557,9 @@ export const useChatStore = defineStore('chat', () => {
// ---- Send message (blocks-based streaming) ----
async function sendMessage(text: string) {
async function sendMessage(text: string, attachments?: ChatAttachment[]) {
const trimmed = text.trim()
if (!trimmed || streaming.value || !currentBotId.value) return
if ((!trimmed && !attachments?.length) || streaming.value || !currentBotId.value) return
loading.value = true
streaming.value = true
@@ -543,10 +572,23 @@ export const useChatStore = defineStore('chat', () => {
const cid = chatId.value!
// Add user message
const userBlocks: ContentBlock[] = []
if (trimmed) userBlocks.push({ type: 'text', content: trimmed })
if (attachments?.length) {
userBlocks.push({
type: 'attachment',
attachments: attachments.map((a) => ({
type: a.type,
name: a.name ?? '',
mime: a.mime ?? '',
url: a.base64,
})),
})
}
messages.push({
id: nextId(),
role: 'user',
blocks: [{ type: 'text', content: trimmed }],
blocks: userBlocks,
timestamp: new Date(),
streaming: false,
})
@@ -615,6 +657,7 @@ export const useChatStore = defineStore('chat', () => {
case 'tool_call_start':
pushBlock({
type: 'tool_call',
toolCallId: (event.toolCallId as string) ?? '',
toolName: (event.toolName as string) ?? 'unknown',
input: event.input ?? null,
result: null,
@@ -623,16 +666,47 @@ export const useChatStore = defineStore('chat', () => {
textBlockIdx = -1
break
case 'tool_call_end':
for (let i = 0; i < assistantMsg.blocks.length; i++) {
const b = assistantMsg.blocks[i]
if (b && b.type === 'tool_call' && b.toolName === event.toolName && !b.done) {
b.result = event.result ?? null
b.done = true
break
case 'tool_call_end': {
const callId = (event.toolCallId as string) ?? ''
let matched = false
if (callId) {
for (let i = 0; i < assistantMsg.blocks.length; i++) {
const b = assistantMsg.blocks[i]
if (b && b.type === 'tool_call' && b.toolCallId === callId && !b.done) {
b.result = event.result ?? null
b.input = event.input ?? b.input
b.done = true
matched = true
break
}
}
}
if (!matched) {
for (let i = 0; i < assistantMsg.blocks.length; i++) {
const b = assistantMsg.blocks[i]
if (b && b.type === 'tool_call' && b.toolName === event.toolName && !b.done) {
b.result = event.result ?? null
b.input = event.input ?? b.input
b.done = true
break
}
}
}
break
}
case 'attachment_delta': {
const items = event.attachments
if (Array.isArray(items) && items.length > 0) {
const lastBlock = assistantMsg.blocks[assistantMsg.blocks.length - 1]
if (lastBlock && lastBlock.type === 'attachment') {
lastBlock.attachments.push(...items)
} else {
pushBlock({ type: 'attachment', attachments: [...items] })
}
}
break
}
case 'processing_started':
if (assistantMsg.blocks.length === 0) {
@@ -642,11 +716,25 @@ export const useChatStore = defineStore('chat', () => {
break
case 'processing_completed':
case 'processing_failed':
case 'agent_start':
case 'agent_end':
break
case 'processing_failed': {
const failMsg = typeof event.message === 'string'
? event.message
: typeof event.error === 'string'
? event.error
: ''
if (failMsg) {
if (textBlockIdx < 0 || assistantMsg.blocks[textBlockIdx]?.type !== 'text') {
textBlockIdx = pushBlock({ type: 'text', content: '' })
}
;(assistantMsg.blocks[textBlockIdx] as TextBlock).content += `\n\n**Error:** ${failMsg}`
}
break
}
case 'error': {
const errMsg = typeof event.message === 'string'
? event.message
@@ -691,6 +779,7 @@ export const useChatStore = defineStore('chat', () => {
loading.value = false
abortFn = null
},
attachments,
)
} catch (err) {
const reason = err instanceof Error ? err.message : 'Unknown error'