mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat: ui message
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
+387
-689
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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -216,6 +216,14 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv
|
||||
Input: p.Input,
|
||||
}
|
||||
|
||||
case *sdk.ToolProgressPart:
|
||||
ch <- StreamEvent{
|
||||
Type: EventToolCallProgress,
|
||||
ToolName: p.ToolName,
|
||||
ToolCallID: p.ToolCallID,
|
||||
Progress: p.Content,
|
||||
}
|
||||
|
||||
case *sdk.StreamToolResultPart:
|
||||
shouldAbort := false
|
||||
if _, ok := toolLoopAbortCallIDs[p.ToolCallID]; ok {
|
||||
|
||||
+17
-15
@@ -6,21 +6,22 @@ import "encoding/json"
|
||||
type StreamEventType string
|
||||
|
||||
const (
|
||||
EventAgentStart StreamEventType = "agent_start"
|
||||
EventTextStart StreamEventType = "text_start"
|
||||
EventTextDelta StreamEventType = "text_delta"
|
||||
EventTextEnd StreamEventType = "text_end"
|
||||
EventReasoningStart StreamEventType = "reasoning_start"
|
||||
EventReasoningDelta StreamEventType = "reasoning_delta"
|
||||
EventReasoningEnd StreamEventType = "reasoning_end"
|
||||
EventToolCallStart StreamEventType = "tool_call_start"
|
||||
EventToolCallEnd StreamEventType = "tool_call_end"
|
||||
EventAttachment StreamEventType = "attachment_delta"
|
||||
EventReaction StreamEventType = "reaction_delta"
|
||||
EventSpeech StreamEventType = "speech_delta"
|
||||
EventAgentEnd StreamEventType = "agent_end"
|
||||
EventAgentAbort StreamEventType = "agent_abort"
|
||||
EventError StreamEventType = "error"
|
||||
EventAgentStart StreamEventType = "agent_start"
|
||||
EventTextStart StreamEventType = "text_start"
|
||||
EventTextDelta StreamEventType = "text_delta"
|
||||
EventTextEnd StreamEventType = "text_end"
|
||||
EventReasoningStart StreamEventType = "reasoning_start"
|
||||
EventReasoningDelta StreamEventType = "reasoning_delta"
|
||||
EventReasoningEnd StreamEventType = "reasoning_end"
|
||||
EventToolCallStart StreamEventType = "tool_call_start"
|
||||
EventToolCallProgress StreamEventType = "tool_call_progress"
|
||||
EventToolCallEnd StreamEventType = "tool_call_end"
|
||||
EventAttachment StreamEventType = "attachment_delta"
|
||||
EventReaction StreamEventType = "reaction_delta"
|
||||
EventSpeech StreamEventType = "speech_delta"
|
||||
EventAgentEnd StreamEventType = "agent_end"
|
||||
EventAgentAbort StreamEventType = "agent_abort"
|
||||
EventError StreamEventType = "error"
|
||||
)
|
||||
|
||||
// StreamEvent is emitted by the agent during streaming.
|
||||
@@ -30,6 +31,7 @@ type StreamEvent struct {
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
Progress any `json:"progress,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Attachments []FileAttachment `json:"attachments,omitempty"`
|
||||
Reactions []ReactionItem `json:"reactions,omitempty"`
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UIMessageType identifies the frontend-friendly message block type.
|
||||
type UIMessageType string
|
||||
|
||||
const (
|
||||
UIMessageText UIMessageType = "text"
|
||||
UIMessageReasoning UIMessageType = "reasoning"
|
||||
UIMessageTool UIMessageType = "tool"
|
||||
UIMessageAttachments UIMessageType = "attachments"
|
||||
)
|
||||
|
||||
// UIAttachment is the normalized attachment shape used by the web frontend.
|
||||
type UIAttachment struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Base64 string `json:"base64,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ContentHash string `json:"content_hash,omitempty"`
|
||||
BotID string `json:"bot_id,omitempty"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
StorageKey string `json:"storage_key,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// UIMessage is the normalized assistant output block used by the web frontend.
|
||||
type UIMessage struct {
|
||||
ID int `json:"id"`
|
||||
Type UIMessageType `json:"type"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
Output any `json:"output,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
Running *bool `json:"running,omitempty"`
|
||||
Progress []any `json:"progress,omitempty"`
|
||||
Attachments []UIAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// UITurn is the normalized chat turn used by the web frontend.
|
||||
type UITurn struct {
|
||||
Role string `json:"role"`
|
||||
Messages []UIMessage `json:"messages,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Attachments []UIAttachment `json:"attachments,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
SenderDisplayName string `json:"sender_display_name,omitempty"`
|
||||
SenderAvatarURL string `json:"sender_avatar_url,omitempty"`
|
||||
SenderUserID string `json:"sender_user_id,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// UIMessageStreamEvent is the generic event shape accepted by the UI stream converter.
|
||||
// The handler layer adapts agent/channel events to this struct to avoid package cycles.
|
||||
type UIMessageStreamEvent struct {
|
||||
Type string
|
||||
Delta string
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
Input any
|
||||
Output any
|
||||
Progress any
|
||||
Attachments []UIAttachment
|
||||
Error string
|
||||
}
|
||||
|
||||
func uiBoolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func normalizeUIAttachmentType(kind, mime string) string {
|
||||
if trimmed := strings.ToLower(strings.TrimSpace(kind)); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
normalizedMime := strings.ToLower(strings.TrimSpace(mime))
|
||||
switch {
|
||||
case strings.HasPrefix(normalizedMime, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(normalizedMime, "audio/"):
|
||||
return "audio"
|
||||
case strings.HasPrefix(normalizedMime, "video/"):
|
||||
return "video"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
messagepkg "github.com/memohai/memoh/internal/message"
|
||||
)
|
||||
|
||||
var (
|
||||
uiMessageYAMLHeaderRe = regexp.MustCompile(`(?s)\A---\n.*?\n---\n?`)
|
||||
uiMessageAgentTagsRe = regexp.MustCompile(`(?s)<attachments>.*?</attachments>|<reactions>.*?</reactions>|<speech>.*?</speech>`)
|
||||
uiMessageCollapsedNewlinesRe = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
type uiContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Emoji string `json:"emoji,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
Output any `json:"output,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type uiExtractedToolCall struct {
|
||||
ID string
|
||||
Name string
|
||||
Input any
|
||||
}
|
||||
|
||||
type uiExtractedToolResult struct {
|
||||
ToolCallID string
|
||||
Output any
|
||||
}
|
||||
|
||||
type uiPendingAssistantTurn struct {
|
||||
Turn UITurn
|
||||
NextID int
|
||||
ToolIndexes map[string]int
|
||||
}
|
||||
|
||||
// ConvertRawModelMessagesToUIAssistantMessages converts terminal stream payload
|
||||
// messages into frontend-friendly assistant UI messages.
|
||||
func ConvertRawModelMessagesToUIAssistantMessages(raw json.RawMessage) []UIMessage {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []ModelMessage
|
||||
if err := json.Unmarshal(raw, &messages); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ConvertModelMessagesToUIAssistantMessages(messages)
|
||||
}
|
||||
|
||||
// ConvertModelMessagesToUIAssistantMessages converts assistant/tool output
|
||||
// messages into frontend-friendly UI message blocks.
|
||||
func ConvertModelMessagesToUIAssistantMessages(messages []ModelMessage) []UIMessage {
|
||||
pending := &uiPendingAssistantTurn{
|
||||
ToolIndexes: map[string]int{},
|
||||
}
|
||||
|
||||
for _, modelMessage := range messages {
|
||||
switch strings.ToLower(strings.TrimSpace(modelMessage.Role)) {
|
||||
case "assistant":
|
||||
for _, reasoning := range extractPersistedReasoning(modelMessage) {
|
||||
appendPendingAssistantMessage(pending, UIMessage{
|
||||
Type: UIMessageReasoning,
|
||||
Content: reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
if text := extractAssistantStreamMessageText(modelMessage); text != "" {
|
||||
appendPendingAssistantMessage(pending, UIMessage{
|
||||
Type: UIMessageText,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
|
||||
for _, call := range extractPersistedToolCalls(modelMessage) {
|
||||
appendPendingAssistantMessage(pending, UIMessage{
|
||||
Type: UIMessageTool,
|
||||
Name: call.Name,
|
||||
Input: call.Input,
|
||||
ToolCallID: call.ID,
|
||||
Running: uiBoolPtr(true),
|
||||
})
|
||||
if call.ID != "" {
|
||||
pending.ToolIndexes[call.ID] = len(pending.Turn.Messages) - 1
|
||||
}
|
||||
}
|
||||
|
||||
case "tool":
|
||||
for _, toolResult := range extractPersistedToolResults(modelMessage) {
|
||||
idx, ok := pending.ToolIndexes[toolResult.ToolCallID]
|
||||
if !ok || idx < 0 || idx >= len(pending.Turn.Messages) {
|
||||
continue
|
||||
}
|
||||
|
||||
if isHiddenCurrentConversationToolOutput(toolResult.Output) {
|
||||
removePendingAssistantMessage(pending, idx)
|
||||
delete(pending.ToolIndexes, toolResult.ToolCallID)
|
||||
continue
|
||||
}
|
||||
|
||||
pending.Turn.Messages[idx].Output = toolResult.Output
|
||||
pending.Turn.Messages[idx].Running = uiBoolPtr(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, idx := range pending.ToolIndexes {
|
||||
if idx >= 0 && idx < len(pending.Turn.Messages) {
|
||||
pending.Turn.Messages[idx].Running = uiBoolPtr(false)
|
||||
}
|
||||
}
|
||||
|
||||
return pending.Turn.Messages
|
||||
}
|
||||
|
||||
// ConvertMessagesToUITurns converts persisted message rows into frontend-friendly turns.
|
||||
func ConvertMessagesToUITurns(messages []messagepkg.Message) []UITurn {
|
||||
result := make([]UITurn, 0, len(messages))
|
||||
var pending *uiPendingAssistantTurn
|
||||
|
||||
flushPending := func() {
|
||||
if pending == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, idx := range pending.ToolIndexes {
|
||||
if idx < 0 || idx >= len(pending.Turn.Messages) {
|
||||
continue
|
||||
}
|
||||
pending.Turn.Messages[idx].Running = uiBoolPtr(false)
|
||||
}
|
||||
|
||||
if len(pending.Turn.Messages) > 0 {
|
||||
result = append(result, pending.Turn)
|
||||
}
|
||||
pending = nil
|
||||
}
|
||||
|
||||
for _, raw := range messages {
|
||||
modelMessage := decodePersistedModelMessage(raw)
|
||||
switch strings.ToLower(strings.TrimSpace(raw.Role)) {
|
||||
case "user":
|
||||
flushPending()
|
||||
|
||||
text := extractPersistedMessageText(raw, modelMessage)
|
||||
attachments := uiAttachmentsFromMessageAssets(raw)
|
||||
if text == "" && len(attachments) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
turn := UITurn{
|
||||
Role: "user",
|
||||
Text: text,
|
||||
Attachments: attachments,
|
||||
Timestamp: raw.CreatedAt,
|
||||
Platform: resolveUIPersistencePlatform(raw),
|
||||
ID: strings.TrimSpace(raw.ID),
|
||||
}
|
||||
if turn.Platform != "" {
|
||||
turn.SenderDisplayName = strings.TrimSpace(raw.SenderDisplayName)
|
||||
turn.SenderAvatarURL = strings.TrimSpace(raw.SenderAvatarURL)
|
||||
turn.SenderUserID = strings.TrimSpace(raw.SenderUserID)
|
||||
}
|
||||
result = append(result, turn)
|
||||
|
||||
case "assistant":
|
||||
toolCalls := extractPersistedToolCalls(modelMessage)
|
||||
text := extractPersistedMessageText(raw, modelMessage)
|
||||
reasonings := extractPersistedReasoning(modelMessage)
|
||||
attachments := uiAttachmentsFromMessageAssets(raw)
|
||||
|
||||
if len(toolCalls) > 0 {
|
||||
if pending == nil {
|
||||
pending = newPendingAssistantTurn(raw)
|
||||
}
|
||||
|
||||
for _, reasoning := range reasonings {
|
||||
appendPendingAssistantMessage(pending, UIMessage{
|
||||
ID: pending.NextID,
|
||||
Type: UIMessageReasoning,
|
||||
Content: reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
appendPendingAssistantMessage(pending, UIMessage{
|
||||
ID: pending.NextID,
|
||||
Type: UIMessageText,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
|
||||
for _, call := range toolCalls {
|
||||
block := UIMessage{
|
||||
ID: pending.NextID,
|
||||
Type: UIMessageTool,
|
||||
Name: call.Name,
|
||||
Input: call.Input,
|
||||
ToolCallID: call.ID,
|
||||
Running: uiBoolPtr(true),
|
||||
}
|
||||
appendPendingAssistantMessage(pending, block)
|
||||
if call.ID != "" {
|
||||
pending.ToolIndexes[call.ID] = len(pending.Turn.Messages) - 1
|
||||
}
|
||||
}
|
||||
|
||||
if len(attachments) > 0 {
|
||||
appendPendingAssistantMessage(pending, UIMessage{
|
||||
ID: pending.NextID,
|
||||
Type: UIMessageAttachments,
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if pending != nil && (text != "" || len(reasonings) > 0 || len(attachments) > 0) {
|
||||
for _, reasoning := range reasonings {
|
||||
appendPendingAssistantMessage(pending, UIMessage{
|
||||
ID: pending.NextID,
|
||||
Type: UIMessageReasoning,
|
||||
Content: reasoning,
|
||||
})
|
||||
}
|
||||
if text != "" {
|
||||
appendPendingAssistantMessage(pending, UIMessage{
|
||||
ID: pending.NextID,
|
||||
Type: UIMessageText,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
if len(attachments) > 0 {
|
||||
appendPendingAssistantMessage(pending, UIMessage{
|
||||
ID: pending.NextID,
|
||||
Type: UIMessageAttachments,
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
flushPending()
|
||||
continue
|
||||
}
|
||||
|
||||
flushPending()
|
||||
|
||||
assistantMessages := buildStandaloneAssistantMessages(text, reasonings, attachments)
|
||||
if len(assistantMessages) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, UITurn{
|
||||
Role: "assistant",
|
||||
Messages: assistantMessages,
|
||||
Timestamp: raw.CreatedAt,
|
||||
Platform: resolveUIPersistencePlatform(raw),
|
||||
ID: strings.TrimSpace(raw.ID),
|
||||
})
|
||||
|
||||
case "tool":
|
||||
if pending == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, toolResult := range extractPersistedToolResults(modelMessage) {
|
||||
idx, ok := pending.ToolIndexes[toolResult.ToolCallID]
|
||||
if !ok || idx < 0 || idx >= len(pending.Turn.Messages) {
|
||||
continue
|
||||
}
|
||||
|
||||
if isHiddenCurrentConversationToolOutput(toolResult.Output) {
|
||||
removePendingAssistantMessage(pending, idx)
|
||||
delete(pending.ToolIndexes, toolResult.ToolCallID)
|
||||
continue
|
||||
}
|
||||
|
||||
pending.Turn.Messages[idx].Output = toolResult.Output
|
||||
pending.Turn.Messages[idx].Running = uiBoolPtr(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushPending()
|
||||
return result
|
||||
}
|
||||
|
||||
func newPendingAssistantTurn(raw messagepkg.Message) *uiPendingAssistantTurn {
|
||||
return &uiPendingAssistantTurn{
|
||||
Turn: UITurn{
|
||||
Role: "assistant",
|
||||
Timestamp: raw.CreatedAt,
|
||||
Platform: resolveUIPersistencePlatform(raw),
|
||||
ID: strings.TrimSpace(raw.ID),
|
||||
},
|
||||
ToolIndexes: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
func appendPendingAssistantMessage(pending *uiPendingAssistantTurn, message UIMessage) {
|
||||
if pending == nil {
|
||||
return
|
||||
}
|
||||
message.ID = pending.NextID
|
||||
pending.NextID++
|
||||
pending.Turn.Messages = append(pending.Turn.Messages, message)
|
||||
}
|
||||
|
||||
func removePendingAssistantMessage(pending *uiPendingAssistantTurn, idx int) {
|
||||
if pending == nil || idx < 0 || idx >= len(pending.Turn.Messages) {
|
||||
return
|
||||
}
|
||||
|
||||
pending.Turn.Messages = append(pending.Turn.Messages[:idx], pending.Turn.Messages[idx+1:]...)
|
||||
for callID, currentIdx := range pending.ToolIndexes {
|
||||
switch {
|
||||
case currentIdx == idx:
|
||||
delete(pending.ToolIndexes, callID)
|
||||
case currentIdx > idx:
|
||||
pending.ToolIndexes[callID] = currentIdx - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildStandaloneAssistantMessages(text string, reasonings []string, attachments []UIAttachment) []UIMessage {
|
||||
messages := make([]UIMessage, 0, len(reasonings)+2)
|
||||
nextID := 0
|
||||
for _, reasoning := range reasonings {
|
||||
messages = append(messages, UIMessage{
|
||||
ID: nextID,
|
||||
Type: UIMessageReasoning,
|
||||
Content: reasoning,
|
||||
})
|
||||
nextID++
|
||||
}
|
||||
if text != "" {
|
||||
messages = append(messages, UIMessage{
|
||||
ID: nextID,
|
||||
Type: UIMessageText,
|
||||
Content: text,
|
||||
})
|
||||
nextID++
|
||||
}
|
||||
if len(attachments) > 0 {
|
||||
messages = append(messages, UIMessage{
|
||||
ID: nextID,
|
||||
Type: UIMessageAttachments,
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func decodePersistedModelMessage(raw messagepkg.Message) ModelMessage {
|
||||
var message ModelMessage
|
||||
if err := json.Unmarshal(raw.Content, &message); err != nil {
|
||||
return ModelMessage{
|
||||
Role: raw.Role,
|
||||
Content: raw.Content,
|
||||
}
|
||||
}
|
||||
message.Role = raw.Role
|
||||
return message
|
||||
}
|
||||
|
||||
func extractPersistedMessageText(raw messagepkg.Message, message ModelMessage) string {
|
||||
if strings.EqualFold(raw.Role, "user") {
|
||||
if text := strings.TrimSpace(raw.DisplayContent); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(extractTextFromPersistedContent(message.Content))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.EqualFold(raw.Role, "user") {
|
||||
return strings.TrimSpace(stripPersistedYAMLHeader(text))
|
||||
}
|
||||
return strings.TrimSpace(stripPersistedAgentTags(text))
|
||||
}
|
||||
|
||||
func extractAssistantStreamMessageText(message ModelMessage) string {
|
||||
return strings.TrimSpace(stripPersistedAgentTags(extractTextFromPersistedContent(message.Content)))
|
||||
}
|
||||
|
||||
func extractTextFromPersistedContent(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
parts := extractPersistedContentParts(raw)
|
||||
if len(parts) > 0 {
|
||||
lines := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
partType := strings.ToLower(strings.TrimSpace(part.Type))
|
||||
if partType == "reasoning" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case partType == "text" && strings.TrimSpace(part.Text) != "":
|
||||
lines = append(lines, strings.TrimSpace(part.Text))
|
||||
case partType == "link" && strings.TrimSpace(part.URL) != "":
|
||||
lines = append(lines, strings.TrimSpace(part.URL))
|
||||
case partType == "emoji" && strings.TrimSpace(part.Emoji) != "":
|
||||
lines = append(lines, strings.TrimSpace(part.Emoji))
|
||||
case strings.TrimSpace(part.Text) != "":
|
||||
lines = append(lines, strings.TrimSpace(part.Text))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(raw, &object); err == nil {
|
||||
if value, ok := object["text"].(string); ok {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractPersistedReasoning(message ModelMessage) []string {
|
||||
parts := extractPersistedContentParts(message.Content)
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
reasonings := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if strings.ToLower(strings.TrimSpace(part.Type)) != "reasoning" {
|
||||
continue
|
||||
}
|
||||
if text := strings.TrimSpace(part.Text); text != "" {
|
||||
reasonings = append(reasonings, text)
|
||||
}
|
||||
}
|
||||
return reasonings
|
||||
}
|
||||
|
||||
func extractPersistedToolCalls(message ModelMessage) []uiExtractedToolCall {
|
||||
parts := extractPersistedContentParts(message.Content)
|
||||
calls := make([]uiExtractedToolCall, 0, len(parts)+len(message.ToolCalls))
|
||||
for _, part := range parts {
|
||||
if strings.ToLower(strings.TrimSpace(part.Type)) != "tool-call" {
|
||||
continue
|
||||
}
|
||||
calls = append(calls, uiExtractedToolCall{
|
||||
ID: strings.TrimSpace(part.ToolCallID),
|
||||
Name: strings.TrimSpace(part.ToolName),
|
||||
Input: part.Input,
|
||||
})
|
||||
}
|
||||
if len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
input := any(nil)
|
||||
if rawArgs := strings.TrimSpace(toolCall.Function.Arguments); rawArgs != "" {
|
||||
if err := json.Unmarshal([]byte(rawArgs), &input); err != nil {
|
||||
input = rawArgs
|
||||
}
|
||||
}
|
||||
calls = append(calls, uiExtractedToolCall{
|
||||
ID: strings.TrimSpace(toolCall.ID),
|
||||
Name: strings.TrimSpace(toolCall.Function.Name),
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
func extractPersistedToolResults(message ModelMessage) []uiExtractedToolResult {
|
||||
parts := extractPersistedContentParts(message.Content)
|
||||
results := make([]uiExtractedToolResult, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if strings.ToLower(strings.TrimSpace(part.Type)) != "tool-result" {
|
||||
continue
|
||||
}
|
||||
output := part.Output
|
||||
if output == nil {
|
||||
output = part.Result
|
||||
}
|
||||
results = append(results, uiExtractedToolResult{
|
||||
ToolCallID: strings.TrimSpace(part.ToolCallID),
|
||||
Output: output,
|
||||
})
|
||||
}
|
||||
if len(results) > 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
if strings.TrimSpace(message.ToolCallID) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var output any
|
||||
if err := json.Unmarshal(message.Content, &output); err != nil {
|
||||
output = strings.TrimSpace(string(message.Content))
|
||||
}
|
||||
return []uiExtractedToolResult{{
|
||||
ToolCallID: strings.TrimSpace(message.ToolCallID),
|
||||
Output: output,
|
||||
}}
|
||||
}
|
||||
|
||||
func extractPersistedContentParts(raw json.RawMessage) []uiContentPart {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parts []uiContentPart
|
||||
if err := json.Unmarshal(raw, &parts); err == nil {
|
||||
return parts
|
||||
}
|
||||
|
||||
var encoded string
|
||||
if err := json.Unmarshal(raw, &encoded); err == nil {
|
||||
trimmed := strings.TrimSpace(encoded)
|
||||
if strings.HasPrefix(trimmed, "[") && json.Unmarshal([]byte(trimmed), &parts) == nil {
|
||||
return parts
|
||||
}
|
||||
}
|
||||
|
||||
var object struct {
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &object); err == nil && len(object.Content) > 0 {
|
||||
return extractPersistedContentParts(object.Content)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func uiAttachmentsFromMessageAssets(raw messagepkg.Message) []UIAttachment {
|
||||
if len(raw.Assets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
attachments := make([]UIAttachment, 0, len(raw.Assets))
|
||||
for _, asset := range raw.Assets {
|
||||
attachments = append(attachments, UIAttachment{
|
||||
ID: strings.TrimSpace(asset.ContentHash),
|
||||
Type: normalizeUIAttachmentType("", asset.Mime),
|
||||
Name: strings.TrimSpace(asset.Name),
|
||||
ContentHash: strings.TrimSpace(asset.ContentHash),
|
||||
BotID: strings.TrimSpace(raw.BotID),
|
||||
Mime: strings.TrimSpace(asset.Mime),
|
||||
Size: asset.SizeBytes,
|
||||
StorageKey: strings.TrimSpace(asset.StorageKey),
|
||||
Metadata: asset.Metadata,
|
||||
})
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
func resolveUIPersistencePlatform(raw messagepkg.Message) string {
|
||||
direct := strings.ToLower(strings.TrimSpace(raw.Platform))
|
||||
if direct == "local" {
|
||||
return ""
|
||||
}
|
||||
if direct != "" {
|
||||
return direct
|
||||
}
|
||||
|
||||
if raw.Metadata != nil {
|
||||
if platform, ok := raw.Metadata["platform"].(string); ok {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(platform))
|
||||
if trimmed == "local" {
|
||||
return ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stripPersistedYAMLHeader(text string) string {
|
||||
return strings.TrimSpace(uiMessageYAMLHeaderRe.ReplaceAllString(text, ""))
|
||||
}
|
||||
|
||||
func stripPersistedAgentTags(text string) string {
|
||||
stripped := uiMessageAgentTagsRe.ReplaceAllString(text, "")
|
||||
return strings.TrimSpace(uiMessageCollapsedNewlinesRe.ReplaceAllString(stripped, "\n\n"))
|
||||
}
|
||||
|
||||
func isHiddenCurrentConversationToolOutput(output any) bool {
|
||||
typed, ok := output.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delivered, _ := typed["delivered"].(string)
|
||||
return strings.EqualFold(strings.TrimSpace(delivered), "current_conversation")
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package conversation
|
||||
|
||||
import "strings"
|
||||
|
||||
type uiTextStreamState struct {
|
||||
ID int
|
||||
Content string
|
||||
}
|
||||
|
||||
type uiToolStreamState struct {
|
||||
Message UIMessage
|
||||
}
|
||||
|
||||
// UIMessageStreamConverter converts low-level stream events into complete UI messages.
|
||||
type UIMessageStreamConverter struct {
|
||||
nextID int
|
||||
text *uiTextStreamState
|
||||
reasoning *uiTextStreamState
|
||||
tools map[string]*uiToolStreamState
|
||||
}
|
||||
|
||||
// NewUIMessageStreamConverter creates a new UI stream converter.
|
||||
func NewUIMessageStreamConverter() *UIMessageStreamConverter {
|
||||
return &UIMessageStreamConverter{
|
||||
tools: map[string]*uiToolStreamState{},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleEvent updates converter state and returns zero or one complete UI messages.
|
||||
func (c *UIMessageStreamConverter) HandleEvent(event UIMessageStreamEvent) []UIMessage {
|
||||
switch strings.ToLower(strings.TrimSpace(event.Type)) {
|
||||
case "text_start":
|
||||
c.text = &uiTextStreamState{ID: c.nextMessageID()}
|
||||
return nil
|
||||
|
||||
case "text_delta":
|
||||
if c.text == nil {
|
||||
c.text = &uiTextStreamState{ID: c.nextMessageID()}
|
||||
}
|
||||
c.text.Content += event.Delta
|
||||
return []UIMessage{{
|
||||
ID: c.text.ID,
|
||||
Type: UIMessageText,
|
||||
Content: c.text.Content,
|
||||
}}
|
||||
|
||||
case "text_end":
|
||||
c.text = nil
|
||||
return nil
|
||||
|
||||
case "reasoning_start":
|
||||
c.reasoning = &uiTextStreamState{ID: c.nextMessageID()}
|
||||
return nil
|
||||
|
||||
case "reasoning_delta":
|
||||
if c.reasoning == nil {
|
||||
c.reasoning = &uiTextStreamState{ID: c.nextMessageID()}
|
||||
}
|
||||
c.reasoning.Content += event.Delta
|
||||
return []UIMessage{{
|
||||
ID: c.reasoning.ID,
|
||||
Type: UIMessageReasoning,
|
||||
Content: c.reasoning.Content,
|
||||
}}
|
||||
|
||||
case "reasoning_end":
|
||||
c.reasoning = nil
|
||||
return nil
|
||||
|
||||
case "tool_call_start":
|
||||
state := &uiToolStreamState{
|
||||
Message: UIMessage{
|
||||
ID: c.nextMessageID(),
|
||||
Type: UIMessageTool,
|
||||
Name: strings.TrimSpace(event.ToolName),
|
||||
Input: event.Input,
|
||||
ToolCallID: strings.TrimSpace(event.ToolCallID),
|
||||
Running: uiBoolPtr(true),
|
||||
},
|
||||
}
|
||||
if state.Message.ToolCallID != "" {
|
||||
c.tools[state.Message.ToolCallID] = state
|
||||
}
|
||||
c.text = nil
|
||||
return []UIMessage{state.Message}
|
||||
|
||||
case "tool_call_progress":
|
||||
state := c.findToolState(event.ToolCallID, event.ToolName)
|
||||
if state == nil {
|
||||
state = &uiToolStreamState{
|
||||
Message: UIMessage{
|
||||
ID: c.nextMessageID(),
|
||||
Type: UIMessageTool,
|
||||
Name: strings.TrimSpace(event.ToolName),
|
||||
Input: event.Input,
|
||||
ToolCallID: strings.TrimSpace(event.ToolCallID),
|
||||
Running: uiBoolPtr(true),
|
||||
},
|
||||
}
|
||||
if state.Message.ToolCallID != "" {
|
||||
c.tools[state.Message.ToolCallID] = state
|
||||
}
|
||||
}
|
||||
state.Message.Progress = append(state.Message.Progress, event.Progress)
|
||||
if event.Input != nil {
|
||||
state.Message.Input = event.Input
|
||||
}
|
||||
return []UIMessage{cloneToolStreamMessage(state.Message)}
|
||||
|
||||
case "tool_call_end":
|
||||
state := c.findToolState(event.ToolCallID, event.ToolName)
|
||||
if state == nil {
|
||||
state = &uiToolStreamState{
|
||||
Message: UIMessage{
|
||||
ID: c.nextMessageID(),
|
||||
Type: UIMessageTool,
|
||||
Name: strings.TrimSpace(event.ToolName),
|
||||
Input: event.Input,
|
||||
ToolCallID: strings.TrimSpace(event.ToolCallID),
|
||||
},
|
||||
}
|
||||
}
|
||||
if event.Input != nil {
|
||||
state.Message.Input = event.Input
|
||||
}
|
||||
state.Message.Output = event.Output
|
||||
state.Message.Running = uiBoolPtr(false)
|
||||
if state.Message.ToolCallID != "" {
|
||||
delete(c.tools, state.Message.ToolCallID)
|
||||
}
|
||||
return []UIMessage{cloneToolStreamMessage(state.Message)}
|
||||
|
||||
case "attachment_delta":
|
||||
if len(event.Attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []UIMessage{{
|
||||
ID: c.nextMessageID(),
|
||||
Type: UIMessageAttachments,
|
||||
Attachments: append([]UIAttachment(nil), event.Attachments...),
|
||||
}}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UIMessageStreamConverter) nextMessageID() int {
|
||||
id := c.nextID
|
||||
c.nextID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *UIMessageStreamConverter) findToolState(toolCallID, toolName string) *uiToolStreamState {
|
||||
if trimmed := strings.TrimSpace(toolCallID); trimmed != "" {
|
||||
if state, ok := c.tools[trimmed]; ok {
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
normalizedName := strings.TrimSpace(toolName)
|
||||
for _, state := range c.tools {
|
||||
if strings.TrimSpace(state.Message.Name) == normalizedName {
|
||||
return state
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneToolStreamMessage(message UIMessage) UIMessage {
|
||||
clone := message
|
||||
if len(message.Progress) > 0 {
|
||||
clone.Progress = append([]any(nil), message.Progress...)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
messagepkg "github.com/memohai/memoh/internal/message"
|
||||
)
|
||||
|
||||
func TestConvertMessagesToUITurnsGroupsAssistantToolAndFiltersCurrentConversationDelivery(t *testing.T) {
|
||||
baseTime := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC)
|
||||
messages := []messagepkg.Message{
|
||||
{
|
||||
ID: "user-1",
|
||||
BotID: "bot-1",
|
||||
SessionID: "session-1",
|
||||
Role: "user",
|
||||
DisplayContent: "hello",
|
||||
Content: mustUIMessageJSON(t, ModelMessage{
|
||||
Role: "user",
|
||||
Content: mustUIRawJSON(t, "hello"),
|
||||
}),
|
||||
CreatedAt: baseTime,
|
||||
},
|
||||
{
|
||||
ID: "assistant-1",
|
||||
BotID: "bot-1",
|
||||
SessionID: "session-1",
|
||||
Role: "assistant",
|
||||
Content: mustUIMessageJSON(t, ModelMessage{
|
||||
Role: "assistant",
|
||||
Content: mustUIRawJSON(t, []map[string]any{
|
||||
{"type": "reasoning", "text": "thinking"},
|
||||
{"type": "tool-call", "toolCallId": "call-1", "toolName": "read", "input": map[string]any{"path": "/tmp/a.txt"}},
|
||||
{"type": "tool-call", "toolCallId": "call-2", "toolName": "send", "input": map[string]any{"message": "hi"}},
|
||||
}),
|
||||
}),
|
||||
Assets: []messagepkg.MessageAsset{{
|
||||
ContentHash: "hash-1",
|
||||
Mime: "image/png",
|
||||
StorageKey: "media/hash-1",
|
||||
Name: "image.png",
|
||||
}},
|
||||
CreatedAt: baseTime.Add(1 * time.Minute),
|
||||
},
|
||||
{
|
||||
ID: "tool-1",
|
||||
BotID: "bot-1",
|
||||
SessionID: "session-1",
|
||||
Role: "tool",
|
||||
Content: mustUIMessageJSON(t, ModelMessage{
|
||||
Role: "tool",
|
||||
Content: mustUIRawJSON(t, []map[string]any{
|
||||
{"type": "tool-result", "toolCallId": "call-1", "toolName": "read", "result": map[string]any{"structuredContent": map[string]any{"stdout": "hello"}}},
|
||||
}),
|
||||
}),
|
||||
CreatedAt: baseTime.Add(2 * time.Minute),
|
||||
},
|
||||
{
|
||||
ID: "tool-2",
|
||||
BotID: "bot-1",
|
||||
SessionID: "session-1",
|
||||
Role: "tool",
|
||||
Content: mustUIMessageJSON(t, ModelMessage{
|
||||
Role: "tool",
|
||||
Content: mustUIRawJSON(t, []map[string]any{
|
||||
{"type": "tool-result", "toolCallId": "call-2", "toolName": "send", "result": map[string]any{"delivered": "current_conversation"}},
|
||||
}),
|
||||
}),
|
||||
CreatedAt: baseTime.Add(3 * time.Minute),
|
||||
},
|
||||
{
|
||||
ID: "assistant-2",
|
||||
BotID: "bot-1",
|
||||
SessionID: "session-1",
|
||||
Role: "assistant",
|
||||
Content: mustUIMessageJSON(t, ModelMessage{
|
||||
Role: "assistant",
|
||||
Content: mustUIRawJSON(t, []map[string]any{{"type": "text", "text": "done"}}),
|
||||
}),
|
||||
CreatedAt: baseTime.Add(4 * time.Minute),
|
||||
},
|
||||
}
|
||||
|
||||
turns := ConvertMessagesToUITurns(messages)
|
||||
if len(turns) != 2 {
|
||||
t.Fatalf("expected 2 turns, got %d", len(turns))
|
||||
}
|
||||
|
||||
userTurn := turns[0]
|
||||
if userTurn.Role != "user" || userTurn.Text != "hello" {
|
||||
t.Fatalf("unexpected user turn: %#v", userTurn)
|
||||
}
|
||||
|
||||
assistantTurn := turns[1]
|
||||
if assistantTurn.Role != "assistant" {
|
||||
t.Fatalf("expected assistant turn, got %#v", assistantTurn)
|
||||
}
|
||||
if len(assistantTurn.Messages) != 4 {
|
||||
t.Fatalf("expected 4 assistant messages, got %d", len(assistantTurn.Messages))
|
||||
}
|
||||
|
||||
if assistantTurn.Messages[0].Type != UIMessageReasoning || assistantTurn.Messages[0].Content != "thinking" {
|
||||
t.Fatalf("unexpected reasoning block: %#v", assistantTurn.Messages[0])
|
||||
}
|
||||
if assistantTurn.Messages[1].Type != UIMessageTool || assistantTurn.Messages[1].Name != "read" {
|
||||
t.Fatalf("unexpected tool block: %#v", assistantTurn.Messages[1])
|
||||
}
|
||||
if assistantTurn.Messages[1].Running == nil || *assistantTurn.Messages[1].Running {
|
||||
t.Fatalf("expected tool block to be completed: %#v", assistantTurn.Messages[1])
|
||||
}
|
||||
if assistantTurn.Messages[2].Type != UIMessageAttachments || len(assistantTurn.Messages[2].Attachments) != 1 {
|
||||
t.Fatalf("unexpected attachment block: %#v", assistantTurn.Messages[2])
|
||||
}
|
||||
if assistantTurn.Messages[2].Attachments[0].Type != "image" || assistantTurn.Messages[2].Attachments[0].BotID != "bot-1" {
|
||||
t.Fatalf("unexpected attachment payload: %#v", assistantTurn.Messages[2].Attachments[0])
|
||||
}
|
||||
if assistantTurn.Messages[3].Type != UIMessageText || assistantTurn.Messages[3].Content != "done" {
|
||||
t.Fatalf("unexpected trailing text block: %#v", assistantTurn.Messages[3])
|
||||
}
|
||||
|
||||
for _, block := range assistantTurn.Messages {
|
||||
if block.Type == UIMessageTool && block.Name == "send" {
|
||||
t.Fatalf("expected current conversation delivery tool to be filtered out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMessagesToUITurnsStripsUserYAMLHeaderFallback(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
turns := ConvertMessagesToUITurns([]messagepkg.Message{{
|
||||
ID: "user-1",
|
||||
BotID: "bot-1",
|
||||
SessionID: "session-1",
|
||||
Role: "user",
|
||||
Content: mustUIMessageJSON(t, ModelMessage{
|
||||
Role: "user",
|
||||
Content: mustUIRawJSON(t, "---\nmessage-id: 1\nchannel: telegram\n---\nhello"),
|
||||
}),
|
||||
CreatedAt: now,
|
||||
}})
|
||||
|
||||
if len(turns) != 1 {
|
||||
t.Fatalf("expected 1 turn, got %d", len(turns))
|
||||
}
|
||||
if turns[0].Text != "hello" {
|
||||
t.Fatalf("expected YAML header to be stripped, got %q", turns[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIMessageStreamConverterAccumulatesToolProgress(t *testing.T) {
|
||||
converter := NewUIMessageStreamConverter()
|
||||
|
||||
start := converter.HandleEvent(UIMessageStreamEvent{
|
||||
Type: "tool_call_start",
|
||||
ToolName: "exec",
|
||||
ToolCallID: "call-1",
|
||||
Input: map[string]any{"command": "ls"},
|
||||
})
|
||||
if len(start) != 1 || start[0].Type != UIMessageTool || start[0].Name != "exec" {
|
||||
t.Fatalf("unexpected tool start event: %#v", start)
|
||||
}
|
||||
if start[0].Running == nil || !*start[0].Running {
|
||||
t.Fatalf("expected running tool start, got %#v", start[0])
|
||||
}
|
||||
|
||||
progressOne := converter.HandleEvent(UIMessageStreamEvent{
|
||||
Type: "tool_call_progress",
|
||||
ToolName: "exec",
|
||||
ToolCallID: "call-1",
|
||||
Progress: "line 1",
|
||||
})
|
||||
progressTwo := converter.HandleEvent(UIMessageStreamEvent{
|
||||
Type: "tool_call_progress",
|
||||
ToolName: "exec",
|
||||
ToolCallID: "call-1",
|
||||
Progress: map[string]any{"line": 2},
|
||||
})
|
||||
if len(progressOne) != 1 || len(progressOne[0].Progress) != 1 {
|
||||
t.Fatalf("unexpected first progress snapshot: %#v", progressOne)
|
||||
}
|
||||
if len(progressTwo) != 1 || len(progressTwo[0].Progress) != 2 {
|
||||
t.Fatalf("unexpected second progress snapshot: %#v", progressTwo)
|
||||
}
|
||||
if progressTwo[0].ID != start[0].ID {
|
||||
t.Fatalf("expected progress snapshots to reuse tool message id")
|
||||
}
|
||||
|
||||
end := converter.HandleEvent(UIMessageStreamEvent{
|
||||
Type: "tool_call_end",
|
||||
ToolName: "exec",
|
||||
ToolCallID: "call-1",
|
||||
Output: map[string]any{"structuredContent": map[string]any{"stdout": "done"}},
|
||||
})
|
||||
if len(end) != 1 || end[0].Running == nil || *end[0].Running {
|
||||
t.Fatalf("expected completed tool snapshot, got %#v", end)
|
||||
}
|
||||
if end[0].ID != start[0].ID || len(end[0].Progress) != 2 {
|
||||
t.Fatalf("expected final snapshot to keep id and progress, got %#v", end[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIMessageStreamConverterStartsNewTextBlockAfterTool(t *testing.T) {
|
||||
converter := NewUIMessageStreamConverter()
|
||||
|
||||
first := converter.HandleEvent(UIMessageStreamEvent{Type: "text_delta", Delta: "hello"})
|
||||
converter.HandleEvent(UIMessageStreamEvent{Type: "tool_call_start", ToolName: "read", ToolCallID: "call-1"})
|
||||
converter.HandleEvent(UIMessageStreamEvent{Type: "tool_call_end", ToolName: "read", ToolCallID: "call-1"})
|
||||
second := converter.HandleEvent(UIMessageStreamEvent{Type: "text_delta", Delta: "world"})
|
||||
|
||||
if len(first) != 1 || len(second) != 1 {
|
||||
t.Fatalf("expected text snapshots, got first=%#v second=%#v", first, second)
|
||||
}
|
||||
if first[0].ID == second[0].ID {
|
||||
t.Fatalf("expected new text block after tool call, got same id %d", first[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRawModelMessagesToUIAssistantMessagesBuildsTerminalSnapshots(t *testing.T) {
|
||||
raw := mustUIRawJSON(t, []ModelMessage{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: mustUIRawJSON(t, []map[string]any{
|
||||
{"type": "reasoning", "text": "thinking"},
|
||||
{"type": "tool-call", "toolCallId": "call-1", "toolName": "read", "input": map[string]any{"path": "/tmp/a.txt"}},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
Content: mustUIRawJSON(t, []map[string]any{
|
||||
{"type": "tool-result", "toolCallId": "call-1", "toolName": "read", "result": map[string]any{"structuredContent": map[string]any{"stdout": "ok"}}},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: mustUIRawJSON(t, []map[string]any{
|
||||
{"type": "text", "text": "final answer"},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
messages := ConvertRawModelMessagesToUIAssistantMessages(raw)
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("expected 3 ui messages, got %d", len(messages))
|
||||
}
|
||||
if messages[0].ID != 0 || messages[0].Type != UIMessageReasoning {
|
||||
t.Fatalf("unexpected first ui message: %#v", messages[0])
|
||||
}
|
||||
if messages[1].ID != 1 || messages[1].Type != UIMessageTool {
|
||||
t.Fatalf("unexpected second ui message: %#v", messages[1])
|
||||
}
|
||||
if messages[1].Running == nil || *messages[1].Running {
|
||||
t.Fatalf("expected terminal tool message to be completed: %#v", messages[1])
|
||||
}
|
||||
if messages[2].ID != 2 || messages[2].Type != UIMessageText || messages[2].Content != "final answer" {
|
||||
t.Fatalf("unexpected final ui message: %#v", messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
func mustUIRawJSON(t *testing.T, value any) json.RawMessage {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal raw json: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func mustUIMessageJSON(t *testing.T, message ModelMessage) json.RawMessage {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/memohai/memoh/internal/accounts"
|
||||
agentpkg "github.com/memohai/memoh/internal/agent"
|
||||
attachmentpkg "github.com/memohai/memoh/internal/attachment"
|
||||
"github.com/memohai/memoh/internal/bots"
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
@@ -446,15 +447,50 @@ func (h *LocalChannelHandler) HandleWebSocket(c echo.Context) error {
|
||||
}()
|
||||
|
||||
go func() {
|
||||
converter := conversation.NewUIMessageStreamConverter()
|
||||
for event := range eventCh {
|
||||
processed := h.processWSEvent(streamCtx, botID, event)
|
||||
for _, p := range processed {
|
||||
writer.Send(p)
|
||||
if refs := extractAssetRefsFromProcessedEvent(p); len(refs) > 0 {
|
||||
outboundAssetMu.Lock()
|
||||
outboundAssetRefs = append(outboundAssetRefs, refs...)
|
||||
outboundAssetMu.Unlock()
|
||||
}
|
||||
|
||||
var streamEvent agentpkg.StreamEvent
|
||||
if err := json.Unmarshal(p, &streamEvent); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch streamEvent.Type {
|
||||
case agentpkg.EventAgentStart:
|
||||
writer.SendJSON(map[string]string{"type": "start"})
|
||||
continue
|
||||
case agentpkg.EventAgentEnd, agentpkg.EventAgentAbort:
|
||||
for _, uiMessage := range conversation.ConvertRawModelMessagesToUIAssistantMessages(streamEvent.Messages) {
|
||||
writer.SendJSON(map[string]any{
|
||||
"type": "message",
|
||||
"data": uiMessage,
|
||||
})
|
||||
}
|
||||
writer.SendJSON(map[string]string{"type": "end"})
|
||||
continue
|
||||
case agentpkg.EventError:
|
||||
message := strings.TrimSpace(streamEvent.Error)
|
||||
if message == "" {
|
||||
message = "stream error"
|
||||
}
|
||||
writer.SendJSON(map[string]string{"type": "error", "message": message})
|
||||
continue
|
||||
}
|
||||
|
||||
uiEvents := converter.HandleEvent(uiStreamEventFromAgentEvent(streamEvent))
|
||||
for _, uiMessage := range uiEvents {
|
||||
writer.SendJSON(map[string]any{
|
||||
"type": "message",
|
||||
"data": uiMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
outboundAssetMu.Lock()
|
||||
@@ -495,6 +531,67 @@ func (h *LocalChannelHandler) authorizeBotAccess(ctx context.Context, channelIde
|
||||
return AuthorizeBotAccess(ctx, h.botService, h.accountService, channelIdentityID, botID)
|
||||
}
|
||||
|
||||
func uiStreamEventFromAgentEvent(event agentpkg.StreamEvent) conversation.UIMessageStreamEvent {
|
||||
attachments := make([]conversation.UIAttachment, 0, len(event.Attachments))
|
||||
for _, attachment := range event.Attachments {
|
||||
attachments = append(attachments, uiAttachmentFromAgentAttachment(attachment))
|
||||
}
|
||||
|
||||
return conversation.UIMessageStreamEvent{
|
||||
Type: string(event.Type),
|
||||
Delta: event.Delta,
|
||||
ToolName: event.ToolName,
|
||||
ToolCallID: event.ToolCallID,
|
||||
Input: event.Input,
|
||||
Output: event.Result,
|
||||
Progress: event.Progress,
|
||||
Attachments: attachments,
|
||||
Error: event.Error,
|
||||
}
|
||||
}
|
||||
|
||||
func uiAttachmentFromAgentAttachment(attachment agentpkg.FileAttachment) conversation.UIAttachment {
|
||||
result := conversation.UIAttachment{
|
||||
ID: strings.TrimSpace(attachment.ContentHash),
|
||||
Type: normalizeWSUIAttachmentType(attachment.Type, attachment.Mime),
|
||||
Path: strings.TrimSpace(attachment.Path),
|
||||
URL: strings.TrimSpace(attachment.URL),
|
||||
Name: strings.TrimSpace(attachment.Name),
|
||||
ContentHash: strings.TrimSpace(attachment.ContentHash),
|
||||
Mime: strings.TrimSpace(attachment.Mime),
|
||||
Size: attachment.Size,
|
||||
Metadata: attachment.Metadata,
|
||||
}
|
||||
if attachment.Metadata != nil {
|
||||
if botID, ok := attachment.Metadata["bot_id"].(string); ok {
|
||||
result.BotID = strings.TrimSpace(botID)
|
||||
}
|
||||
if storageKey, ok := attachment.Metadata["storage_key"].(string); ok {
|
||||
result.StorageKey = strings.TrimSpace(storageKey)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeWSUIAttachmentType(kind, mime string) string {
|
||||
normalizedKind := strings.ToLower(strings.TrimSpace(kind))
|
||||
if normalizedKind != "" {
|
||||
return normalizedKind
|
||||
}
|
||||
|
||||
normalizedMime := strings.ToLower(strings.TrimSpace(mime))
|
||||
switch {
|
||||
case strings.HasPrefix(normalizedMime, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(normalizedMime, "audio/"):
|
||||
return "audio"
|
||||
case strings.HasPrefix(normalizedMime, "video/"):
|
||||
return "video"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket event processing — attachment ingestion + TTS extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -145,6 +145,7 @@ func (h *MessageHandler) ListMessages(c echo.Context) error {
|
||||
}
|
||||
|
||||
before, hasBefore := parseBeforeParam(c.QueryParam("before"))
|
||||
format := strings.ToLower(strings.TrimSpace(c.QueryParam("format")))
|
||||
|
||||
sessionID := strings.TrimSpace(c.QueryParam("session_id"))
|
||||
|
||||
@@ -172,6 +173,11 @@ func (h *MessageHandler) ListMessages(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.fillAssetMimeFromStorage(c.Request().Context(), botID, messages)
|
||||
if format == "ui" {
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"items": conversation.ConvertMessagesToUITurns(messages),
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"items": messages})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user