diff --git a/.changeset/retry-regenerate-messages.md b/.changeset/retry-regenerate-messages.md new file mode 100644 index 0000000..c2a2bb4 --- /dev/null +++ b/.changeset/retry-regenerate-messages.md @@ -0,0 +1,7 @@ +--- +'@chatcops/core': minor +'@chatcops/server': minor +'@chatcops/widget': minor +--- + +Add retry and regenerate support for widget conversations, including assistant message regeneration, retryable error bubbles, and server-side conversation updates for regenerate requests. diff --git a/packages/core/src/conversation/manager.ts b/packages/core/src/conversation/manager.ts index 29c7f8b..0089559 100644 --- a/packages/core/src/conversation/manager.ts +++ b/packages/core/src/conversation/manager.ts @@ -47,6 +47,15 @@ export class ConversationManager { return conversation?.messages ?? []; } + async removeMessage(conversationId: string, messageId: string): Promise { + const conversation = await this.store.get(conversationId); + if (!conversation) return; + + conversation.messages = conversation.messages.filter((message) => message.id !== messageId); + conversation.updatedAt = Date.now(); + await this.store.save(conversation); + } + async deleteConversation(id: string): Promise { await this.store.delete(id); } diff --git a/packages/core/tests/conversation/manager.test.ts b/packages/core/tests/conversation/manager.test.ts index a690f39..bfb2baf 100644 --- a/packages/core/tests/conversation/manager.test.ts +++ b/packages/core/tests/conversation/manager.test.ts @@ -89,4 +89,18 @@ describe('ConversationManager', () => { const messages = await manager.getMessages('nope'); expect(messages).toHaveLength(0); }); + + it('removes a message by id', async () => { + const manager = new ConversationManager(); + const userMessage = makeMessage('user', 'Hello'); + const assistantMessage = makeMessage('assistant', 'Hi there!'); + + await manager.addMessage('conv-remove', userMessage); + await manager.addMessage('conv-remove', assistantMessage); + await manager.removeMessage('conv-remove', assistantMessage.id); + + const messages = await manager.getMessages('conv-remove'); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe(userMessage.id); + }); }); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 0e85b0d..aacc6db 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -17,6 +17,8 @@ export interface ChatCopsServerConfig { export const chatRequestSchema = z.object({ conversationId: z.string().min(1).max(128), message: z.string().min(1).max(10000), + messageId: z.string().min(1).max(128).optional(), + regenerate: z.boolean().optional(), pageContext: z.object({ url: z.string().url(), title: z.string().max(500), diff --git a/packages/server/src/handler.ts b/packages/server/src/handler.ts index 55adf31..d6ca895 100644 --- a/packages/server/src/handler.ts +++ b/packages/server/src/handler.ts @@ -46,22 +46,38 @@ export function createChatHandler(config: ChatCopsServerConfig) { } // Get or create conversation - const conversation = await conversations.getOrCreate(req.conversationId); + await conversations.getOrCreate(req.conversationId); + + if (req.regenerate) { + const existingMessages = await conversations.getMessages(req.conversationId); + const lastAssistant = [...existingMessages].reverse().find((message) => message.role === 'assistant'); + if (lastAssistant) { + await conversations.removeMessage(req.conversationId, lastAssistant.id); + } + } + + const conversationMessages = await conversations.getMessages(req.conversationId); // Track analytics - if (analytics && conversation.messages.length === 0) { + if (analytics && conversationMessages.length === 0) { analytics.track('conversation:started', { conversationId: req.conversationId }); } analytics?.track('message:sent', { conversationId: req.conversationId }); - // Add user message - const userMessage: ChatMessage = { - id: crypto.randomUUID(), - role: 'user', - content: req.message, - timestamp: Date.now(), - }; - await conversations.addMessage(req.conversationId, userMessage); + // Add user message unless this is a regenerate request or a retry of the same message + const hasExistingUserMessage = req.messageId + ? conversationMessages.some((message) => message.id === req.messageId) + : false; + + if (!req.regenerate && !hasExistingUserMessage) { + const userMessage: ChatMessage = { + id: req.messageId ?? crypto.randomUUID(), + role: 'user', + content: req.message, + timestamp: Date.now(), + }; + await conversations.addMessage(req.conversationId, userMessage); + } // Build system prompt let systemPrompt = config.systemPrompt; diff --git a/packages/server/tests/handler.test.ts b/packages/server/tests/handler.test.ts index 66f6a99..44175a9 100644 --- a/packages/server/tests/handler.test.ts +++ b/packages/server/tests/handler.test.ts @@ -1,23 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@chatcops/core', () => { + type ChatMessage = { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: number; + metadata?: Record; + }; + + type Conversation = { + id: string; + messages: ChatMessage[]; + metadata?: Record; + createdAt: number; + updatedAt: number; + }; + class ConversationManager { - private readonly conversations = new Map< - string, - { - id: string; - messages: Array<{ - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp: number; - metadata?: Record; - }>; - metadata?: Record; - createdAt: number; - updatedAt: number; - } - >(); + private readonly conversations = new Map(); async getOrCreate(id: string) { const existing = this.conversations.get(id); @@ -26,7 +27,7 @@ vi.mock('@chatcops/core', () => { } const now = Date.now(); - const conversation = { + const conversation: Conversation = { id, messages: [], createdAt: now, @@ -36,22 +37,23 @@ vi.mock('@chatcops/core', () => { return conversation; } - async addMessage(conversationId: string, message: { - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp: number; - metadata?: Record; - }) { + async addMessage(conversationId: string, message: ChatMessage) { const conversation = await this.getOrCreate(conversationId); conversation.messages.push(message); conversation.updatedAt = Date.now(); + return conversation; } async getMessages(conversationId: string) { const conversation = await this.getOrCreate(conversationId); return [...conversation.messages]; } + + async removeMessage(conversationId: string, messageId: string) { + const conversation = await this.getOrCreate(conversationId); + conversation.messages = conversation.messages.filter((message) => message.id !== messageId); + conversation.updatedAt = Date.now(); + } } class AnalyticsCollector { @@ -333,6 +335,54 @@ describe('createChatHandler', () => { }, }); }); + + it('removes the previous assistant message before regenerating', async () => { + const receivedMessages: Array> = []; + + mockedCreateProvider.mockResolvedValue({ + name: 'test-provider', + async *chat(params) { + receivedMessages.push( + params.messages.map((message) => ({ + role: message.role, + content: message.content, + })) + ); + yield receivedMessages.length === 1 ? 'First reply' : 'Regenerated reply'; + }, + async chatSync() { + return ''; + }, + }); + + const { handleChat } = createChatHandler({ + provider: { type: 'claude', apiKey: 'test-key' }, + systemPrompt: 'Test', + cors: '*', + }); + + for await (const _chunk of handleChat({ + conversationId: 'conv-regenerate', + message: 'Hello there', + messageId: 'user-1', + })) { + // Drain initial response. + } + + for await (const _chunk of handleChat({ + conversationId: 'conv-regenerate', + message: 'Hello there', + messageId: 'user-1', + regenerate: true, + })) { + // Drain regenerated response. + } + + expect(receivedMessages).toEqual([ + [{ role: 'user', content: 'Hello there' }], + [{ role: 'user', content: 'Hello there' }], + ]); + }); }); describe('chatRequestSchema', () => { @@ -358,6 +408,21 @@ describe('chatRequestSchema', () => { expect(result.success).toBe(true); }); + it('accepts regenerate metadata', () => { + const result = chatRequestSchema.safeParse({ + conversationId: 'conv-1', + message: 'Hello', + messageId: 'msg-1', + regenerate: true, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.messageId).toBe('msg-1'); + expect(result.data.regenerate).toBe(true); + } + }); + it('rejects empty conversationId', () => { const result = chatRequestSchema.safeParse({ conversationId: '', @@ -403,6 +468,7 @@ describe('chatRequestSchema', () => { if (result.success) { expect(result.data.pageContext).toBeUndefined(); expect(result.data.locale).toBeUndefined(); + expect(result.data.regenerate).toBeUndefined(); } }); }); diff --git a/packages/widget/src/api/types.ts b/packages/widget/src/api/types.ts index 3014c81..cef82e4 100644 --- a/packages/widget/src/api/types.ts +++ b/packages/widget/src/api/types.ts @@ -1,6 +1,8 @@ export interface WidgetChatRequest { conversationId: string; message: string; + messageId?: string; + regenerate?: boolean; pageContext?: { url: string; title: string; diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index 81e25cc..bbb8634 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -1,16 +1,33 @@ import { renderMarkdown } from '../markdown.js'; +const RETRY_ICON = ''; +const REGENERATE_ICON = ''; + +export type MessageStatus = 'streaming' | 'complete' | 'error'; +export type MessageErrorType = 'rate_limit' | 'network' | 'provider_error' | 'timeout'; + export interface MessageData { id: string; role: 'user' | 'assistant'; content: string; + status?: MessageStatus; + errorType?: MessageErrorType; +} + +export interface MessageActions { + onRetry?: (messageId: string) => void; + onRegenerate?: (messageId: string) => void; + retryLabel?: string; + regenerateLabel?: string; } export class Messages { private container: HTMLDivElement; private typingEl: HTMLDivElement | null = null; + private actions?: MessageActions; - constructor(parent: HTMLElement) { + constructor(parent: HTMLElement, actions?: MessageActions) { + this.actions = actions; this.container = document.createElement('div'); this.container.className = 'cc-messages'; parent.appendChild(this.container); @@ -18,19 +35,16 @@ export class Messages { addMessage(msg: MessageData): HTMLDivElement { this.removeTyping(); - const el = document.createElement('div'); - el.className = `cc-message cc-message-${msg.role}`; - el.dataset.id = msg.id; - el.innerHTML = msg.role === 'assistant' ? renderMarkdown(msg.content) : this.escapeHtml(msg.content); + const el = this.createMessageElement(msg); this.container.appendChild(el); this.scrollToBottom(); return el; } - updateMessage(id: string, content: string, isAssistant: boolean): void { - const el = this.container.querySelector(`[data-id="${id}"]`) as HTMLDivElement | null; + updateMessage(msg: MessageData): void { + const el = this.container.querySelector(`[data-id="${msg.id}"]`) as HTMLDivElement | null; if (el) { - el.innerHTML = isAssistant ? renderMarkdown(content) : this.escapeHtml(content); + this.renderMessageElement(el, msg); this.scrollToBottom(); } } @@ -56,6 +70,33 @@ export class Messages { this.typingEl = null; } + removeMessage(id: string): void { + const el = this.container.querySelector(`[data-id="${id}"]`) as HTMLDivElement | null; + el?.remove(); + } + + clearRegenerateButtons(): void { + this.container.querySelectorAll('.cc-message-regenerate').forEach((button) => button.remove()); + this.cleanupEmptyActionContainers(); + } + + showRegenerateButton(messageId: string): void { + this.clearRegenerateButtons(); + + if (!this.actions?.onRegenerate) return; + + const msgEl = this.container.querySelector(`[data-id="${messageId}"]`) as HTMLDivElement | null; + if (!msgEl || !msgEl.classList.contains('cc-message-assistant')) return; + + const actions = this.ensureActionsContainer(msgEl); + actions.appendChild(this.createActionButton({ + className: 'cc-message-regenerate', + label: this.actions.regenerateLabel ?? 'Regenerate', + icon: REGENERATE_ICON, + onClick: () => this.actions?.onRegenerate?.(messageId), + })); + } + setVisible(visible: boolean): void { this.container.classList.toggle('cc-hidden', !visible); } @@ -64,6 +105,91 @@ export class Messages { this.container.scrollTop = this.container.scrollHeight; } + private createMessageElement(msg: MessageData): HTMLDivElement { + const el = document.createElement('div'); + this.renderMessageElement(el, msg); + return el; + } + + private renderMessageElement(el: HTMLDivElement, msg: MessageData): void { + el.className = `cc-message cc-message-${msg.role}`; + el.dataset.id = msg.id; + + if (msg.status) { + el.dataset.status = msg.status; + } else { + delete el.dataset.status; + } + + if (msg.errorType) { + el.dataset.errorType = msg.errorType; + } else { + delete el.dataset.errorType; + } + + el.innerHTML = ''; + + const content = document.createElement('div'); + content.className = 'cc-message-content'; + content.innerHTML = msg.role === 'assistant' ? renderMarkdown(msg.content) : this.escapeHtml(msg.content); + el.appendChild(content); + + const actions = this.createActionsContainer(msg); + if (actions) { + el.appendChild(actions); + } + } + + private createActionsContainer(msg: MessageData): HTMLDivElement | null { + if (msg.status !== 'error' || !this.actions?.onRetry) { + return null; + } + + const actions = document.createElement('div'); + actions.className = 'cc-message-actions'; + actions.appendChild(this.createActionButton({ + className: 'cc-message-retry', + label: this.actions.retryLabel ?? 'Retry', + icon: RETRY_ICON, + onClick: () => this.actions?.onRetry?.(msg.id), + })); + return actions; + } + + private ensureActionsContainer(msgEl: HTMLDivElement): HTMLDivElement { + const existing = msgEl.querySelector('.cc-message-actions') as HTMLDivElement | null; + if (existing) { + return existing; + } + + const actions = document.createElement('div'); + actions.className = 'cc-message-actions'; + msgEl.appendChild(actions); + return actions; + } + + private cleanupEmptyActionContainers(): void { + this.container.querySelectorAll('.cc-message-actions').forEach((container) => { + if (!container.hasChildNodes()) { + container.remove(); + } + }); + } + + private createActionButton(options: { + className: string; + label: string; + icon: string; + onClick: () => void; + }): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = options.className; + button.innerHTML = `${options.icon}${this.escapeHtml(options.label)}`; + button.addEventListener('click', options.onClick); + return button; + } + private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index 7418a8b..4122234 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -1,4 +1,4 @@ -import { Messages, type MessageData } from './messages.js'; +import { Messages, type MessageActions, type MessageData } from './messages.js'; import { Input } from './input.js'; const BOT_ICON = ``; @@ -15,6 +15,7 @@ export interface PanelOptions { }; placeholder: string; footerText: string; + messageActions?: MessageActions; onSend: (text: string) => void; onClose: () => void; } @@ -77,7 +78,7 @@ export class Panel { } else { this.messagesContainer = this.el; } - this.messages = new Messages(this.messagesContainer); + this.messages = new Messages(this.messagesContainer, options.messageActions); // Input this.input = new Input(this.el, { diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts index 6de4054..6a3816a 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -15,6 +15,8 @@ export interface WidgetLocaleStrings { preChatSubmit: string; preChatRequired: string; preChatInvalidEmail: string; + retryButton: string; + regenerateButton: string; } const en: WidgetLocaleStrings = { @@ -34,6 +36,8 @@ const en: WidgetLocaleStrings = { preChatSubmit: 'Start Chat', preChatRequired: 'This field is required', preChatInvalidEmail: 'Please enter a valid email', + retryButton: 'Retry', + regenerateButton: 'Regenerate', }; const locales: Record = { en }; diff --git a/packages/widget/src/storage.ts b/packages/widget/src/storage.ts index df0f2c6..b90a285 100644 --- a/packages/widget/src/storage.ts +++ b/packages/widget/src/storage.ts @@ -4,6 +4,8 @@ interface StoredConversation { id: string; role: 'user' | 'assistant'; content: string; + status?: 'streaming' | 'complete' | 'error'; + errorType?: 'rate_limit' | 'network' | 'provider_error' | 'timeout'; timestamp: number; }>; updatedAt: number; @@ -30,7 +32,13 @@ export class ConversationStorage { save(conversation: StoredConversation): void { try { - localStorage.setItem(this.key, JSON.stringify(conversation)); + const filtered = { + ...conversation, + messages: conversation.messages + .filter((m) => m.status !== 'error') + .map(({ status, ...message }) => message), + }; + localStorage.setItem(this.key, JSON.stringify(filtered)); } catch { // Storage full or unavailable } diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css index 6131a2c..b9519cf 100644 --- a/packages/widget/src/styles/widget.css +++ b/packages/widget/src/styles/widget.css @@ -256,6 +256,9 @@ overflow-wrap: break-word; font-size: 14px; line-height: 1.5; + display: flex; + flex-direction: column; + gap: 8px; } .cc-message-user { @@ -307,6 +310,56 @@ margin: 4px 0; } +.cc-message-actions { + display: flex; + align-items: center; +} + +.cc-message-retry, +.cc-message-regenerate { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: transparent; + border: 1px solid var(--cc-border); + border-radius: 6px; + color: var(--cc-text-secondary); + font-family: var(--cc-font); + font-size: 12px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease; +} + +.cc-message-retry:hover, +.cc-message-regenerate:hover { + background: var(--cc-bg-input); + color: var(--cc-text); +} + +.cc-message-retry svg, +.cc-message-regenerate svg { + fill: currentColor; +} + +.cc-message-regenerate { + opacity: 0; +} + +.cc-message:hover .cc-message-regenerate { + opacity: 1; +} + +.cc-message-retry { + border-color: #ef4444; + color: #ef4444; +} + +.cc-message-retry:hover { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + /* Typing indicator */ .cc-typing { display: flex; diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index bbfafb9..d1e561c 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -7,7 +7,8 @@ import { ConversationStorage } from './storage.js'; import { applyTheme } from './theme.js'; import { getWidgetLocale, type WidgetLocaleStrings } from './i18n.js'; import { PreChatForm } from './dom/prechat-form.js'; -import type { MessageData } from './dom/messages.js'; +import type { WidgetChatRequest } from './api/types.js'; +import type { MessageData, MessageErrorType } from './dom/messages.js'; export interface PreChatField { name: string; @@ -73,6 +74,10 @@ type WidgetEventType = | 'leadCaptured' | 'preChatSubmit'; type WidgetEventHandler = (...args: unknown[]) => void; +type SendRequestOptions = { + regenerate?: boolean; + userMessageId?: string; +}; export class Widget { private config: WidgetConfig; @@ -89,6 +94,7 @@ export class Widget { private userData?: Record; private userDataSent = false; private isStreaming = false; + private retryRequests = new Map(); private eventHandlers = new Map>(); private containerEl?: HTMLElement; @@ -151,6 +157,16 @@ export class Widget { }, placeholder: this.config.placeholder ?? this.locale.inputPlaceholder, footerText: this.locale.poweredBy, + messageActions: { + onRetry: (messageId) => { + void this.handleRetry(messageId); + }, + onRegenerate: (messageId) => { + void this.handleRegenerate(messageId); + }, + retryLabel: this.locale.retryButton, + regenerateLabel: this.locale.regenerateButton, + }, onSend: (text) => this.handleSend(text), onClose: () => this.close(), }); @@ -178,6 +194,8 @@ export class Widget { this.showChatView(); } + this.showLatestRegenerateButton(); + // Welcome bubble — popup mode only if (!this.isInline && this.config.welcomeBubble) { this.bubble = new WelcomeBubble(this.shadow, { @@ -326,44 +344,117 @@ export class Widget { this.config.onMessage?.(userMsg); this.emit('message', userMsg); + await this.sendToServer(text, { userMessageId: userMsg.id }); + } + + private async handleRetry(errorMessageId: string): Promise { + if (this.isStreaming) return; + + const errorIndex = this.messages.findIndex((message) => message.id === errorMessageId); + if (errorIndex === -1) return; + + const retryRequest = this.retryRequests.get(errorMessageId); + let userMessage = retryRequest?.userMessageId + ? this.messages.find((message) => message.id === retryRequest.userMessageId) + : undefined; + + if (!userMessage) { + for (let i = errorIndex - 1; i >= 0; i--) { + if (this.messages[i].role === 'user') { + userMessage = this.messages[i]; + break; + } + } + } + + if (!userMessage) return; + + this.panel.messages.removeMessage(errorMessageId); + this.retryRequests.delete(errorMessageId); + this.messages = this.messages.filter((message) => message.id !== errorMessageId); + + await this.sendToServer(userMessage.content, { + regenerate: retryRequest?.regenerate, + userMessageId: retryRequest?.userMessageId ?? userMessage.id, + }); + } + + private async handleRegenerate(assistantMessageId: string): Promise { + if (this.isStreaming) return; + + const assistantIndex = this.messages.findIndex((message) => message.id === assistantMessageId); + if (assistantIndex === -1) return; + + const assistantMessage = this.messages[assistantIndex]; + if (assistantMessage.role !== 'assistant') return; + + const userMessage = this.findUserMessageForAssistant(assistantIndex); + if (!userMessage) return; + + this.panel.messages.clearRegenerateButtons(); + this.panel.messages.removeMessage(assistantMessageId); + this.retryRequests.delete(assistantMessageId); + this.messages = this.messages.filter((message) => message.id !== assistantMessageId); + + await this.sendToServer(userMessage.content, { + regenerate: true, + userMessageId: userMessage.id, + }); + } + + private async sendToServer(text: string, options: SendRequestOptions = {}): Promise { + if (this.isStreaming) return; + this.isStreaming = true; this.panel.input.setDisabled(true); + this.panel.messages.clearRegenerateButtons(); this.panel.messages.showTyping(); const assistantMsg: MessageData = { id: crypto.randomUUID(), role: 'assistant', content: '', + status: 'streaming', }; - const pageContext = this.config.pageContext !== false - ? { url: window.location.href, title: document.title } - : undefined; - - const includeUserData = this.userData && !this.userDataSent; + const includeUserData = !!(this.userData && !this.userDataSent); + let firstChunk = true; + let requestCompleted = false; + let hasError = false; try { - let firstChunk = true; - for await (const chunk of this.client.sendMessage({ - conversationId: this.conversationId, - message: text, - pageContext, - locale: this.config.locale, - ...(includeUserData ? { userData: this.userData } : {}), - })) { + for await (const chunk of this.client.sendMessage(this.createRequest(text, options, includeUserData))) { if (chunk.error) { - const errorKey = chunk.error === 'rate_limit' ? 'errorRateLimit' - : chunk.error === 'network' ? 'errorNetwork' - : 'errorGeneric'; - assistantMsg.content = this.locale[errorKey]; + const errorType = this.resolveErrorType(chunk.error); + assistantMsg.content = this.getErrorMessage(errorType); + assistantMsg.status = 'error'; + assistantMsg.errorType = errorType; + hasError = true; + + this.retryRequests.set(assistantMsg.id, { + regenerate: options.regenerate, + userMessageId: options.userMessageId, + }); + this.panel.messages.removeTyping(); - this.panel.addMessage(assistantMsg); - this.config.onError?.(new Error(chunk.error)); - this.emit('error', new Error(chunk.error)); + if (firstChunk) { + this.panel.addMessage(assistantMsg); + } else { + this.panel.messages.updateMessage(assistantMsg); + } + + this.messages.push(assistantMsg); + + const error = new Error(chunk.error); + this.config.onError?.(error); + this.emit('error', error); break; } - if (chunk.done) break; + if (chunk.done) { + requestCompleted = true; + break; + } if (chunk.leadCaptured) { this.config.onLeadCaptured?.(chunk.leadData); @@ -376,27 +467,51 @@ export class Widget { this.panel.addMessage(assistantMsg); firstChunk = false; } + assistantMsg.content += chunk.content; - this.panel.messages.updateMessage(assistantMsg.id, assistantMsg.content, true); + this.panel.messages.updateMessage(assistantMsg); } } - if (assistantMsg.content) { + if (!hasError && !requestCompleted) { + requestCompleted = true; + } + + if (!hasError && firstChunk) { + this.panel.messages.removeTyping(); + } + + if (!hasError && assistantMsg.content) { + assistantMsg.status = 'complete'; + this.panel.messages.updateMessage(assistantMsg); this.messages.push(assistantMsg); this.config.onMessage?.(assistantMsg); this.emit('message', assistantMsg); + this.showLatestRegenerateButton(); if (!this.isInline && !this.panel.isVisible) { this.fab?.showBadge(); } } } catch (err) { - this.panel.messages.removeTyping(); assistantMsg.content = this.locale.errorGeneric; + assistantMsg.status = 'error'; + assistantMsg.errorType = 'network'; + + this.retryRequests.set(assistantMsg.id, { + regenerate: options.regenerate, + userMessageId: options.userMessageId, + }); + + this.panel.messages.removeTyping(); this.panel.addMessage(assistantMsg); - this.config.onError?.(err instanceof Error ? err : new Error(String(err))); + this.messages.push(assistantMsg); + + const error = err instanceof Error ? err : new Error(String(err)); + this.config.onError?.(error); + this.emit('error', error); } finally { - if (includeUserData) { + if (includeUserData && requestCompleted) { this.userDataSent = true; } this.isStreaming = false; @@ -406,6 +521,86 @@ export class Widget { } } + private createRequest( + text: string, + options: SendRequestOptions, + includeUserData: boolean + ): WidgetChatRequest { + const pageContext = this.config.pageContext !== false + ? { url: window.location.href, title: document.title } + : undefined; + + return { + conversationId: this.conversationId, + message: text, + messageId: options.userMessageId, + regenerate: options.regenerate, + pageContext, + locale: this.config.locale, + ...(includeUserData ? { userData: this.userData } : {}), + }; + } + + private findUserMessageForAssistant(assistantIndex: number): MessageData | undefined { + for (let i = assistantIndex - 1; i >= 0; i--) { + if (this.messages[i].role === 'user') { + return this.messages[i]; + } + } + + return undefined; + } + + private findLatestRegeneratableAssistant(): MessageData | undefined { + for (let i = this.messages.length - 1; i >= 0; i--) { + const message = this.messages[i]; + if (message.id === 'welcome') { + continue; + } + + if (message.role !== 'assistant' || message.status === 'error') { + return undefined; + } + + return message; + } + + return undefined; + } + + private showLatestRegenerateButton(): void { + const assistantMessage = this.findLatestRegeneratableAssistant(); + if (assistantMessage) { + this.panel.messages.showRegenerateButton(assistantMessage.id); + } else { + this.panel.messages.clearRegenerateButtons(); + } + } + + private resolveErrorType(error: string): MessageErrorType { + switch (error) { + case 'rate_limit': + case 'network': + case 'provider_error': + case 'timeout': + return error; + default: + return 'provider_error'; + } + } + + private getErrorMessage(errorType: MessageErrorType): string { + switch (errorType) { + case 'rate_limit': + return this.locale.errorRateLimit; + case 'network': + case 'timeout': + return this.locale.errorNetwork; + default: + return this.locale.errorGeneric; + } + } + private saveHistory(): void { if (this.config.persistHistory === false) return; @@ -418,6 +613,8 @@ export class Widget { id: m.id, role: m.role, content: m.content, + status: m.status, + errorType: m.errorType, timestamp: Date.now(), })), updatedAt: Date.now(), diff --git a/packages/widget/tests/dom/messages.test.ts b/packages/widget/tests/dom/messages.test.ts index 6580ca3..7911da4 100644 --- a/packages/widget/tests/dom/messages.test.ts +++ b/packages/widget/tests/dom/messages.test.ts @@ -32,7 +32,7 @@ describe('Messages', () => { it('updates an existing message', () => { messages.addMessage({ id: '3', role: 'assistant', content: 'Hello' }); - messages.updateMessage('3', 'Hello world', true); + messages.updateMessage({ id: '3', role: 'assistant', content: 'Hello world' }); const msg = parent.querySelector('[data-id="3"]'); expect(msg?.textContent).toContain('Hello world'); }); @@ -63,4 +63,63 @@ describe('Messages', () => { const msg = parent.querySelector('.cc-message-user'); expect(msg?.innerHTML).not.toContain('