mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat: add media asset system, channel lifecycle refactor, and chat attachments (#54)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "飞书",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user