From 51495ba5838e3c8bccfaa2c61df84cf58a46eea6 Mon Sep 17 00:00:00 2001 From: Acbox Date: Fri, 10 Apr 2026 16:44:44 +0800 Subject: [PATCH] feat: ui message --- .../composables/api/useChat.message-api.ts | 21 + apps/web/src/composables/api/useChat.types.ts | 97 +- .../src/composables/api/useChat.ws.test.ts | 74 ++ apps/web/src/composables/api/useChat.ws.ts | 21 +- .../pages/home/components/message-item.vue | 131 +- .../pages/home/components/tool-call-exec.vue | 24 +- .../home/components/tool-call-generic.vue | 32 +- .../pages/home/composables/useMediaGallery.ts | 19 +- apps/web/src/store/chat-list.ts | 1076 ++++++----------- apps/web/src/store/chat-list.utils.test.ts | 79 ++ apps/web/src/store/chat-list.utils.ts | 38 + internal/agent/agent.go | 8 + internal/agent/stream.go | 32 +- internal/conversation/uimessage.go | 96 ++ internal/conversation/uimessage_convert.go | 609 ++++++++++ internal/conversation/uimessage_stream.go | 176 +++ internal/conversation/uimessage_test.go | 277 +++++ internal/handlers/local_channel.go | 99 +- internal/handlers/message.go | 6 + 19 files changed, 2141 insertions(+), 774 deletions(-) create mode 100644 apps/web/src/composables/api/useChat.ws.test.ts create mode 100644 apps/web/src/store/chat-list.utils.test.ts create mode 100644 apps/web/src/store/chat-list.utils.ts create mode 100644 internal/conversation/uimessage.go create mode 100644 internal/conversation/uimessage_convert.go create mode 100644 internal/conversation/uimessage_stream.go create mode 100644 internal/conversation/uimessage_test.go diff --git a/apps/web/src/composables/api/useChat.message-api.ts b/apps/web/src/composables/api/useChat.message-api.ts index 89a927e5..f5cdf81b 100644 --- a/apps/web/src/composables/api/useChat.message-api.ts +++ b/apps/web/src/composables/api/useChat.message-api.ts @@ -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 { + 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 diff --git a/apps/web/src/composables/api/useChat.types.ts b/apps/web/src/composables/api/useChat.types.ts index c31e4d59..c8248424 100644 --- a/apps/web/src/composables/api/useChat.types.ts +++ b/apps/web/src/composables/api/useChat.types.ts @@ -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> 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 +} + +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 diff --git a/apps/web/src/composables/api/useChat.ws.test.ts b/apps/web/src/composables/api/useChat.ws.test.ts new file mode 100644 index 00000000..cba3d9dd --- /dev/null +++ b/apps/web/src/composables/api/useChat.ws.test.ts @@ -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', + }) + }) +}) diff --git a/apps/web/src/composables/api/useChat.ws.ts b/apps/web/src/composables/api/useChat.ws.ts index edc37386..fe50a594 100644 --- a/apps/web/src/composables/api/useChat.ws.ts +++ b/apps/web/src/composables/api/useChat.ws.ts @@ -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 | 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. } diff --git a/apps/web/src/pages/home/components/message-item.vue b/apps/web/src/pages/home/components/message-item.vue index 467e4b75..e2514368 100644 --- a/apps/web/src/pages/home/components/message-item.vue +++ b/apps/web/src/pages/home/components/message-item.vue @@ -68,11 +68,15 @@ class="space-y-2" > +

+

-
-
- -
+
+
-
+

-
- {{ cleanUserText(block.content) }} -
- + {{ cleanUserText(message.text) }}
+

-->