diff --git a/.github/workflows/manual-e2e.yml b/.github/workflows/manual-e2e.yml index 8294f152..149612b6 100644 --- a/.github/workflows/manual-e2e.yml +++ b/.github/workflows/manual-e2e.yml @@ -46,7 +46,7 @@ jobs: - name: Setup Node.js for SDK uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache-dependency-path: agents-sdk/yarn.lock - name: Install Yarn @@ -80,7 +80,7 @@ jobs: - name: Setup Node.js for agents-ui uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Render .npmrc for agents-ui working-directory: agents-ui diff --git a/.github/workflows/pr-main-e2e.yml b/.github/workflows/pr-main-e2e.yml index e3dafec2..2293b64e 100644 --- a/.github/workflows/pr-main-e2e.yml +++ b/.github/workflows/pr-main-e2e.yml @@ -42,7 +42,7 @@ jobs: - name: Setup Node.js for SDK uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache-dependency-path: agents-sdk/yarn.lock - name: Install Yarn @@ -76,7 +76,7 @@ jobs: - name: Setup Node.js for agents-ui uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Render .npmrc for agents-ui working-directory: agents-ui diff --git a/.github/workflows/pr-prod-e2e.yml b/.github/workflows/pr-prod-e2e.yml index fd9e82df..996abfd2 100644 --- a/.github/workflows/pr-prod-e2e.yml +++ b/.github/workflows/pr-prod-e2e.yml @@ -48,7 +48,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Render .npmrc for agents-ui working-directory: agents-ui diff --git a/.github/workflows/publish-on-merge.yml b/.github/workflows/publish-on-merge.yml index 79bf3c60..dbf9b186 100644 --- a/.github/workflows/publish-on-merge.yml +++ b/.github/workflows/publish-on-merge.yml @@ -43,7 +43,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 registry-url: "https://registry.npmjs.org" cache: "yarn" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f22ccb0..05e0255a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'yarn' cache-dependency-path: yarn.lock @@ -46,7 +46,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'yarn' cache-dependency-path: yarn.lock @@ -77,7 +77,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'yarn' cache-dependency-path: yarn.lock @@ -100,7 +100,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'yarn' cache-dependency-path: yarn.lock diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..53d1c14d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v22 diff --git a/package.json b/package.json index 7e3f4db0..d14dd80b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@d-id/client-sdk", "private": false, - "version": "1.1.57", + "version": "1.1.58", "type": "module", "description": "d-id client sdk", "repository": { diff --git a/src/index.ts b/src/index.ts index c67b20ce..854fb3cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './errors'; export * from './services/agent-manager'; export * from './types'; +export { parseMessageParts } from './utils/content-parser'; diff --git a/src/services/agent-manager/index.test.ts b/src/services/agent-manager/index.test.ts index e5b914c0..b2c51c51 100644 --- a/src/services/agent-manager/index.test.ts +++ b/src/services/agent-manager/index.test.ts @@ -140,7 +140,7 @@ describe('createAgentManager', () => { it('should handle initial messages correctly', async () => { const initialMessages = [ - { id: '1', role: 'user' as const, content: 'Hello', created_at: new Date().toISOString() }, + { id: '1', role: 'user' as const, content: 'Hello', parts: [], created_at: new Date().toISOString() }, ]; (getInitialMessages as jest.Mock).mockReturnValue(initialMessages); @@ -297,6 +297,30 @@ describe('createAgentManager', () => { }); }); + it('should populate parts on user message', async () => { + const mockCallback = mockOptions.callbacks.onNewMessage as jest.Mock; + mockCallback.mockClear(); + + await manager.chat('Hello, how are you?'); + + // First call is the user message + const [userMessages] = mockCallback.mock.calls[0]; + const userMsg = userMessages[userMessages.length - 1]; + expect(userMsg.parts).toEqual([{ type: 'text', text: 'Hello, how are you?' }]); + }); + + it('should populate parts on assistant response message', async () => { + const mockCallback = mockOptions.callbacks.onNewMessage as jest.Mock; + mockCallback.mockClear(); + + await manager.chat('Hello, how are you?'); + + // Second call is the answer + const [answerMessages] = mockCallback.mock.calls[1]; + const assistantMsg = answerMessages[answerMessages.length - 1]; + expect(assistantMsg.parts).toEqual([{ type: 'text', text: 'Agent response' }]); + }); + it('should validate chat request - empty message', async () => { await expect(manager.chat('')).rejects.toThrow('Message cannot be empty'); }); @@ -447,6 +471,17 @@ describe('createAgentManager', () => { expect(lastMessage.created_at).toBeDefined(); }); + it('should populate parts on speak message', async () => { + const mockCallback = mockOptions.callbacks.onNewMessage as jest.Mock; + mockCallback.mockClear(); + + await manager.speak('Hello from speak'); + + const [messages] = mockCallback.mock.calls[0]; + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.parts).toEqual([{ type: 'text', text: 'Hello from speak' }]); + }); + it('should trigger onNewMessage with script object', async () => { const script = { type: 'text' as const, input: 'Hello from script', ssml: false }; const mockCallback = mockOptions.callbacks.onNewMessage as jest.Mock; diff --git a/src/services/agent-manager/index.ts b/src/services/agent-manager/index.ts index 0b719f37..a4175a80 100644 --- a/src/services/agent-manager/index.ts +++ b/src/services/agent-manager/index.ts @@ -21,6 +21,7 @@ import { ChatCreationFailed, ValidationError } from '@sdk/errors'; import { getRandom } from '@sdk/utils'; import { isStreamsV2Agent } from '@sdk/utils/agent'; import { isChatModeWithoutChat, isTextualChat } from '@sdk/utils/chat'; +import { parseMessagePartsMemo } from '@sdk/utils/content-parser'; import { createAgentsApi } from '../../api/agents'; import { getAgentInfo, getAnalyticsInfo } from '../../utils/analytics'; import { defer } from '../../utils/defer'; @@ -439,6 +440,7 @@ export async function createAgentManager(agent: string, options: AgentManagerOpt id: getRandom(), role: 'user', content: userMessage, + parts: parseMessagePartsMemo(userMessage), created_at: new Date(latencyTimestampTracker.update()).toISOString(), }); @@ -451,6 +453,7 @@ export async function createAgentManager(agent: string, options: AgentManagerOpt id: getRandom(), role: 'assistant', content: response.result || '', + parts: parseMessagePartsMemo(response.result || ''), created_at: new Date().toISOString(), context: response.context, matches: response.matches, @@ -568,6 +571,7 @@ export async function createAgentManager(agent: string, options: AgentManagerOpt id: getRandom(), role: 'assistant', content: script.input, + parts: parseMessagePartsMemo(script.input), created_at: new Date().toISOString(), }); options.callbacks.onNewMessage?.([...items.messages], 'answer'); diff --git a/src/services/socket-manager/message-queue.test.ts b/src/services/socket-manager/message-queue.test.ts index 52e4277b..f2d2d924 100644 --- a/src/services/socket-manager/message-queue.test.ts +++ b/src/services/socket-manager/message-queue.test.ts @@ -51,6 +51,7 @@ describe('createMessageEventQueue', () => { id: 'user-1', role: 'user', content: 'first question', + parts: [], created_at: new Date().toISOString(), transcribed: true, }); @@ -86,6 +87,7 @@ describe('createMessageEventQueue', () => { id: 'user-1', role: 'user', content: 'test', + parts: [], created_at: new Date().toISOString(), transcribed: true, }); @@ -111,6 +113,7 @@ describe('createMessageEventQueue', () => { id: 'user-1', role: 'user', content: 'test', + parts: [], created_at: new Date().toISOString(), transcribed: true, }); @@ -137,6 +140,7 @@ describe('createMessageEventQueue', () => { id: 'user-1', role: 'user', content: 'test', + parts: [], created_at: new Date().toISOString(), transcribed: true, }); @@ -164,6 +168,7 @@ describe('createMessageEventQueue', () => { id: 'user-1', role: 'user', content: 'first message', + parts: [], created_at: new Date().toISOString(), transcribed: true, }); @@ -190,6 +195,48 @@ describe('createMessageEventQueue', () => { }); }); + describe('first assistant turn (greeting)', () => { + it('creates an assistant message when partials arrive with no prior messages', () => { + const { onMessage } = createMessageEventQueue( + mockAnalytics, + mockItems, + mockOptions, + mockAgent, + mockOnStreamDone + ); + + onMessage(ChatProgress.Partial, { content: 'Hello', sequence: 0 }); + onMessage(ChatProgress.Partial, { content: ' there', sequence: 1 }); + onMessage(ChatProgress.Answer, { content: 'Hello there!' }); + + expect(mockItems.messages).toHaveLength(1); + expect(mockItems.messages[0]).toMatchObject({ + role: 'assistant', + content: 'Hello there!', + }); + expect(mockOnNewMessage).toHaveBeenCalled(); + }); + + it('streams partials live for a greeting before the final answer', () => { + const { onMessage } = createMessageEventQueue( + mockAnalytics, + mockItems, + mockOptions, + mockAgent, + mockOnStreamDone + ); + + onMessage(ChatProgress.Partial, { content: 'Hel', sequence: 0 }); + onMessage(ChatProgress.Partial, { content: 'lo', sequence: 1 }); + + expect(mockItems.messages).toHaveLength(1); + expect(mockItems.messages[0]).toMatchObject({ role: 'assistant', content: 'Hello' }); + expect(mockOnNewMessage).toHaveBeenCalled(); + const lastCall = mockOnNewMessage.mock.calls[mockOnNewMessage.mock.calls.length - 1]; + expect(lastCall[1]).toBe(ChatProgress.Partial); + }); + }); + describe('clearQueue function', () => { it('should expose clearQueue for external use', () => { const { clearQueue } = createMessageEventQueue( @@ -216,6 +263,7 @@ describe('createMessageEventQueue', () => { id: 'user-1', role: 'user', content: 'test', + parts: [], created_at: new Date().toISOString(), transcribed: true, }); @@ -232,4 +280,109 @@ describe('createMessageEventQueue', () => { expect(lastMessage.content).toBe('Fresh'); }); }); + + describe('message parts population', () => { + it('should populate parts on partial messages', () => { + const { onMessage } = createMessageEventQueue( + mockAnalytics, + mockItems, + mockOptions, + mockAgent, + mockOnStreamDone + ); + + // Start with an existing assistant message so partials update it + mockItems.messages.push({ + id: 'assistant-1', + role: 'assistant', + content: '', + parts: [], + created_at: new Date().toISOString(), + }); + + onMessage(ChatProgress.Partial, { content: 'Hello ![img](https://example.com/pic.png)', sequence: 0 }); + + const lastCall = mockOnNewMessage.mock.calls[mockOnNewMessage.mock.calls.length - 1]; + const lastMessage = lastCall[0][lastCall[0].length - 1]; + expect(lastMessage.parts).toEqual([ + { type: 'text', text: 'Hello ' }, + { type: 'image', src: 'https://example.com/pic.png', alt: 'img' }, + ]); + }); + + it('should populate parts on answer messages', () => { + const { onMessage } = createMessageEventQueue( + mockAnalytics, + mockItems, + mockOptions, + mockAgent, + mockOnStreamDone + ); + + mockItems.messages.push({ + id: 'user-1', + role: 'user', + content: 'test', + parts: [], + created_at: new Date().toISOString(), + transcribed: true, + }); + + onMessage(ChatProgress.Answer, { content: 'Check [this](https://example.com)' }); + + const lastCall = mockOnNewMessage.mock.calls[mockOnNewMessage.mock.calls.length - 1]; + const lastMessage = lastCall[0][lastCall[0].length - 1]; + expect(lastMessage.parts).toEqual([ + { type: 'text', text: 'Check ' }, + { type: 'link', href: 'https://example.com', label: 'this' }, + ]); + }); + + it('should populate parts for plain text content', () => { + const { onMessage } = createMessageEventQueue( + mockAnalytics, + mockItems, + mockOptions, + mockAgent, + mockOnStreamDone + ); + + mockItems.messages.push({ + id: 'user-1', + role: 'user', + content: 'test', + parts: [], + created_at: new Date().toISOString(), + transcribed: true, + }); + + onMessage(ChatProgress.Answer, { content: 'Just plain text' }); + + const lastCall = mockOnNewMessage.mock.calls[mockOnNewMessage.mock.calls.length - 1]; + const lastMessage = lastCall[0][lastCall[0].length - 1]; + expect(lastMessage.parts).toEqual([{ type: 'text', text: 'Just plain text' }]); + }); + + it('should populate parts on transcribed user messages', () => { + const { onMessage } = createMessageEventQueue( + mockAnalytics, + mockItems, + mockOptions, + mockAgent, + mockOnStreamDone + ); + + onMessage(ChatProgress.Transcribe, { + content: 'Hello there', + role: 'user', + id: 'user-transcribed-1', + }); + + const lastCall = mockOnNewMessage.mock.calls[mockOnNewMessage.mock.calls.length - 1]; + const lastMessage = lastCall[0][lastCall[0].length - 1]; + expect(lastMessage.role).toBe('user'); + expect(lastMessage.transcribed).toBe(true); + expect(lastMessage.parts).toEqual([{ type: 'text', text: 'Hello there' }]); + }); + }); }); diff --git a/src/services/socket-manager/message-queue.ts b/src/services/socket-manager/message-queue.ts index 23448d7f..930d2dd5 100644 --- a/src/services/socket-manager/message-queue.ts +++ b/src/services/socket-manager/message-queue.ts @@ -1,6 +1,7 @@ import { Agent, AgentManagerOptions, ChatProgress, StreamEvents } from '@sdk/types'; import { Message } from '@sdk/types/entities/agents/chat'; import { getStreamAnalyticsProps } from '@sdk/utils/analytics'; +import { parseMessagePartsMemo } from '@sdk/utils/content-parser'; import { AgentManagerItems } from '../agent-manager'; import { Analytics } from '../analytics/mixpanel'; @@ -43,6 +44,7 @@ function handleAudioTranscribedMessage( id: data.id || `user-${Date.now()}`, role: data.role, content: data.content, + parts: parseMessagePartsMemo(data.content), created_at: data.created_at || new Date().toISOString(), transcribed: true, }; @@ -69,17 +71,17 @@ function processChatEvent( const lastMessage = items.messages[items.messages.length - 1]; let currentMessage: Message; - if (lastMessage?.transcribed && lastMessage.role === 'user') { - const initialContent = event === ChatProgress.Answer ? data.content || '' : ''; + if (lastMessage?.role === 'assistant') { + currentMessage = lastMessage; + } else if (!lastMessage || (lastMessage.transcribed && lastMessage.role === 'user')) { currentMessage = { id: data.id || `assistant-${Date.now()}`, role: data.role || 'assistant', content: data.content || '', + parts: [], created_at: data.created_at || new Date().toISOString(), }; items.messages.push(currentMessage); - } else if (lastMessage?.role === 'assistant') { - currentMessage = lastMessage; } else { return; } @@ -96,6 +98,7 @@ function processChatEvent( if (currentMessage.content !== messageContent || event === ChatProgress.Answer) { currentMessage.content = messageContent; + currentMessage.parts = parseMessagePartsMemo(messageContent); onNewMessage?.([...items.messages], event); } diff --git a/src/types/entities/agents/chat.ts b/src/types/entities/agents/chat.ts index 4d9b67d3..fd5d075d 100644 --- a/src/types/entities/agents/chat.ts +++ b/src/types/entities/agents/chat.ts @@ -24,10 +24,17 @@ export type RatingPayload = Omit< 'owner_id' | 'id' | 'created_at' | 'modified_at' | 'created_by' | 'external_id' | 'agent_id' | 'chat_id' >; +export type MessagePart = + | { type: 'text'; text: string } + | { type: 'image'; src: string; alt: string; mimeType?: string } + | { type: 'video'; src: string; alt: string; thumbnail?: string } + | { type: 'link'; href: string; label: string }; + export interface Message { id: string; role?: 'system' | 'assistant' | 'user' | 'function' | 'tool'; content: string; + parts: MessagePart[]; created_at?: string; matches?: ChatResponse['matches']; context?: string; diff --git a/src/utils/content-parser.test.ts b/src/utils/content-parser.test.ts new file mode 100644 index 00000000..cb26abf5 --- /dev/null +++ b/src/utils/content-parser.test.ts @@ -0,0 +1,135 @@ +import { parseMessageParts, parseMessagePartsMemo } from './content-parser'; + +describe('parseMessageParts', () => { + describe('plain text', () => { + it('should return a single text part for plain text', () => { + const result = parseMessageParts('Hello, world!'); + expect(result).toEqual([{ type: 'text', text: 'Hello, world!' }]); + }); + + it('should return empty array for empty string', () => { + const result = parseMessageParts(''); + expect(result).toEqual([]); + }); + + it('should return a single text part for whitespace-only content', () => { + const result = parseMessageParts(' \n '); + expect(result).toEqual([{ type: 'text', text: ' \n ' }]); + }); + }); + + describe('markdown images', () => { + it('should parse a markdown image', () => { + const result = parseMessageParts('![alt text](https://example.com/image.png)'); + expect(result).toEqual([{ type: 'image', src: 'https://example.com/image.png', alt: 'alt text' }]); + }); + + it('should detect GIF images with mimeType', () => { + const result = parseMessageParts('![animation](https://example.com/funny.gif)'); + expect(result).toEqual([ + { type: 'image', src: 'https://example.com/funny.gif', alt: 'animation', mimeType: 'image/gif' }, + ]); + }); + + it('should handle image with empty alt text', () => { + const result = parseMessageParts('![](https://example.com/image.jpg)'); + expect(result).toEqual([{ type: 'image', src: 'https://example.com/image.jpg', alt: '' }]); + }); + }); + + describe('markdown video (thumbnail syntax)', () => { + it('should parse video with thumbnail syntax [![alt](thumb)](video)', () => { + const result = parseMessageParts( + '[![video title](https://example.com/thumb.jpg)](https://example.com/video.mp4)' + ); + expect(result).toEqual([ + { + type: 'video', + src: 'https://example.com/video.mp4', + alt: 'video title', + thumbnail: 'https://example.com/thumb.jpg', + }, + ]); + }); + }); + + describe('markdown links', () => { + it('should parse a markdown link', () => { + const result = parseMessageParts('[click here](https://example.com)'); + expect(result).toEqual([{ type: 'link', href: 'https://example.com', label: 'click here' }]); + }); + }); + + describe('HTML links', () => { + it('should parse an HTML anchor tag', () => { + const result = parseMessageParts('Visit'); + expect(result).toEqual([{ type: 'link', href: 'https://example.com', label: 'Visit' }]); + }); + }); + + describe('mixed content', () => { + it('should parse text interleaved with an image', () => { + const result = parseMessageParts('Hello ![pic](https://example.com/pic.png) world'); + expect(result).toEqual([ + { type: 'text', text: 'Hello ' }, + { type: 'image', src: 'https://example.com/pic.png', alt: 'pic' }, + { type: 'text', text: ' world' }, + ]); + }); + + it('should parse multiple different part types in order', () => { + const content = + 'Check this out: ![img](https://example.com/img.png)\nAnd visit [our site](https://example.com)'; + const result = parseMessageParts(content); + expect(result).toEqual([ + { type: 'text', text: 'Check this out: ' }, + { type: 'image', src: 'https://example.com/img.png', alt: 'img' }, + { type: 'text', text: '\nAnd visit ' }, + { type: 'link', href: 'https://example.com', label: 'our site' }, + ]); + }); + + it('should handle content starting with an asset', () => { + const result = parseMessageParts('![img](https://example.com/a.png) followed by text'); + expect(result).toEqual([ + { type: 'image', src: 'https://example.com/a.png', alt: 'img' }, + { type: 'text', text: ' followed by text' }, + ]); + }); + + it('should handle content ending with an asset', () => { + const result = parseMessageParts('text then ![img](https://example.com/a.png)'); + expect(result).toEqual([ + { type: 'text', text: 'text then ' }, + { type: 'image', src: 'https://example.com/a.png', alt: 'img' }, + ]); + }); + }); + + describe('incomplete markdown (streaming partials)', () => { + it('should keep incomplete image markdown as text', () => { + const result = parseMessageParts('Hello ![loading](https://example.com/pic'); + expect(result).toEqual([{ type: 'text', text: 'Hello ![loading](https://example.com/pic' }]); + }); + + it('should keep incomplete link markdown as text', () => { + const result = parseMessageParts('Check [this](https://exam'); + expect(result).toEqual([{ type: 'text', text: 'Check [this](https://exam' }]); + }); + }); +}); + +describe('parseMessagePartsMemo', () => { + it('should return same reference for same input', () => { + const content = 'Hello ![img](https://example.com/pic.png)'; + const result1 = parseMessagePartsMemo(content); + const result2 = parseMessagePartsMemo(content); + expect(result1).toBe(result2); + }); + + it('should return new result for different input', () => { + const result1 = parseMessagePartsMemo('Hello'); + const result2 = parseMessagePartsMemo('World'); + expect(result1).not.toBe(result2); + }); +}); diff --git a/src/utils/content-parser.ts b/src/utils/content-parser.ts new file mode 100644 index 00000000..9c99d3c7 --- /dev/null +++ b/src/utils/content-parser.ts @@ -0,0 +1,119 @@ +import { MessagePart } from '@sdk/types/entities/agents/chat'; + +// Video thumbnail syntax: [![alt](thumbnail-url)](video-url) +const VIDEO_THUMBNAIL_RE = /\[!\[([^\[\]]*)\]\(([^)\s]+)\)\]\(([^)\s]+)\)/g; + +// Standard markdown image: ![alt](url) +const IMAGE_RE = /!\[([^\[\]]*)\]\(([^)\s]+)\)/g; + +// Standard markdown link: [label](url) — but NOT images (no leading !) +const MD_LINK_RE = /(?label +const HTML_LINK_RE = /]*?>([^<]*)<\/a>/gi; + +interface MatchEntry { + index: number; + length: number; + part: MessagePart; +} + +export function parseMessageParts(content: string): MessagePart[] { + if (content.length === 0) { + return []; + } + + const matches: MatchEntry[] = []; + + let m: RegExpExecArray | null; + + // 1. Video thumbnail: [![alt](thumb)](video) — must be matched first + VIDEO_THUMBNAIL_RE.lastIndex = 0; + while ((m = VIDEO_THUMBNAIL_RE.exec(content)) !== null) { + matches.push({ + index: m.index, + length: m[0].length, + part: { type: 'video', src: m[3], alt: m[1], thumbnail: m[2] }, + }); + } + + // 2. Markdown images: ![alt](url) — skip those already consumed by video thumbnails + IMAGE_RE.lastIndex = 0; + while ((m = IMAGE_RE.exec(content)) !== null) { + const overlaps = matches.some(entry => m!.index >= entry.index && m!.index < entry.index + entry.length); + if (!overlaps) { + const src = m[2]; + const part: MessagePart = { type: 'image', src, alt: m[1] }; + if (src.toLowerCase().endsWith('.gif')) { + (part as Extract).mimeType = 'image/gif'; + } + matches.push({ index: m.index, length: m[0].length, part }); + } + } + + // 3. Markdown links: [label](url) — skip those already consumed + MD_LINK_RE.lastIndex = 0; + while ((m = MD_LINK_RE.exec(content)) !== null) { + const overlaps = matches.some(entry => m!.index >= entry.index && m!.index < entry.index + entry.length); + if (!overlaps) { + matches.push({ + index: m.index, + length: m[0].length, + part: { type: 'link', href: m[2], label: m[1] }, + }); + } + } + + // 4. HTML links: label — skip those already consumed + HTML_LINK_RE.lastIndex = 0; + while ((m = HTML_LINK_RE.exec(content)) !== null) { + const overlaps = matches.some(entry => m!.index >= entry.index && m!.index < entry.index + entry.length); + if (!overlaps) { + matches.push({ + index: m.index, + length: m[0].length, + part: { type: 'link', href: m[1], label: m[2] }, + }); + } + } + + // No matches → single text part + if (matches.length === 0) { + return [{ type: 'text', text: content }]; + } + + // Sort by position + matches.sort((a, b) => a.index - b.index); + + // Build parts array with text gaps + const parts: MessagePart[] = []; + let cursor = 0; + + for (const entry of matches) { + if (entry.index > cursor) { + parts.push({ type: 'text', text: content.slice(cursor, entry.index) }); + } + parts.push(entry.part); + cursor = entry.index + entry.length; + } + + if (cursor < content.length) { + parts.push({ type: 'text', text: content.slice(cursor) }); + } + + return parts; +} + +// Single-entry memoization — optimal for streaming where the same content string +// is checked multiple times per render cycle +let memoKey: string = ''; +let memoValue: MessagePart[] = []; + +export function parseMessagePartsMemo(content: string): MessagePart[] { + if (content === memoKey) { + return memoValue; + } + memoKey = content; + memoValue = parseMessageParts(content); + return memoValue; +}