diff --git a/web-app/src/containers/MessageItem.tsx b/web-app/src/containers/MessageItem.tsx index 0971121abd..08344dab5b 100644 --- a/web-app/src/containers/MessageItem.tsx +++ b/web-app/src/containers/MessageItem.tsx @@ -2,7 +2,7 @@ import { memo, useState, useCallback, useEffect } from 'react' import type { UIMessage, ChatStatus } from 'ai' import { RenderMarkdown } from './RenderMarkdown' -import { cn } from '@/lib/utils' +import { cn, getModelDisplayName, getProviderTitle, getProviderLogo } from '@/lib/utils' import { twMerge } from 'tailwind-merge' import { ChainOfThought, @@ -87,9 +87,40 @@ export const MessageItem = memo( onDelete, }: MessageItemProps) => { const selectedModel = useModelProvider((state) => state.selectedModel) + const selectedProvider = useModelProvider((state) => state.selectedProvider) + const providers = useModelProvider((state) => state.providers) const metadata = message.metadata as Record | undefined const messageError = useMessageErrors((s) => s.errors[message.id]) const createdAt = (metadata?.createdAt as Date) ?? new Date() + + // Derive model display name from per-message metadata + const messageModelId = metadata?.modelId as string | undefined + const messageModelProvider = metadata?.modelProvider as string | undefined + + const modelDisplayName = useMemo(() => { + if (messageModelId) { + const provider = providers.find( + (p) => p.provider === messageModelProvider + ) + const model = provider?.models.find((m) => m.id === messageModelId) + if (model) return getModelDisplayName(model) + return messageModelId + } + // Backwards compat: for the last message without metadata, use current selection + if (isLastMessage && selectedModel) { + return getModelDisplayName(selectedModel) + } + return null + }, [messageModelId, messageModelProvider, isLastMessage, selectedModel, providers]) + + // Provider for the logo/badge: per-message metadata, falling back to the + // current selection for the streaming last message before metadata persists. + const modelProviderForDisplay = + messageModelProvider ?? (isLastMessage ? selectedProvider : undefined) + const modelProviderLogo = modelProviderForDisplay + ? getProviderLogo(modelProviderForDisplay) + : undefined + const [previewImage, setPreviewImage] = useState<{ url: string filename?: string @@ -549,6 +580,26 @@ export const MessageItem = memo( )} > + {/* Model name label for assistant messages */} + {message.role === 'assistant' && modelDisplayName && ( +
+ {modelProviderLogo ? ( + {`${getProviderTitle(modelProviderForDisplay!)} + ) : modelProviderForDisplay ? ( +
+ + {getProviderTitle(modelProviderForDisplay).charAt(0)} + +
+ ) : null} + {modelDisplayName} +
+ )} + {/* Render message parts */} {renderedParts} diff --git a/web-app/src/containers/__tests__/MessageItem.test.tsx b/web-app/src/containers/__tests__/MessageItem.test.tsx index 8d36d81898..e6fbf4e4e9 100644 --- a/web-app/src/containers/__tests__/MessageItem.test.tsx +++ b/web-app/src/containers/__tests__/MessageItem.test.tsx @@ -5,9 +5,15 @@ import '@testing-library/jest-dom' // ---- Module mocks ---------------------------------------------------------- const selectedModelRef = vi.hoisted(() => ({ current: { id: 'm1' } as any })) +const selectedProviderRef = vi.hoisted(() => ({ current: 'llamacpp' })) +const providersRef = vi.hoisted(() => ({ current: [] as any[] })) vi.mock('@/hooks/useModelProvider', () => ({ useModelProvider: (selector: any) => - selector({ selectedModel: selectedModelRef.current }), + selector({ + selectedModel: selectedModelRef.current, + selectedProvider: selectedProviderRef.current, + providers: providersRef.current, + }), })) // Stub heavy children: RenderMarkdown @@ -115,6 +121,8 @@ describe('MessageItem', () => { beforeEach(() => { vi.clearAllMocks() selectedModelRef.current = { id: 'm1' } + selectedProviderRef.current = 'llamacpp' + providersRef.current = [] }) it('renders assistant text via RenderMarkdown', () => { @@ -129,6 +137,82 @@ describe('MessageItem', () => { expect(screen.getByTestId('render-markdown')).toHaveTextContent('Hello assistant') }) + it('renders the model name label and provider logo from message metadata', () => { + providersRef.current = [ + { + provider: 'anthropic', + models: [{ id: 'claude-x', displayName: 'Claude X' }], + }, + ] + render( + + ) + expect(screen.getByText('Claude X')).toBeInTheDocument() + expect(screen.getByAltText('Anthropic logo')).toBeInTheDocument() + }) + + it('falls back to the raw model id when metadata model is unknown', () => { + render( + + ) + expect(screen.getByText('mystery-model')).toBeInTheDocument() + expect(screen.getByAltText('OpenAI logo')).toBeInTheDocument() + }) + + it('falls back to current selection for the last message without metadata', () => { + selectedModelRef.current = { id: 'm1', displayName: 'Local Model' } + selectedProviderRef.current = 'llamacpp' + render( + + ) + expect(screen.getByText('Local Model')).toBeInTheDocument() + expect(screen.getByAltText('Llama.cpp logo')).toBeInTheDocument() + }) + + it('does not render a model label for a non-last message without metadata', () => { + selectedModelRef.current = { id: 'm1', displayName: 'Local Model' } + render( + + ) + expect(screen.queryByText('Local Model')).not.toBeInTheDocument() + }) + it('renders user message in a bubble (no markdown renderer)', () => { render( { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(input)) + controller.close() + }, + }) + + const filtered = stream.pipeThrough(createSSEEventFilter()) + const reader = filtered.getReader() + const decoder = new TextDecoder() + let result = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + result += decoder.decode(value, { stream: true }) + } + return result +} + +describe('createSSEEventFilter', () => { + it('passes through standard data blocks (no event: field)', async () => { + const input = + 'data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"}}]}\n\n' + + 'data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" world"}}]}\n\n' + const output = await filterSSE(input) + expect(output).toBe(input) + }) + + it('passes through data blocks with event: message', async () => { + const input = + 'event: message\ndata: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hi"}}]}\n\n' + const output = await filterSSE(input) + expect(output).toBe(input) + }) + + it('drops data blocks with custom event types', async () => { + const custom = + 'event: hermes.tool.progress\ndata: {"tool":"terminal","emoji":"💻","status":"running"}\n\n' + const standard = + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"}}]}\n\n' + const output = await filterSSE(custom + standard) + expect(output).toBe(standard) + }) + + it('drops multiple custom event types while keeping standard ones', async () => { + const custom1 = + 'event: hermes.tool.progress\ndata: {"tool":"terminal"}\n\n' + const standard1 = + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"A"}}]}\n\n' + const custom2 = + 'event: heartbeat\ndata: {"ping":true}\n\n' + const standard2 = + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"B"}}]}\n\n' + + const output = await filterSSE(custom1 + standard1 + custom2 + standard2) + expect(output).toBe(standard1 + standard2) + }) + + it('keeps data: [DONE] blocks', async () => { + const input = + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{}},"finish_reason":"stop"]}\n\n' + + 'data: [DONE]\n\n' + const output = await filterSSE(input) + expect(output).toBe(input) + }) + + it('handles chunks split across multiple stream pieces', async () => { + const encoder = new TextDecoder() + const part1 = + 'event: hermes.tool.progress\ndata: {"tool":"term' + const part2 = + 'inal","status":"running"}\n\ndata: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"OK"}}]}\n\n' + + const rs = new ReadableStream({ + start(controller) { + const enc = new TextEncoder() + controller.enqueue(enc.encode(part1)) + controller.enqueue(enc.encode(part2)) + controller.close() + }, + }) + + const reader = rs.pipeThrough(createSSEEventFilter()).getReader() + let result = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + result += encoder.decode(value, { stream: true }) + } + + expect(result).toBe( + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"OK"}}]}\n\n' + ) + }) + + it('handles CRLF line endings', async () => { + const custom = + 'event: hermes.tool.progress\r\ndata: {"tool":"terminal"}\r\n\r\n' + const standard = + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hi"}}]}\r\n\r\n' + const output = await filterSSE(custom + standard) + // After CRLF normalization, the filter should have dropped the custom block + // and kept the standard block (with normalized newlines) + expect(output).toContain('"content":"Hi"') + expect(output).not.toContain('hermes.tool.progress') + }) + + it('handles empty event type (passes through)', async () => { + // An `event:` with an empty value should still pass through + const input = 'event: \ndata: {"id":"chatcmpl-1","choices":[]}\n\n' + const output = await filterSSE(input) + expect(output).toBe(input) + }) + + it('handles event type with extra whitespace', async () => { + const custom = + 'event: hermes.tool.progress \ndata: {"tool":"terminal"}\n\n' + const standard = + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"OK"}}]}\n\n' + const output = await filterSSE(custom + standard) + expect(output).toBe(standard) + }) +}) + +describe('createCustomFetch — SSE event filtering integration', () => { + function makeSSEFetch( + sseBody: string, + headers?: Record + ): typeof globalThis.fetch { + return (async () => + new Response(sseBody, { + status: 200, + headers: { + 'content-type': headers?.['content-type'] ?? 'text/event-stream', + ...headers, + }, + })) as typeof globalThis.fetch + } + + it('filters custom events from SSE responses', async () => { + const custom = + 'event: hermes.tool.progress\ndata: {"tool":"terminal","status":"running"}\n\n' + const standard = + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"}}]}\n\n' + const baseFetch = makeSSEFetch(custom + standard) + const wrapped = createCustomFetch(baseFetch, {}, false) + + const res = await wrapped('http://test/v1/chat/completions', { + method: 'POST', + body: '{}', + }) + + expect(res.ok).toBe(true) + const body = await res.text() + expect(body).not.toContain('hermes.tool.progress') + expect(body).toContain('"content":"Hello"') + }) + + it('does NOT filter non-SSE responses (e.g. JSON)', async () => { + const jsonBody = JSON.stringify({ + id: 'chatcmpl-1', + choices: [{ message: { content: 'Hi' } }], + }) + const baseFetch = makeSSEFetch(jsonBody, { + 'content-type': 'application/json', + }) + const wrapped = createCustomFetch(baseFetch, {}, false) + + const res = await wrapped('http://test/v1/chat/completions', { + method: 'POST', + body: '{}', + }) + + const body = await res.text() + expect(body).toBe(jsonBody) + }) + + it('passes standard SSE streams through unchanged', async () => { + const standard = + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"role":"assistant"}}]}\n\n' + + 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"}}]}\n\n' + + 'data: [DONE]\n\n' + const baseFetch = makeSSEFetch(standard) + const wrapped = createCustomFetch(baseFetch, {}, false) + + const res = await wrapped('http://test/v1/chat/completions', { + method: 'POST', + body: '{}', + }) + + const body = await res.text() + expect(body).toBe(standard) + }) +}) diff --git a/web-app/src/lib/__tests__/thread-title-summarizer.test.ts b/web-app/src/lib/__tests__/thread-title-summarizer.test.ts index df4c9440d9..34f3493236 100644 --- a/web-app/src/lib/__tests__/thread-title-summarizer.test.ts +++ b/web-app/src/lib/__tests__/thread-title-summarizer.test.ts @@ -74,6 +74,32 @@ describe('cleanTitle', () => { expect(cleanTitle('Version 2.0 Release')).toBe('Version 20 Release') }) + it('preserves emoji-prefixed titles', () => { + expect(cleanTitle('🐍 Python List Sorting')).toBe('🐍 Python List Sorting') + expect(cleanTitle('🚀 Deployment Guide')).toBe('🚀 Deployment Guide') + expect(cleanTitle('💡 Machine Learning Basics')).toBe( + '💡 Machine Learning Basics' + ) + }) + + it('preserves emojis within titles', () => { + expect(cleanTitle('Learn Rust 🦀 for beginners')).toBe( + 'Learn Rust 🦀 for beginners' + ) + }) + + it('preserves ZWJ emoji sequences', () => { + // 👨‍💻 is a ZWJ sequence (man + ZWJ + computer) + expect(cleanTitle('👨‍💻 Coding Session Recap')).toBe( + '👨‍💻 Coding Session Recap' + ) + }) + + it('preserves standalone emoji as valid title', () => { + // 🎉 has length 2 (surrogate pair) so it passes the length >= 2 check + expect(cleanTitle('🎉')).toBe('🎉') + }) + it('returns null for empty or very short text', () => { expect(cleanTitle('')).toBeNull() expect(cleanTitle(' ')).toBeNull() diff --git a/web-app/src/lib/model-factory.ts b/web-app/src/lib/model-factory.ts index 22081ad5b3..b288ace028 100644 --- a/web-app/src/lib/model-factory.ts +++ b/web-app/src/lib/model-factory.ts @@ -330,6 +330,63 @@ function requestUrlOf(input: RequestInfo | URL): string { return (input as Request).url ?? '' } +/** + * TransformStream that filters SSE event blocks by type. + * + * Per the W3C SSE spec, blocks with a custom `event:` type that the client + * has no handler for should be silently ignored. Some OpenAI-compatible + * servers emit non-standard event types (e.g. `event: hermes.tool.progress`, + * heartbeat events, metadata events) alongside the standard completion + * stream. When the AI SDK's `EventSourceParserStream` passes these through, + * their `data:` payloads fail Zod schema validation because they lack a + * `choices[]` array, producing a fatal "Type validation failed" error. + * + * This filter drops SSE blocks whose `event:` field is neither empty nor + * "message" (the SSE default), so only standard completion chunks reach the + * AI SDK's parser. + */ +export function createSSEEventFilter(): TransformStream { + const decoder = new TextDecoder() + const encoder = new TextEncoder() + let remainder = '' + + /** + * Returns `true` when the block carries a custom event type that should be + * silently skipped. The check walks each line of the SSE block looking for + * the first `event:` field — if its value is non-empty and not "message", + * the block is non-standard. + */ + const isCustomEventBlock = (block: string): boolean => + block.split('\n').some((line) => { + if (!line.startsWith('event:')) return false + const eventType = line.slice(6).trim() + return eventType !== '' && eventType !== 'message' + }) + + return new TransformStream({ + transform(chunk, controller) { + remainder += decoder.decode(chunk, { stream: true }) + // Normalize \r\n → \n so split works regardless of server line endings + remainder = remainder.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const blocks = remainder.split('\n\n') + // Last element may be an incomplete block — keep it for the next chunk + remainder = blocks.pop() ?? '' + + for (const block of blocks) { + if (!block.trim()) continue + if (!isCustomEventBlock(block)) { + controller.enqueue(encoder.encode(block + '\n\n')) + } + } + }, + flush(controller) { + if (remainder.trim() && !isCustomEventBlock(remainder)) { + controller.enqueue(encoder.encode(remainder)) + } + }, + }) +} + /** * Create a custom fetch function that injects additional parameters into the * request body, normalising key names for OpenAI-compatible APIs: @@ -397,7 +454,22 @@ export function createCustomFetch( if (!friendly) throw err throw new Error(`${friendly} (${requestUrlOf(input)})`) } - if (res.ok) return res + if (res.ok) { + // Filter out custom SSE event types that would cause AI SDK validation + // errors. Standard SSE behaviour is to skip unknown event types. + const contentType = res.headers.get('content-type') || '' + if (contentType.includes('text/event-stream') && res.body) { + return new Response( + res.body.pipeThrough(createSSEEventFilter()), + { + status: res.status, + statusText: res.statusText, + headers: res.headers, + } + ) + } + return res + } const isLlamacpp500 = keepLlamacppOnly && res.status === 500 diff --git a/web-app/src/lib/thread-title-summarizer.ts b/web-app/src/lib/thread-title-summarizer.ts index fc83fc89f4..7d03a97b3a 100644 --- a/web-app/src/lib/thread-title-summarizer.ts +++ b/web-app/src/lib/thread-title-summarizer.ts @@ -10,7 +10,7 @@ function buildSummarizePrompt(transcript: string): string { transcript.length > MAX_PROMPT_LENGTH ? transcript.slice(0, MAX_PROMPT_LENGTH) + '...' : transcript - return `Summarize the following conversation into a concise title of at most ${MAX_TITLE_WORDS} words. Capture the overall topic, not just the latest turn. Output the title only, no quotes, no explanation.\n\nConversation:\n${truncated}` + return `Summarize the following conversation into a concise title of at most ${MAX_TITLE_WORDS} words. Capture the overall topic, not just the latest turn. Start the title with a single emoji that best represents the conversation topic. Output the title only, no quotes, no explanation.\n\nConversation:\n${truncated}` } /** @@ -41,8 +41,8 @@ export function cleanTitle(raw: string): string | null { // Remove surrounding quotes text = text.replace(/^["']+|["']+$/g, '').trim() - // Keep only letters, numbers, and spaces (unicode-aware) - text = text.replace(/[^\p{L}\p{N}\s]/gu, '').trim() + // Keep letters, numbers, spaces, and emojis (including emoji sequences/ZWJ) + text = text.replace(/[^\p{L}\p{N}\p{Extended_Pictographic}\uFE0F\u200D\s]/gu, '').trim() // Enforce word limit const words = text.split(/\s+/).slice(0, MAX_TITLE_WORDS) diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index 9e773dd833..6bf09921a2 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -270,6 +270,13 @@ function ThreadDetail() { unknown > + // Capture which model produced this message so we can display it in the UI + const currentModelState = useModelProvider.getState() + if (!messageMetadata.modelId && currentModelState.selectedModel) { + messageMetadata.modelId = currentModelState.selectedModel.id + messageMetadata.modelProvider = currentModelState.selectedProvider + } + const assistantMessage: ThreadMessage = { type: 'text', role: ChatCompletionRole.Assistant,