feat: ui message

This commit is contained in:
Acbox
2026-04-10 16:44:44 +08:00
parent d3bf6bc90a
commit 51495ba583
19 changed files with 2141 additions and 774 deletions
@@ -7,6 +7,7 @@ import type {
Message,
MessageStreamEvent,
StreamEventHandler,
UITurn,
} from './useChat.types'
import { parseStreamPayload, readSSEStream } from './useChat.sse'
@@ -28,6 +29,26 @@ export async function fetchMessages(
return (data as unknown as { items?: Message[] })?.items ?? []
}
export async function fetchMessagesUI(
botId: string,
sessionId?: string,
options?: FetchMessagesOptions,
): Promise<UITurn[]> {
const response = await client.get({
url: '/bots/{bot_id}/messages',
path: { bot_id: botId },
query: {
limit: options?.limit ?? 30,
format: 'ui',
...(options?.before?.trim() ? { before: options.before.trim() } : {}),
...(sessionId?.trim() ? { session_id: sessionId.trim() } : {}),
},
throwOnError: true,
})
return (response.data as { items?: UITurn[] } | undefined)?.items ?? []
}
export interface SendMessageOverrides {
modelId?: string
reasoningEffort?: string
+96 -1
View File
@@ -50,7 +50,7 @@ export interface StreamEvent {
type?:
| 'text_start' | 'text_delta' | 'text_end'
| 'reasoning_start' | 'reasoning_delta' | 'reasoning_end'
| 'tool_call_start' | 'tool_call_end'
| 'tool_call_start' | 'tool_call_progress' | 'tool_call_end'
| 'attachment_delta' | 'reaction_delta'
| 'agent_start' | 'agent_end' | 'agent_abort'
| 'processing_started' | 'processing_completed' | 'processing_failed'
@@ -59,6 +59,7 @@ export interface StreamEvent {
toolCallId?: string
toolName?: string
input?: unknown
progress?: unknown
result?: unknown
attachments?: Array<Record<string, unknown>>
error?: string
@@ -88,3 +89,97 @@ export interface ChatAttachment {
mime?: string
name?: string
}
export interface UIAttachment {
id?: string
type: string
path?: string
url?: string
base64?: string
name?: string
content_hash?: string
bot_id?: string
mime?: string
size?: number
storage_key?: string
metadata?: Record<string, unknown>
}
export interface UITextMessage {
id: number
type: 'text'
content: string
}
export interface UIReasoningMessage {
id: number
type: 'reasoning'
content: string
}
export interface UIToolMessage {
id: number
type: 'tool'
name: string
input: unknown
output?: unknown
tool_call_id: string
running: boolean
progress?: unknown[]
}
export interface UIAttachmentsMessage {
id: number
type: 'attachments'
attachments: UIAttachment[]
}
export type UIMessage = UITextMessage | UIReasoningMessage | UIToolMessage | UIAttachmentsMessage
export interface UIUserTurn {
role: 'user'
text: string
attachments?: UIAttachment[]
timestamp: string
platform?: string
sender_display_name?: string
sender_avatar_url?: string
sender_user_id?: string
id?: string
}
export interface UIAssistantTurn {
role: 'assistant'
messages: UIMessage[]
timestamp: string
platform?: string
id?: string
}
export type UITurn = UIUserTurn | UIAssistantTurn
export interface UIStreamStartEvent {
type: 'start'
}
export interface UIStreamMessageEvent {
type: 'message'
data: UIMessage
}
export interface UIStreamEndEvent {
type: 'end'
}
export interface UIStreamErrorEvent {
type: 'error'
message: string
}
export type UIStreamEvent =
| UIStreamStartEvent
| UIStreamMessageEvent
| UIStreamEndEvent
| UIStreamErrorEvent
export type UIStreamEventHandler = (event: UIStreamEvent) => void
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { client } from '@memohai/sdk/client'
import { connectWebSocket } from './useChat.ws'
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static instances: MockWebSocket[] = []
readyState = MockWebSocket.CONNECTING
sent: string[] = []
onopen: (() => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
constructor(public readonly url: string) {
MockWebSocket.instances.push(this)
}
send(payload: string) {
this.sent.push(payload)
}
close() {
this.readyState = MockWebSocket.CLOSED
this.onclose?.()
}
open() {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
}
}
describe('useChat.ws', () => {
beforeEach(() => {
MockWebSocket.instances = []
vi.unstubAllGlobals()
client.setConfig({ baseUrl: '/api' })
vi.stubGlobal('window', {
location: {
protocol: 'http:',
host: 'localhost:8082',
},
})
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => ''),
})
vi.stubGlobal('WebSocket', MockWebSocket)
})
it('queues outbound messages until socket opens', () => {
const onStreamEvent = vi.fn()
const ws = connectWebSocket('bot-1', onStreamEvent)
const socket = MockWebSocket.instances[0]
expect(socket).toBeDefined()
ws.send({ type: 'message', text: 'hello', session_id: 'session-1' })
expect(socket.sent).toEqual([])
socket.open()
expect(socket.sent).toHaveLength(1)
expect(JSON.parse(socket.sent[0]!)).toEqual({
type: 'message',
text: 'hello',
session_id: 'session-1',
})
})
})
+12 -9
View File
@@ -1,5 +1,5 @@
import { client } from '@memohai/sdk/client'
import type { StreamEvent, MessageStreamEvent, ChatAttachment, StreamEventHandler } from './useChat.types'
import type { ChatAttachment, UIStreamEvent, UIStreamEventHandler } from './useChat.types'
export interface WSClientMessage {
type: 'message' | 'abort'
@@ -43,8 +43,7 @@ function resolveWebSocketUrl(botId: string): string {
export function connectWebSocket(
botId: string,
onStreamEvent: StreamEventHandler,
onMessageEvent?: (event: MessageStreamEvent) => void,
onStreamEvent: UIStreamEventHandler,
): ChatWebSocket {
const id = botId.trim()
if (!id) throw new Error('bot id is required')
@@ -58,12 +57,16 @@ export function connectWebSocket(
let closed = false
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectDelay = 1000
const sendQueue: string[] = []
const handle: ChatWebSocket = {
send(msg: WSClientMessage) {
const payload = JSON.stringify(msg)
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
ws.send(payload)
return
}
sendQueue.push(payload)
},
abort() {
if (ws && ws.readyState === WebSocket.OPEN) {
@@ -96,6 +99,9 @@ export function connectWebSocket(
ws.onopen = () => {
isConnected = true
reconnectDelay = 1000
while (sendQueue.length > 0 && ws?.readyState === WebSocket.OPEN) {
ws.send(sendQueue.shift()!)
}
handle.onOpen?.()
}
@@ -114,14 +120,11 @@ export function connectWebSocket(
try {
const parsed = JSON.parse(event.data)
if (!parsed || typeof parsed !== 'object') return
const eventType = String(parsed.type ?? '').trim()
if (eventType === 'message_created' && onMessageEvent) {
onMessageEvent(parsed as MessageStreamEvent)
if (eventType !== 'start' && eventType !== 'message' && eventType !== 'end' && eventType !== 'error') {
return
}
onStreamEvent(parsed as StreamEvent)
onStreamEvent(parsed as UIStreamEvent)
} catch {
// Ignore unparsable messages.
}
@@ -68,11 +68,15 @@
class="space-y-2"
>
<HeartbeatTriggerBlock
v-for="(block, i) in message.blocks.filter(b => b.type === 'text')"
:key="i"
:content="block.content ?? ''"
v-if="message.text"
:content="message.text"
:bot-id="botId"
/>
<AttachmentBlock
v-if="userAttachmentBlock"
:block="userAttachmentBlock"
:on-open-media="onOpenMedia"
/>
<p
class="text-xs text-muted-foreground/80 mt-1"
:title="fullTimestamp"
@@ -87,11 +91,15 @@
class="space-y-2"
>
<ScheduleTriggerBlock
v-for="(block, i) in message.blocks.filter(b => b.type === 'text')"
:key="i"
:content="block.content ?? ''"
v-if="message.text"
:content="message.text"
:bot-id="botId"
/>
<AttachmentBlock
v-if="userAttachmentBlock"
:block="userAttachmentBlock"
:on-open-media="onOpenMedia"
/>
<p
class="text-xs text-muted-foreground/80 mt-1"
:title="fullTimestamp"
@@ -106,28 +114,23 @@
class="space-y-2"
>
<div
v-for="(block, i) in message.blocks"
:key="i"
v-if="message.text"
class="w-full rounded-lg border border-violet-200 dark:border-violet-400/20 bg-violet-50/50 dark:bg-violet-950/20 px-4 py-3"
>
<div
v-if="block.type === 'text' && block.content"
class="w-full rounded-lg border border-violet-200 dark:border-violet-400/20 bg-violet-50/50 dark:bg-violet-950/20 px-4 py-3"
>
<div class="prose prose-sm dark:prose-invert max-w-none *:first:mt-0">
<MarkdownRender
:content="block.content"
:is-dark="isDark"
:typewriter="message.streaming"
custom-id="chat-msg"
/>
</div>
<div class="prose prose-sm dark:prose-invert max-w-none *:first:mt-0">
<MarkdownRender
:content="message.text"
:is-dark="isDark"
:typewriter="message.streaming"
custom-id="chat-msg"
/>
</div>
<AttachmentBlock
v-else-if="block.type === 'attachment'"
:block="(block as AttachmentBlockType)"
:on-open-media="onOpenMedia"
/>
</div>
<AttachmentBlock
v-if="userAttachmentBlock"
:block="userAttachmentBlock"
:on-open-media="onOpenMedia"
/>
<p
class="text-xs text-muted-foreground/80 mt-1"
:title="fullTimestamp"
@@ -142,24 +145,19 @@
class="space-y-2"
>
<div
v-for="(block, i) in message.blocks"
:key="i"
v-if="cleanUserText(message.text)"
class="rounded-2xl px-3 py-2 text-xs whitespace-pre-wrap break-all"
:class="isSelf
? 'rounded-tr-sm bg-foreground text-background'
: 'rounded-tl-sm bg-accent text-foreground'"
>
<div
v-if="block.type === 'text' && cleanUserText(block.content)"
class="rounded-2xl px-3 py-2 text-xs whitespace-pre-wrap break-all"
:class="isSelf
? 'rounded-tr-sm bg-foreground text-background'
: 'rounded-tl-sm bg-accent text-foreground'"
>
{{ cleanUserText(block.content) }}
</div>
<AttachmentBlock
v-else-if="block.type === 'attachment'"
:block="(block as AttachmentBlockType)"
:on-open-media="onOpenMedia"
/>
{{ cleanUserText(message.text) }}
</div>
<AttachmentBlock
v-if="userAttachmentBlock"
:block="userAttachmentBlock"
:on-open-media="onOpenMedia"
/>
<p
class="text-xs text-muted-foreground/80 mt-1"
:title="fullTimestamp"
@@ -182,19 +180,19 @@
</p> -->
<template
v-for="(block, i) in message.blocks"
v-for="(block, i) in message.messages"
:key="i"
>
<!-- Thinking block -->
<ThinkingBlock
v-if="block.type === 'thinking'"
v-if="block.type === 'reasoning'"
:block="(block as ThinkingBlockType)"
:streaming="message.streaming && !block.done"
:streaming="isAssistantBlockStreaming(i)"
/>
<!-- Tool call block -->
<ToolCallBlock
v-else-if="block.type === 'tool_call'"
v-else-if="block.type === 'tool' && !isHiddenToolMessage(block)"
:block="(block as ToolCallBlockType)"
/>
@@ -206,14 +204,14 @@
<MarkdownRender
:content="block.content"
:is-dark="isDark"
:typewriter="message.streaming"
:typewriter="isAssistantBlockStreaming(i)"
custom-id="chat-msg"
/>
</div>
<!-- Attachment block -->
<AttachmentBlock
v-else-if="block.type === 'attachment'"
v-else-if="block.type === 'attachments'"
:block="(block as AttachmentBlockType)"
:on-open-media="onOpenMedia"
/>
@@ -221,7 +219,7 @@
<!-- Streaming indicator -->
<div
v-if="message.streaming && message.blocks.length === 0"
v-if="message.streaming && !hasVisibleAssistantBlocks"
class="flex items-center gap-2 text-xs text-muted-foreground h-6"
>
<LoaderCircle
@@ -259,6 +257,7 @@ import ChannelBadge from '@/components/chat-list/channel-badge/index.vue'
// import { useI18n } from 'vue-i18n'
import type {
ChatMessage,
ContentBlock,
ThinkingBlock as ThinkingBlockType,
ToolCallBlock as ToolCallBlockType,
AttachmentBlock as AttachmentBlockType,
@@ -280,7 +279,9 @@ const props = defineProps<{
// const chatStore = useChatStore()
// const { currentBotId, bots } = storeToRefs(chatStore)
const isSelf = computed(() => props.message.isSelf !== false)
const isSelf = computed(() =>
props.message.role !== 'user' || props.message.isSelf !== false,
)
// const currentBot = computed(() =>
// bots.value.find((b) => b.id === currentBotId.value) ?? null,
@@ -300,7 +301,7 @@ const isSelf = computed(() => props.message.isSelf !== false)
// })
const senderFallback = computed(() => {
const name = props.message.senderDisplayName ?? ''
const name = props.message.role === 'user' ? (props.message.senderDisplayName ?? '') : ''
return name.slice(0, 2).toUpperCase() || '?'
})
@@ -324,10 +325,38 @@ const contentClass = computed(() => {
return 'flex-1 max-w-full'
})
const userAttachmentBlock = computed<AttachmentBlockType | null>(() => {
if (props.message.role !== 'user' || props.message.attachments.length === 0) return null
return {
id: -1,
type: 'attachments',
attachments: props.message.attachments,
}
})
function isHiddenToolMessage(block: ContentBlock): boolean {
if (block.type !== 'tool') return false
const result = block.result as Record<string, unknown> | null
return !!result && typeof result === 'object' && result.delivered === 'current_conversation'
}
function hasLaterAssistantMessage(index: number): boolean {
return props.message.role === 'assistant' && props.message.messages.slice(index + 1).length > 0
}
function isAssistantBlockStreaming(index: number): boolean {
return props.message.role === 'assistant' && props.message.streaming && !hasLaterAssistantMessage(index)
}
const hasVisibleAssistantBlocks = computed(() =>
props.message.role === 'assistant'
&& props.message.messages.some(block => block.type !== 'tool' || !isHiddenToolMessage(block)),
)
const relativeTimestamp = computed(() =>
formatRelativeTime(props.message.timestamp),
)
const fullTimestamp = computed(() =>
formatDateTime(props.message.timestamp.toISOString()),
formatDateTime(props.message.timestamp),
)
</script>
@@ -58,6 +58,10 @@
{{ $t('chat.toolExecOutput') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre
v-if="progressText"
class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all"
>{{ progressText }}</pre>
<pre
v-if="stdout"
class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all"
@@ -137,7 +141,23 @@ const stderr = computed(() => {
return (r?.stderr as string) ?? ''
})
const hasOutput = computed(() =>
props.block.done && (stdout.value || stderr.value || errorText.value),
const progressText = computed(() =>
(props.block.progress ?? [])
.map((item) => formatProgress(item))
.filter(Boolean)
.join('\n'),
)
const hasOutput = computed(() =>
!!(progressText.value || (props.block.done && (stdout.value || stderr.value || errorText.value))),
)
function formatProgress(val: unknown): string {
if (typeof val === 'string') return val
try {
return JSON.stringify(val, null, 2)
} catch {
return String(val)
}
}
</script>
@@ -44,6 +44,22 @@
</CollapsibleContent>
</Collapsible>
<Collapsible
v-if="progressText"
v-model:open="progressOpen"
>
<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">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': progressOpen }"
/>
{{ $t('chat.toolRunning') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all">{{ progressText }}</pre>
</CollapsibleContent>
</Collapsible>
<Collapsible
v-if="block.done && block.result != null"
v-model:open="resultOpen"
@@ -63,17 +79,25 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { Check, LoaderCircle, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
defineProps<{
const inputOpen = ref(false)
const progressOpen = ref(true)
const resultOpen = ref(false)
const props = defineProps<{
block: ToolCallBlock
}>()
const inputOpen = ref(false)
const resultOpen = ref(false)
const progressText = computed(() =>
(props.block.progress ?? [])
.map(item => formatJson(item))
.filter(Boolean)
.join('\n'),
)
function formatJson(val: unknown): string {
if (typeof val === 'string') return val
@@ -70,8 +70,23 @@ export function useMediaGallery(messages: Ref<ChatMessage[]>) {
const items = computed((): MediaGalleryItem[] => {
const result: MediaGalleryItem[] = []
for (const msg of messages.value) {
for (const block of msg.blocks) {
if (block.type !== 'attachment') continue
if (msg.role === 'user') {
for (const att of msg.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,
})
}
continue
}
for (const block of msg.messages) {
if (block.type !== 'attachments') continue
for (const att of block.attachments) {
if (!isMediaType(att)) continue
const src = resolveUrl(att)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { shouldRefreshFromMessageCreated, upsertById } from './chat-list.utils'
describe('chat-list.utils', () => {
it('replaces existing item with same id and preserves order', () => {
const items = [
{ id: 2, content: 'second' },
{ id: 4, content: 'fourth' },
]
expect(upsertById(items, { id: 2, content: 'updated' })).toEqual([
{ id: 2, content: 'updated' },
{ id: 4, content: 'fourth' },
])
})
it('inserts new item and sorts by id', () => {
const items = [
{ id: 4, content: 'fourth' },
{ id: 8, content: 'eighth' },
]
expect(upsertById(items, { id: 6, content: 'sixth' })).toEqual([
{ id: 4, content: 'fourth' },
{ id: 6, content: 'sixth' },
{ id: 8, content: 'eighth' },
])
})
it('refreshes only for current session message_created events', () => {
expect(shouldRefreshFromMessageCreated('bot-1', 'session-1', null, {
type: 'message_created',
bot_id: 'bot-1',
message: {
id: 'm1',
bot_id: 'bot-1',
session_id: 'session-1',
role: 'user',
content: 'hello',
created_at: '2026-04-10T10:00:00Z',
},
})).toBe(true)
expect(shouldRefreshFromMessageCreated('bot-1', 'session-1', null, {
type: 'message_created',
bot_id: 'bot-1',
message: {
id: 'm2',
bot_id: 'bot-1',
session_id: 'session-2',
role: 'user',
content: 'hello',
created_at: '2026-04-10T10:00:00Z',
},
})).toBe(false)
expect(shouldRefreshFromMessageCreated('bot-1', 'session-1', null, {
type: 'session_title_updated',
bot_id: 'bot-1',
session_id: 'session-1',
title: 'new title',
})).toBe(false)
})
it('does not refresh current session while a local stream is active', () => {
expect(shouldRefreshFromMessageCreated('bot-1', 'session-1', 'session-1', {
type: 'message_created',
bot_id: 'bot-1',
message: {
id: 'm3',
bot_id: 'bot-1',
session_id: 'session-1',
role: 'user',
content: 'hello',
created_at: '2026-04-10T10:00:00Z',
},
})).toBe(false)
})
})
+38
View File
@@ -0,0 +1,38 @@
import type { MessageStreamEvent } from '@/composables/api/useChat'
export function upsertById<T extends { id: number }>(items: T[], incoming: T): T[] {
const next = [...items]
const index = next.findIndex(item => item.id === incoming.id)
if (index >= 0) {
next[index] = incoming
} else {
next.push(incoming)
next.sort((a, b) => a.id - b.id)
}
return next
}
export function shouldRefreshFromMessageCreated(
targetBotId: string,
currentSessionId: string | null,
streamingSessionId: string | null,
event: MessageStreamEvent,
): boolean {
if ((event.type ?? '').toLowerCase() !== 'message_created') return false
const raw = event.message
if (!raw) return false
const eventBotId = String(event.bot_id ?? '').trim()
if (eventBotId && eventBotId !== targetBotId) return false
const messageBotId = String(raw.bot_id ?? '').trim()
if (messageBotId && messageBotId !== targetBotId) return false
const messageSessionId = String(raw.session_id ?? '').trim()
if (!currentSessionId) return false
if (messageSessionId && messageSessionId !== currentSessionId) return false
if (streamingSessionId && streamingSessionId === currentSessionId) return false
return true
}