diff --git a/core/src/browser/extensions/engines/AIEngine.ts b/core/src/browser/extensions/engines/AIEngine.ts index 41de30c1b..0e8a75fca 100644 --- a/core/src/browser/extensions/engines/AIEngine.ts +++ b/core/src/browser/extensions/engines/AIEngine.ts @@ -54,6 +54,7 @@ export type ToolChoice = 'none' | 'auto' | 'required' | ToolCallSpec export interface chatCompletionRequest { model: string // Model ID, though for local it might be implicit via sessionInfo messages: chatCompletionRequestMessage[] + thread_id?: string // Thread/conversation ID for context tracking return_progress?: boolean tools?: Tool[] tool_choice?: ToolChoice diff --git a/extensions-web/src/conversational-web/api.ts b/extensions-web/src/conversational-web/api.ts new file mode 100644 index 000000000..0e398eb05 --- /dev/null +++ b/extensions-web/src/conversational-web/api.ts @@ -0,0 +1,160 @@ +/** + * Conversation API wrapper using JanAuthProvider + */ + +import { getSharedAuthService, JanAuthService } from '../shared/auth' +import { CONVERSATION_API_ROUTES } from './const' +import { + Conversation, + ConversationResponse, + ListConversationsParams, + ListConversationsResponse, + PaginationParams, + PaginatedResponse, + ConversationItem, + ListConversationItemsParams, + ListConversationItemsResponse +} from './types' + +declare const JAN_API_BASE: string + +export class RemoteApi { + private authService: JanAuthService + + constructor() { + this.authService = getSharedAuthService() + } + + async createConversation( + data: Conversation + ): Promise { + const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATIONS}` + + return this.authService.makeAuthenticatedRequest( + url, + { + method: 'POST', + body: JSON.stringify(data), + } + ) + } + + async updateConversation( + conversationId: string, + data: Conversation + ): Promise { + const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}` + + return this.authService.makeAuthenticatedRequest( + url, + { + method: 'PATCH', + body: JSON.stringify(data), + } + ) + } + + async listConversations( + params?: ListConversationsParams + ): Promise { + const queryParams = new URLSearchParams() + + if (params?.limit !== undefined) { + queryParams.append('limit', params.limit.toString()) + } + if (params?.after) { + queryParams.append('after', params.after) + } + if (params?.order) { + queryParams.append('order', params.order) + } + + const queryString = queryParams.toString() + const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATIONS}${queryString ? `?${queryString}` : ''}` + + return this.authService.makeAuthenticatedRequest( + url, + { + method: 'GET', + } + ) + } + + /** + * Generic method to fetch all pages of paginated data + */ + async fetchAllPaginated( + fetchFn: (params: PaginationParams) => Promise>, + initialParams?: Partial + ): Promise { + const allItems: T[] = [] + let after: string | undefined = undefined + let hasMore = true + const limit = initialParams?.limit || 100 + + while (hasMore) { + const response = await fetchFn({ + limit, + after, + ...initialParams, + }) + + allItems.push(...response.data) + hasMore = response.has_more + after = response.last_id + } + + return allItems + } + + async getAllConversations(): Promise { + return this.fetchAllPaginated( + (params) => this.listConversations(params) + ) + } + + async deleteConversation(conversationId: string): Promise { + const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}` + + await this.authService.makeAuthenticatedRequest( + url, + { + method: 'DELETE', + } + ) + } + + async listConversationItems( + conversationId: string, + params?: Omit + ): Promise { + const queryParams = new URLSearchParams() + + if (params?.limit !== undefined) { + queryParams.append('limit', params.limit.toString()) + } + if (params?.after) { + queryParams.append('after', params.after) + } + if (params?.order) { + queryParams.append('order', params.order) + } + + const queryString = queryParams.toString() + const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_ITEMS(conversationId)}${queryString ? `?${queryString}` : ''}` + + return this.authService.makeAuthenticatedRequest( + url, + { + method: 'GET', + } + ) + } + + async getAllConversationItems(conversationId: string): Promise { + return this.fetchAllPaginated( + (params) => this.listConversationItems(conversationId, params), + { limit: 100, order: 'asc' } + ) + } +} diff --git a/extensions-web/src/conversational-web/const.ts b/extensions-web/src/conversational-web/const.ts new file mode 100644 index 000000000..0ad7e9049 --- /dev/null +++ b/extensions-web/src/conversational-web/const.ts @@ -0,0 +1,17 @@ +/** + * API Constants for Conversational Web + */ + + +export const CONVERSATION_API_ROUTES = { + CONVERSATIONS: '/conversations', + CONVERSATION_BY_ID: (id: string) => `/conversations/${id}`, + CONVERSATION_ITEMS: (id: string) => `/conversations/${id}/items`, +} as const + +export const DEFAULT_ASSISTANT = { + id: 'jan', + name: 'Jan', + avatar: '👋', + created_at: 1747029866.542, +} \ No newline at end of file diff --git a/extensions-web/src/conversational-web/extension.ts b/extensions-web/src/conversational-web/extension.ts new file mode 100644 index 000000000..7c31f1c31 --- /dev/null +++ b/extensions-web/src/conversational-web/extension.ts @@ -0,0 +1,154 @@ +/** + * Web Conversational Extension + * Implements thread and message management using IndexedDB + */ + +import { + Thread, + ThreadMessage, + ConversationalExtension, + ThreadAssistantInfo, +} from '@janhq/core' +import { RemoteApi } from './api' +import { getDefaultAssistant, ObjectParser, combineConversationItemsToMessages } from './utils' + +export default class ConversationalExtensionWeb extends ConversationalExtension { + private remoteApi: RemoteApi | undefined + + async onLoad() { + console.log('Loading Web Conversational Extension') + this.remoteApi = new RemoteApi() + } + + onUnload() {} + + // Thread Management + async listThreads(): Promise { + try { + if (!this.remoteApi) { + throw new Error('RemoteApi not initialized') + } + const conversations = await this.remoteApi.getAllConversations() + console.log('!!!Listed threads:', conversations.map(ObjectParser.conversationToThread)) + return conversations.map(ObjectParser.conversationToThread) + } catch (error) { + console.error('Failed to list threads:', error) + return [] + } + } + + async createThread(thread: Thread): Promise { + try { + if (!this.remoteApi) { + throw new Error('RemoteApi not initialized') + } + const response = await this.remoteApi.createConversation( + ObjectParser.threadToConversation(thread) + ) + // Create a new thread object with the server's ID + const createdThread = { + ...thread, + id: response.id, + assistants: thread.assistants.map(getDefaultAssistant) + } + console.log('!!!Created thread:', createdThread) + return createdThread + } catch (error) { + console.error('Failed to create thread:', error) + throw error + } + } + + async modifyThread(thread: Thread): Promise { + try { + if (!this.remoteApi) { + throw new Error('RemoteApi not initialized') + } + await this.remoteApi.updateConversation( + thread.id, + ObjectParser.threadToConversation(thread) + ) + console.log('!!!Modified thread:', thread) + } catch (error) { + console.error('Failed to modify thread:', error) + throw error + } + } + + async deleteThread(threadId: string): Promise { + try { + if (!this.remoteApi) { + throw new Error('RemoteApi not initialized') + } + await this.remoteApi.deleteConversation(threadId) + console.log('!!!Deleted thread:', threadId) + } catch (error) { + console.error('Failed to delete thread:', error) + throw error + } + } + + // Message Management + async createMessage(message: ThreadMessage): Promise { + console.log('!!!Created message:', message) + return message + } + + async listMessages(threadId: string): Promise { + try { + if (!this.remoteApi) { + throw new Error('RemoteApi not initialized') + } + console.log('!!!Listing messages for thread:', threadId) + + // Fetch all conversation items from the API + const items = await this.remoteApi.getAllConversationItems(threadId) + + // Convert and combine conversation items to thread messages + const messages = combineConversationItemsToMessages(items, threadId) + + console.log('!!!Fetched messages:', messages) + return messages + } catch (error) { + console.error('Failed to list messages:', error) + return [] + } + } + + async modifyMessage(message: ThreadMessage): Promise { + console.log('!!!Modified message:', message) + return message + } + + async deleteMessage(threadId: string, messageId: string): Promise { + console.log('!!!Deleted message:', threadId, messageId) + } + + async getThreadAssistant(threadId: string): Promise { + console.log('!!!Getting assistant for thread:', threadId) + return { id: 'jan', name: 'Jan', model: { id: 'jan-v1-4b' } } + } + + async createThreadAssistant( + threadId: string, + assistant: ThreadAssistantInfo + ): Promise { + console.log('!!!Creating assistant for thread:', threadId, assistant) + return assistant + } + + async modifyThreadAssistant( + threadId: string, + assistant: ThreadAssistantInfo + ): Promise { + console.log('!!!Modifying assistant for thread:', threadId, assistant) + return assistant + } + + async getThreadAssistantInfo( + threadId: string + ): Promise { + console.log('!!!Getting assistant info for thread:', threadId) + return { id: 'jan', name: 'Jan', model: { id: 'jan-v1-4b' } } + } +} diff --git a/extensions-web/src/conversational-web/index.ts b/extensions-web/src/conversational-web/index.ts index 5f9ae260e..7bedfdd80 100644 --- a/extensions-web/src/conversational-web/index.ts +++ b/extensions-web/src/conversational-web/index.ts @@ -1,347 +1,3 @@ -/** - * Web Conversational Extension - * Implements thread and message management using IndexedDB - */ +import ConversationalExtensionWeb from './extension' -import { Thread, ThreadMessage, ConversationalExtension, ThreadAssistantInfo } from '@janhq/core' -import { getSharedDB } from '../shared/db' - -export default class ConversationalExtensionWeb extends ConversationalExtension { - private db: IDBDatabase | null = null - - async onLoad() { - console.log('Loading Web Conversational Extension') - this.db = await getSharedDB() - } - - onUnload() { - // Don't close shared DB, other extensions might be using it - this.db = null - } - - private ensureDB(): void { - if (!this.db) { - throw new Error('Database not initialized. Call onLoad() first.') - } - } - - // Thread Management - async listThreads(): Promise { - return this.getThreads() - } - - async getThreads(): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['threads'], 'readonly') - const store = transaction.objectStore('threads') - const request = store.getAll() - - request.onsuccess = () => { - const threads = request.result || [] - // Sort by updated desc (most recent first) - threads.sort((a, b) => (b.updated || 0) - (a.updated || 0)) - resolve(threads) - } - - request.onerror = () => { - reject(request.error) - } - }) - } - - async createThread(thread: Thread): Promise { - await this.saveThread(thread) - return thread - } - - async modifyThread(thread: Thread): Promise { - await this.saveThread(thread) - } - - async saveThread(thread: Thread): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['threads'], 'readwrite') - const store = transaction.objectStore('threads') - - const threadToStore = { - ...thread, - created: thread.created || Date.now() / 1000, - updated: Date.now() / 1000, - } - - const request = store.put(threadToStore) - - request.onsuccess = () => { - console.log('Thread saved:', thread.id) - resolve() - } - - request.onerror = () => { - console.error('Failed to save thread:', request.error) - reject(request.error) - } - }) - } - - async deleteThread(threadId: string): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['threads', 'messages'], 'readwrite') - const threadsStore = transaction.objectStore('threads') - const messagesStore = transaction.objectStore('messages') - - // Delete thread - const deleteThreadRequest = threadsStore.delete(threadId) - - // Delete all messages in the thread - const messageIndex = messagesStore.index('thread_id') - const messagesRequest = messageIndex.openCursor(IDBKeyRange.only(threadId)) - - messagesRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result - if (cursor) { - cursor.delete() - cursor.continue() - } - } - - transaction.oncomplete = () => { - console.log('Thread and messages deleted:', threadId) - resolve() - } - - transaction.onerror = () => { - console.error('Failed to delete thread:', transaction.error) - reject(transaction.error) - } - }) - } - - // Message Management - async createMessage(message: ThreadMessage): Promise { - await this.addNewMessage(message) - return message - } - - async listMessages(threadId: string): Promise { - return this.getAllMessages(threadId) - } - - async modifyMessage(message: ThreadMessage): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['messages'], 'readwrite') - const store = transaction.objectStore('messages') - - const messageToStore = { - ...message, - updated: Date.now() / 1000, - } - - const request = store.put(messageToStore) - - request.onsuccess = () => { - console.log('Message updated:', message.id) - resolve(message) - } - - request.onerror = () => { - console.error('Failed to update message:', request.error) - reject(request.error) - } - }) - } - - async deleteMessage(threadId: string, messageId: string): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['messages'], 'readwrite') - const store = transaction.objectStore('messages') - const request = store.delete(messageId) - - request.onsuccess = () => { - console.log('Message deleted:', messageId) - resolve() - } - - request.onerror = () => { - console.error('Failed to delete message:', request.error) - reject(request.error) - } - }) - } - - async addNewMessage(message: ThreadMessage): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['messages'], 'readwrite') - const store = transaction.objectStore('messages') - - const messageToStore = { - ...message, - created_at: message.created_at || Date.now() / 1000, - } - - const request = store.add(messageToStore) - - request.onsuccess = () => { - console.log('Message added:', message.id) - resolve() - } - - request.onerror = () => { - console.error('Failed to add message:', request.error) - reject(request.error) - } - }) - } - - async writeMessages(threadId: string, messages: ThreadMessage[]): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['messages'], 'readwrite') - const store = transaction.objectStore('messages') - - // First, delete existing messages for this thread - const index = store.index('thread_id') - const deleteRequest = index.openCursor(IDBKeyRange.only(threadId)) - - deleteRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result - if (cursor) { - cursor.delete() - cursor.continue() - } else { - // After deleting old messages, add new ones - const addPromises = messages.map(message => { - return new Promise((resolveAdd, rejectAdd) => { - const messageToStore = { - ...message, - thread_id: threadId, - created_at: message.created_at || Date.now() / 1000, - } - - const addRequest = store.add(messageToStore) - addRequest.onsuccess = () => resolveAdd() - addRequest.onerror = () => rejectAdd(addRequest.error) - }) - }) - - Promise.all(addPromises) - .then(() => { - console.log(`${messages.length} messages written for thread:`, threadId) - resolve() - }) - .catch(reject) - } - } - - deleteRequest.onerror = () => { - reject(deleteRequest.error) - } - }) - } - - async getAllMessages(threadId: string): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['messages'], 'readonly') - const store = transaction.objectStore('messages') - const index = store.index('thread_id') - const request = index.getAll(threadId) - - request.onsuccess = () => { - const messages = request.result || [] - // Sort by created_at asc (chronological order) - messages.sort((a, b) => (a.created_at || 0) - (b.created_at || 0)) - resolve(messages) - } - - request.onerror = () => { - reject(request.error) - } - }) - } - - // Thread Assistant Info (simplified - stored with thread) - async getThreadAssistant(threadId: string): Promise { - const info = await this.getThreadAssistantInfo(threadId) - if (!info) { - throw new Error(`Thread assistant info not found for thread ${threadId}`) - } - return info - } - - async createThreadAssistant(threadId: string, assistant: ThreadAssistantInfo): Promise { - await this.saveThreadAssistantInfo(threadId, assistant) - return assistant - } - - async modifyThreadAssistant(threadId: string, assistant: ThreadAssistantInfo): Promise { - await this.saveThreadAssistantInfo(threadId, assistant) - return assistant - } - - async saveThreadAssistantInfo(threadId: string, assistantInfo: ThreadAssistantInfo): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['threads'], 'readwrite') - const store = transaction.objectStore('threads') - - // Get existing thread and update with assistant info - const getRequest = store.get(threadId) - - getRequest.onsuccess = () => { - const thread = getRequest.result - if (!thread) { - reject(new Error(`Thread ${threadId} not found`)) - return - } - - const updatedThread = { - ...thread, - assistantInfo, - updated_at: Date.now() / 1000, - } - - const putRequest = store.put(updatedThread) - putRequest.onsuccess = () => resolve() - putRequest.onerror = () => reject(putRequest.error) - } - - getRequest.onerror = () => { - reject(getRequest.error) - } - }) - } - - async getThreadAssistantInfo(threadId: string): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['threads'], 'readonly') - const store = transaction.objectStore('threads') - const request = store.get(threadId) - - request.onsuccess = () => { - const thread = request.result - resolve(thread?.assistantInfo) - } - - request.onerror = () => { - reject(request.error) - } - }) - } -} \ No newline at end of file +export default ConversationalExtensionWeb diff --git a/extensions-web/src/conversational-web/types.ts b/extensions-web/src/conversational-web/types.ts new file mode 100644 index 000000000..a6057da5d --- /dev/null +++ b/extensions-web/src/conversational-web/types.ts @@ -0,0 +1,93 @@ +/** + * TypeScript Types for Conversational API + */ + +export interface PaginationParams { + limit?: number + after?: string + order?: 'asc' | 'desc' +} + +export interface PaginatedResponse { + data: T[] + has_more: boolean + object: 'list' + first_id?: string + last_id?: string +} + +export interface ConversationMetadata { + model_provider?: string + model_id?: string + is_favorite?: string +} + +export interface Conversation { + title?: string + metadata?: ConversationMetadata +} + +export interface ConversationResponse { + id: string + object: 'conversation' + title?: string + created_at: number + metadata: ConversationMetadata +} + +export type ListConversationsParams = PaginationParams +export type ListConversationsResponse = PaginatedResponse + +// Conversation Items types +export interface ConversationItemAnnotation { + end_index?: number + file_id?: string + index?: number + start_index?: number + text?: string + type?: string + url?: string +} + +export interface ConversationItemContent { + file?: { + file_id?: string + mime_type?: string + name?: string + size?: number + } + finish_reason?: string + image?: { + detail?: string + file_id?: string + url?: string + } + input_text?: string + output_text?: { + annotations?: ConversationItemAnnotation[] + text?: string + } + reasoning_content?: string + text?: { + value?: string + } + type?: string +} + +export interface ConversationItem { + content?: ConversationItemContent[] + created_at: number + id: string + object: string + role: string + status?: string + type?: string +} + +export interface ListConversationItemsParams extends PaginationParams { + conversation_id: string +} + +export interface ListConversationItemsResponse extends PaginatedResponse { + total?: number +} diff --git a/extensions-web/src/conversational-web/utils.ts b/extensions-web/src/conversational-web/utils.ts new file mode 100644 index 000000000..6448d9f4d --- /dev/null +++ b/extensions-web/src/conversational-web/utils.ts @@ -0,0 +1,247 @@ +import { Thread, ThreadAssistantInfo, ThreadMessage, ContentType } from '@janhq/core' +import { Conversation, ConversationResponse, ConversationItem } from './types' +import { DEFAULT_ASSISTANT } from './const' + +export class ObjectParser { + static threadToConversation(thread: Thread): Conversation { + const modelName = thread.assistants?.[0]?.model?.id || undefined + const modelProvider = thread.assistants?.[0]?.model?.engine || undefined + const isFavorite = thread.metadata?.is_favorite?.toString() || 'false' + let metadata = {} + if (modelName && modelProvider) { + metadata = { + model_id: modelName, + model_provider: modelProvider, + is_favorite: isFavorite, + } + } + return { + title: shortenConversationTitle(thread.title), + metadata, + } + } + + static conversationToThread(conversation: ConversationResponse): Thread { + const assistants: ThreadAssistantInfo[] = [] + if ( + conversation.metadata?.model_id && + conversation.metadata?.model_provider + ) { + assistants.push({ + ...DEFAULT_ASSISTANT, + model: { + id: conversation.metadata.model_id, + engine: conversation.metadata.model_provider, + }, + }) + } else { + assistants.push({ + ...DEFAULT_ASSISTANT, + model: { + id: 'jan-v1-4b', + engine: 'jan', + }, + }) + } + + const isFavorite = conversation.metadata?.is_favorite === 'true' + return { + id: conversation.id, + title: conversation.title || '', + assistants, + created: conversation.created_at, + updated: conversation.created_at, + model: { + id: conversation.metadata.model_id, + provider: conversation.metadata.model_provider, + }, + isFavorite, + metadata: { is_favorite: isFavorite }, + } as unknown as Thread + } + + static conversationItemToThreadMessage( + item: ConversationItem, + threadId: string + ): ThreadMessage { + // Extract text content and metadata from the item + let textContent = '' + let reasoningContent = '' + const imageUrls: string[] = [] + let toolCalls: any[] = [] + let finishReason = '' + + if (item.content && item.content.length > 0) { + for (const content of item.content) { + // Handle text content + if (content.text?.value) { + textContent = content.text.value + } + // Handle output_text for assistant messages + if (content.output_text?.text) { + textContent = content.output_text.text + } + // Handle reasoning content + if (content.reasoning_content) { + reasoningContent = content.reasoning_content + } + // Handle image content + if (content.image?.url) { + imageUrls.push(content.image.url) + } + // Extract finish_reason + if (content.finish_reason) { + finishReason = content.finish_reason + } + } + } + + // Handle tool calls parsing for assistant messages + if (item.role === 'assistant' && finishReason === 'tool_calls') { + try { + // Tool calls are embedded as JSON string in textContent + const toolCallMatch = textContent.match(/\[.*\]/) + if (toolCallMatch) { + const toolCallsData = JSON.parse(toolCallMatch[0]) + toolCalls = toolCallsData.map((toolCall: any) => ({ + tool: { + id: toolCall.id || 'unknown', + function: { + name: toolCall.function?.name || 'unknown', + arguments: toolCall.function?.arguments || '{}' + }, + type: toolCall.type || 'function' + }, + response: { + error: '', + content: [] + }, + state: 'ready' + })) + // Remove tool calls JSON from text content, keep only reasoning + textContent = '' + } + } catch (error) { + console.error('Failed to parse tool calls:', error) + } + } + + // Format final content with reasoning if present + let finalTextValue = '' + if (reasoningContent) { + finalTextValue = `${reasoningContent}` + } + if (textContent) { + finalTextValue += textContent + } + + // Build content array for ThreadMessage + const messageContent: any[] = [ + { + type: ContentType.Text, + text: { + value: finalTextValue || '', + annotations: [], + }, + }, + ] + + // Add image content if present + for (const imageUrl of imageUrls) { + messageContent.push({ + type: 'image_url' as ContentType, + image_url: { + url: imageUrl, + }, + }) + } + + // Build metadata + const metadata: any = {} + if (toolCalls.length > 0) { + metadata.tool_calls = toolCalls + } + + // Map status from server format to frontend format + const mappedStatus = item.status === 'completed' ? 'ready' : item.status || 'ready' + + return { + type: 'text', + id: item.id, + object: 'thread.message', + thread_id: threadId, + role: item.role as 'user' | 'assistant', + content: messageContent, + created_at: item.created_at * 1000, // Convert to milliseconds + completed_at: 0, + status: mappedStatus, + metadata, + } as ThreadMessage + } +} + +const shortenConversationTitle = (title: string): string => { + const maxLength = 50 + return title.length <= maxLength ? title : title.substring(0, maxLength) +} + +export const getDefaultAssistant = ( + assistant: ThreadAssistantInfo +): ThreadAssistantInfo => { + return { ...assistant, instructions: undefined } +} + +/** + * Utility function to combine conversation items into thread messages + * Handles tool response merging and message consolidation + */ +export const combineConversationItemsToMessages = ( + items: ConversationItem[], + threadId: string +): ThreadMessage[] => { + const messages: ThreadMessage[] = [] + const toolResponseMap = new Map() + + // First pass: collect tool responses + for (const item of items) { + if (item.role === 'tool') { + const toolContent = item.content?.[0]?.text?.value || '' + toolResponseMap.set(item.id, { + error: '', + content: [ + { + type: 'text', + text: toolContent + } + ] + }) + } + } + + // Second pass: build messages and merge tool responses + for (const item of items) { + // Skip tool messages as they will be merged into assistant messages + if (item.role === 'tool') { + continue + } + + const message = ObjectParser.conversationItemToThreadMessage(item, threadId) + + // If this is an assistant message with tool calls, merge tool responses + if (message.role === 'assistant' && message.metadata?.tool_calls && Array.isArray(message.metadata.tool_calls)) { + const toolCalls = message.metadata.tool_calls as any[] + let toolResponseIndex = 0 + + for (const [responseId, responseData] of toolResponseMap.entries()) { + if (toolResponseIndex < toolCalls.length) { + toolCalls[toolResponseIndex].response = responseData + toolResponseIndex++ + } + } + } + + messages.push(message) + } + + return messages +} diff --git a/extensions-web/src/jan-provider-web/api.ts b/extensions-web/src/jan-provider-web/api.ts index 16c4dc70e..436ee06b6 100644 --- a/extensions-web/src/jan-provider-web/api.ts +++ b/extensions-web/src/jan-provider-web/api.ts @@ -24,6 +24,7 @@ export interface JanChatMessage { export interface JanChatCompletionRequest { model: string messages: JanChatMessage[] + conversation_id?: string temperature?: number max_tokens?: number top_p?: number @@ -93,7 +94,7 @@ export class JanApiClient { janProviderStore.clearError() const response = await this.authService.makeAuthenticatedRequest( - `${JAN_API_BASE}/models` + `${JAN_API_BASE}/conv/models` ) const models = response.data || [] @@ -115,12 +116,16 @@ export class JanApiClient { janProviderStore.clearError() return await this.authService.makeAuthenticatedRequest( - `${JAN_API_BASE}/chat/completions`, + `${JAN_API_BASE}/conv/chat/completions`, { method: 'POST', body: JSON.stringify({ ...request, stream: false, + store: true, + store_reasoning: true, + conversation: request.conversation_id, + conversation_id: undefined, }), } ) @@ -142,7 +147,7 @@ export class JanApiClient { const authHeader = await this.authService.getAuthHeader() - const response = await fetch(`${JAN_API_BASE}/chat/completions`, { + const response = await fetch(`${JAN_API_BASE}/conv/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -151,6 +156,10 @@ export class JanApiClient { body: JSON.stringify({ ...request, stream: true, + store: true, + store_reasoning: true, + conversation: request.conversation_id, + conversation_id: undefined, }), }) diff --git a/extensions-web/src/jan-provider-web/provider.ts b/extensions-web/src/jan-provider-web/provider.ts index 5c829ed34..216da66c9 100644 --- a/extensions-web/src/jan-provider-web/provider.ts +++ b/extensions-web/src/jan-provider-web/provider.ts @@ -144,6 +144,7 @@ export default class JanProviderWeb extends AIEngine { const janRequest = { model: modelId, messages: janMessages, + conversation_id: opts.thread_id, temperature: opts.temperature ?? undefined, max_tokens: opts.n_predict ?? undefined, top_p: opts.top_p ?? undefined, diff --git a/extensions-web/src/shared/auth/api.ts b/extensions-web/src/shared/auth/api.ts index 1bfdae3c7..61163984b 100644 --- a/extensions-web/src/shared/auth/api.ts +++ b/extensions-web/src/shared/auth/api.ts @@ -13,7 +13,7 @@ declare const JAN_API_BASE: string */ export async function logoutUser(): Promise { const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.LOGOUT}`, { - method: 'POST', + method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json', diff --git a/extensions-web/src/shared/auth/broadcast.ts b/extensions-web/src/shared/auth/broadcast.ts index 8a2d316d4..37062ee76 100644 --- a/extensions-web/src/shared/auth/broadcast.ts +++ b/extensions-web/src/shared/auth/broadcast.ts @@ -1,16 +1,69 @@ /** * Authentication Broadcast Channel Handler - * Manages cross-tab communication for auth state changes + * Manages both cross-tab and same-tab communication for auth state changes + * + * Architecture: + * - BroadcastChannel API: For cross-tab communication + * - LocalBroadcastChannel: For same-tab communication via CustomEvents */ -import { AUTH_BROADCAST_CHANNEL, AUTH_EVENTS } from './const' +import { AUTH_BROADCAST_CHANNEL, AUTH_EVENT_NAME, AUTH_EVENTS } from './const' import type { AuthBroadcastMessage } from './types' +/** + * LocalBroadcastChannel - Handles same-tab communication via custom events + * Mimics the BroadcastChannel API but uses CustomEvents internally + * This is needed because BroadcastChannel doesn't deliver messages to the same context + */ +class LocalBroadcastChannel { + private eventName: string + + constructor(eventName: string) { + this.eventName = eventName + } + + /** + * Post a message via custom event (same-tab only) + */ + postMessage(data: any): void { + const customEvent = new CustomEvent(this.eventName, { + detail: data + }) + window.dispatchEvent(customEvent) + } + + /** + * Listen for custom events + */ + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void { + const customEventListener = (event: Event) => { + const customEvent = event as CustomEvent + // Convert CustomEvent to MessageEvent format for consistency + const messageEvent = { + data: customEvent.detail + } as MessageEvent + listener(messageEvent) + } + window.addEventListener(this.eventName, customEventListener) + } + + /** + * Remove custom event listener + */ + removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void { + // Note: This won't work perfectly due to function reference issues + // In practice, we handle this with cleanup functions in AuthBroadcast + window.removeEventListener(this.eventName, listener as any) + } +} + export class AuthBroadcast { private broadcastChannel: BroadcastChannel | null = null + private localBroadcastChannel: LocalBroadcastChannel constructor() { this.setupBroadcastChannel() + this.localBroadcastChannel = new LocalBroadcastChannel(AUTH_EVENT_NAME) } /** @@ -27,17 +80,22 @@ export class AuthBroadcast { } /** - * Broadcast auth event to other tabs + * Broadcast auth event to all tabs (including current) */ broadcastEvent(type: AuthBroadcastMessage): void { + const message = { type } + + // Broadcast to other tabs via BroadcastChannel if (this.broadcastChannel) { try { - const message = { type } this.broadcastChannel.postMessage(message) } catch (error) { console.warn('Failed to broadcast auth event:', error) } } + + // Also broadcast to same tab via LocalBroadcastChannel + this.localBroadcastChannel.postMessage(message) } /** @@ -55,22 +113,41 @@ export class AuthBroadcast { } /** - * Subscribe to auth events + * Subscribe to auth events (from all sources) */ onAuthEvent( listener: (event: MessageEvent<{ type: AuthBroadcastMessage }>) => void ): () => void { + const cleanupFunctions: Array<() => void> = [] + + // Subscribe to BroadcastChannel for cross-tab events if (this.broadcastChannel) { this.broadcastChannel.addEventListener('message', listener) - - // Return cleanup function - return () => { + cleanupFunctions.push(() => { this.broadcastChannel?.removeEventListener('message', listener) - } + }) } - // Return no-op cleanup if no broadcast channel - return () => {} + // Subscribe to LocalBroadcastChannel for same-tab events + // We need to keep track of the actual listener function for proper cleanup + const localEventListener = (event: Event) => { + const customEvent = event as CustomEvent + const messageEvent = { + data: customEvent.detail + } as MessageEvent<{ type: AuthBroadcastMessage }> + listener(messageEvent) + } + + // Add listener directly to window since LocalBroadcastChannel's removeEventListener has limitations + window.addEventListener(AUTH_EVENT_NAME, localEventListener) + cleanupFunctions.push(() => { + window.removeEventListener(AUTH_EVENT_NAME, localEventListener) + }) + + // Return combined cleanup function + return () => { + cleanupFunctions.forEach(cleanup => cleanup()) + } } /** diff --git a/extensions-web/src/shared/auth/const.ts b/extensions-web/src/shared/auth/const.ts index efd5ad196..5b086e999 100644 --- a/extensions-web/src/shared/auth/const.ts +++ b/extensions-web/src/shared/auth/const.ts @@ -19,9 +19,14 @@ export const AUTH_ENDPOINTS = { // Token expiry buffer export const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before expiry -// Broadcast channel for cross-tab communication +// Broadcast channel name for cross-tab communication (BroadcastChannel API) +// Used to sync auth state between different browser tabs export const AUTH_BROADCAST_CHANNEL = 'jan_auth_channel' +// Custom event name for same-tab communication (window.dispatchEvent) +// Used to notify components within the same tab about auth state changes +export const AUTH_EVENT_NAME = 'jan-auth-event' + // Auth events export const AUTH_EVENTS = { LOGIN: 'auth:login', diff --git a/extensions-web/src/shared/auth/service.ts b/extensions-web/src/shared/auth/service.ts index c9a15bb33..ecedb4d62 100644 --- a/extensions-web/src/shared/auth/service.ts +++ b/extensions-web/src/shared/auth/service.ts @@ -158,7 +158,7 @@ export class JanAuthService { /** * Get current authenticated user */ - async getCurrentUser(): Promise { + async getCurrentUser(forceRefresh: boolean = false): Promise { await this.ensureInitialized() const authType = this.getAuthState() @@ -166,7 +166,8 @@ export class JanAuthService { return null } - if (this.currentUser) { + // Force refresh if requested or if cache is cleared + if (!forceRefresh && this.currentUser) { return this.currentUser } @@ -200,6 +201,9 @@ export class JanAuthService { this.clearAuthState() + // Ensure guest access after logout + await this.ensureGuestAccess() + this.authBroadcast.broadcastLogout() if (window.location.pathname !== '/') { @@ -208,6 +212,8 @@ export class JanAuthService { } catch (error) { console.error('Logout failed:', error) this.clearAuthState() + // Try to ensure guest access even on error + this.ensureGuestAccess().catch(console.error) } } @@ -359,8 +365,12 @@ export class JanAuthService { this.authBroadcast.onAuthEvent((event) => { switch (event.data.type) { case AUTH_EVENTS.LOGIN: - // Another tab logged in, refresh our state - this.initialize().catch(console.error) + // Another tab logged in, clear cached data to force refresh + // Clear current user cache so next getCurrentUser() call fetches fresh data + this.currentUser = null + // Clear token cache so next getValidAccessToken() call refreshes + this.accessToken = null + this.tokenExpiryTime = 0 break case AUTH_EVENTS.LOGOUT: diff --git a/extensions-web/src/shared/db.ts b/extensions-web/src/shared/db.ts deleted file mode 100644 index 175d6a2b5..000000000 --- a/extensions-web/src/shared/db.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Shared IndexedDB utilities for web extensions - */ - -import type { IndexedDBConfig } from '../types' - -/** - * Default database configuration for Jan web extensions - */ -const DEFAULT_DB_CONFIG: IndexedDBConfig = { - dbName: 'jan-web-db', - version: 1, - stores: [ - { - name: 'assistants', - keyPath: 'id', - indexes: [ - { name: 'name', keyPath: 'name' }, - { name: 'created_at', keyPath: 'created_at' } - ] - }, - { - name: 'threads', - keyPath: 'id', - indexes: [ - { name: 'title', keyPath: 'title' }, - { name: 'created_at', keyPath: 'created_at' }, - { name: 'updated_at', keyPath: 'updated_at' } - ] - }, - { - name: 'messages', - keyPath: 'id', - indexes: [ - { name: 'thread_id', keyPath: 'thread_id' }, - { name: 'created_at', keyPath: 'created_at' } - ] - } - ] -} - -/** - * Shared IndexedDB instance - */ -let sharedDB: IDBDatabase | null = null - -/** - * Get or create the shared IndexedDB instance - */ -export const getSharedDB = async (config: IndexedDBConfig = DEFAULT_DB_CONFIG): Promise => { - if (sharedDB && sharedDB.name === config.dbName) { - return sharedDB - } - - return new Promise((resolve, reject) => { - const request = indexedDB.open(config.dbName, config.version) - - request.onerror = () => { - reject(new Error(`Failed to open database: ${request.error?.message}`)) - } - - request.onsuccess = () => { - sharedDB = request.result - resolve(sharedDB) - } - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result - - // Create object stores - for (const store of config.stores) { - let objectStore: IDBObjectStore - - if (db.objectStoreNames.contains(store.name)) { - // Store exists, might need to update indexes - continue - } else { - // Create new store - objectStore = db.createObjectStore(store.name, { keyPath: store.keyPath }) - } - - // Create indexes - if (store.indexes) { - for (const index of store.indexes) { - try { - objectStore.createIndex(index.name, index.keyPath, { unique: index.unique || false }) - } catch (error) { - // Index might already exist, ignore - } - } - } - } - } - }) -} - -/** - * Close the shared database connection - */ -export const closeSharedDB = () => { - if (sharedDB) { - sharedDB.close() - sharedDB = null - } -} \ No newline at end of file diff --git a/extensions-web/src/shared/index.ts b/extensions-web/src/shared/index.ts index 447e6e8e1..f140b2ec7 100644 --- a/extensions-web/src/shared/index.ts +++ b/extensions-web/src/shared/index.ts @@ -1,3 +1 @@ -export { getSharedDB } from './db' - export * from './auth' diff --git a/web-app/src/hooks/useAppState.ts b/web-app/src/hooks/useAppState.ts index 0ed9491d7..59e2e6dda 100644 --- a/web-app/src/hooks/useAppState.ts +++ b/web-app/src/hooks/useAppState.ts @@ -39,6 +39,7 @@ type AppState = { setAbortController: (threadId: string, controller: AbortController) => void updateTokenSpeed: (message: ThreadMessage, increment?: number) => void resetTokenSpeed: () => void + clearAppState: () => void setOutOfContextDialog: (show: boolean) => void setCancelToolCall: (cancel: (() => void) | undefined) => void setErrorMessage: (error: AppErrorMessage | undefined) => void @@ -128,6 +129,16 @@ export const useAppState = create()((set) => ({ set({ tokenSpeed: undefined, }), + clearAppState: () => + set({ + streamingContent: undefined, + abortControllers: {}, + tokenSpeed: undefined, + currentToolCall: undefined, + cancelToolCall: undefined, + errorMessage: undefined, + showOutOfContextDialog: false, + }), setOutOfContextDialog: (show) => { set(() => ({ showOutOfContextDialog: show, diff --git a/web-app/src/hooks/useAuth.ts b/web-app/src/hooks/useAuth.ts index 36c0a5e2f..7bca3b8f2 100644 --- a/web-app/src/hooks/useAuth.ts +++ b/web-app/src/hooks/useAuth.ts @@ -33,8 +33,8 @@ interface AuthState { // Auth actions logout: () => Promise - getCurrentUser: () => Promise - loadAuthState: () => Promise + getCurrentUser: (forceRefresh?: boolean) => Promise + loadAuthState: (forceRefresh?: boolean) => Promise subscribeToAuthEvents: (callback: (event: MessageEvent) => void) => () => void // Platform feature check @@ -106,28 +106,34 @@ const useAuthStore = create()((set, get) => ({ }, logout: async () => { - const { authService, isAuthenticationEnabled } = get() + const { authService, isAuthenticationEnabled, loadAuthState } = get() if (!isAuthenticationEnabled || !authService) { throw new Error('Authentication not available on this platform') } - await authService.logout() + try { + await authService.logout() - // Update state after logout - set({ - user: null, - isAuthenticated: false, - }) + // Force reload auth state after logout to ensure consistency + await loadAuthState() + } catch (error) { + console.error('Logout failed:', error) + // Still update local state even if logout call failed + set({ + user: null, + isAuthenticated: false, + }) + } }, - getCurrentUser: async (): Promise => { + getCurrentUser: async (forceRefresh: boolean = false): Promise => { const { authService, isAuthenticationEnabled } = get() if (!isAuthenticationEnabled || !authService) { return null } try { - const profile = await authService.getCurrentUser() + const profile = await authService.getCurrentUser(forceRefresh) set({ user: profile, isAuthenticated: profile !== null, @@ -139,7 +145,7 @@ const useAuthStore = create()((set, get) => ({ } }, - loadAuthState: async () => { + loadAuthState: async (forceRefresh: boolean = false) => { const { authService, isAuthenticationEnabled } = get() if (!isAuthenticationEnabled || !authService) { set({ isLoading: false }) @@ -154,7 +160,7 @@ const useAuthStore = create()((set, get) => ({ // Load user profile if authenticated if (isAuth) { - const profile = await authService.getCurrentUser() + const profile = await authService.getCurrentUser(forceRefresh) set({ user: profile, isAuthenticated: profile !== null, diff --git a/web-app/src/hooks/useMessages.ts b/web-app/src/hooks/useMessages.ts index fc9dcf793..8c011a900 100644 --- a/web-app/src/hooks/useMessages.ts +++ b/web-app/src/hooks/useMessages.ts @@ -9,6 +9,7 @@ type MessageState = { setMessages: (threadId: string, messages: ThreadMessage[]) => void addMessage: (message: ThreadMessage) => void deleteMessage: (threadId: string, messageId: string) => void + clearAllMessages: () => void } export const useMessages = create()((set, get) => ({ @@ -63,4 +64,7 @@ export const useMessages = create()((set, get) => ({ }, })) }, + clearAllMessages: () => { + set({ messages: {} }) + }, })) diff --git a/web-app/src/hooks/useThreads.ts b/web-app/src/hooks/useThreads.ts index b57c0c08a..cce11c027 100644 --- a/web-app/src/hooks/useThreads.ts +++ b/web-app/src/hooks/useThreads.ts @@ -14,6 +14,7 @@ type ThreadState = { deleteThread: (threadId: string) => void renameThread: (threadId: string, newTitle: string) => void deleteAllThreads: () => void + clearAllThreads: () => void unstarAllThreads: () => void setCurrentThreadId: (threadId?: string) => void createThread: ( @@ -160,6 +161,24 @@ export const useThreads = create()((set, get) => ({ } }) }, + clearAllThreads: () => { + set((state) => { + const allThreadIds = Object.keys(state.threads) + + // Delete all threads from server + allThreadIds.forEach((threadId) => { + getServiceHub().threads().deleteThread(threadId) + }) + + return { + threads: {}, + currentThreadId: undefined, + searchIndex: new Fzf([], { + selector: (item: Thread) => item.title, + }), + } + }) + }, unstarAllThreads: () => { set((state) => { const updatedThreads = Object.keys(state.threads).reduce( diff --git a/web-app/src/lib/completion.ts b/web-app/src/lib/completion.ts index 32f1bdc57..8348188f7 100644 --- a/web-app/src/lib/completion.ts +++ b/web-app/src/lib/completion.ts @@ -223,6 +223,7 @@ export const sendCompletion = async ( { messages: messages as chatCompletionRequestMessage[], model: thread.model?.id, + thread_id: thread.id, tools: normalizeTools(tools), tool_choice: tools.length ? 'auto' : undefined, stream: true, diff --git a/web-app/src/lib/platform/const.ts b/web-app/src/lib/platform/const.ts index ac623bd79..ab5a14e79 100644 --- a/web-app/src/lib/platform/const.ts +++ b/web-app/src/lib/platform/const.ts @@ -61,4 +61,7 @@ export const PlatformFeatures: Record = { // Alternate shortcut bindings - enabled for web only (to avoid browser conflicts) [PlatformFeature.ALTERNATE_SHORTCUT_BINDINGS]: !isPlatformTauri(), + + // First message persisted thread - enabled for web only + [PlatformFeature.FIRST_MESSAGE_PERSISTED_THREAD]: !isPlatformTauri(), } \ No newline at end of file diff --git a/web-app/src/lib/platform/types.ts b/web-app/src/lib/platform/types.ts index a644c09bd..d0152da32 100644 --- a/web-app/src/lib/platform/types.ts +++ b/web-app/src/lib/platform/types.ts @@ -63,4 +63,7 @@ export enum PlatformFeature { // Alternate keyboard shortcut bindings (web-only, to avoid browser conflicts) ALTERNATE_SHORTCUT_BINDINGS = 'alternateShortcutBindings', + + // First message persisted thread - web-only feature for storing first user message locally during thread creation + FIRST_MESSAGE_PERSISTED_THREAD = 'firstMessagePersistedThread', } diff --git a/web-app/src/providers/AuthProvider.tsx b/web-app/src/providers/AuthProvider.tsx index 6053e44f5..bfd0e279c 100644 --- a/web-app/src/providers/AuthProvider.tsx +++ b/web-app/src/providers/AuthProvider.tsx @@ -7,6 +7,12 @@ import { useEffect, useState, ReactNode } from 'react' import { PlatformFeature } from '@/lib/platform/types' import { PlatformFeatures } from '@/lib/platform/const' import { initializeAuthStore, getAuthStore } from '@/hooks/useAuth' +import { useThreads } from '@/hooks/useThreads' +import { useMessages } from '@/hooks/useMessages' +import { usePrompt } from '@/hooks/usePrompt' +import { useAppState } from '@/hooks/useAppState' +import { useNavigate } from '@tanstack/react-router' +import { useServiceHub } from '@/hooks/useServiceHub' interface AuthProviderProps { children: ReactNode @@ -14,11 +20,58 @@ interface AuthProviderProps { export function AuthProvider({ children }: AuthProviderProps) { const [isReady, setIsReady] = useState(false) + const navigate = useNavigate() + const serviceHub = useServiceHub() // Check if authentication is enabled for this platform const isAuthenticationEnabled = PlatformFeatures[PlatformFeature.AUTHENTICATION] + // Fetch user data when user logs in + const fetchUserData = async () => { + try { + const { setThreads } = useThreads.getState() + const { setMessages } = useMessages.getState() + + // Fetch threads first + const threads = await serviceHub.threads().fetchThreads() + setThreads(threads) + + // Fetch messages for each thread + const messagePromises = threads.map(async (thread) => { + const messages = await serviceHub.messages().fetchMessages(thread.id) + setMessages(thread.id, messages) + }) + + await Promise.all(messagePromises) + } catch (error) { + console.error('Failed to fetch user data:', error) + } + } + + // Reset all app data when user logs out + const resetAppData = () => { + // Clear all threads (including favorites) + const { clearAllThreads, setCurrentThreadId } = useThreads.getState() + clearAllThreads() + setCurrentThreadId(undefined) + + // Clear all messages + const { clearAllMessages } = useMessages.getState() + clearAllMessages() + + // Reset prompt + const { resetPrompt } = usePrompt.getState() + resetPrompt() + + // Clear app state (streaming, tokens, errors, etc.) + const { clearAppState } = useAppState.getState() + clearAppState() + + // Navigate back to home to ensure clean state + navigate({ to: '/', replace: true }) + } + useEffect(() => { if (!isAuthenticationEnabled) { setIsReady(true) @@ -27,12 +80,10 @@ export function AuthProvider({ children }: AuthProviderProps) { const initializeAuth = async () => { try { - console.log('Initializing auth service...') const { getSharedAuthService } = await import('@jan/extensions-web') const authService = getSharedAuthService() await initializeAuthStore(authService) - console.log('Auth service initialized successfully') setIsReady(true) } catch (error) { @@ -44,26 +95,51 @@ export function AuthProvider({ children }: AuthProviderProps) { initializeAuth() }, [isAuthenticationEnabled]) - // Listen for auth state changes across tabs + // Listen for auth state changes across tabs - setup after auth service is ready useEffect(() => { - if (!isAuthenticationEnabled) return + if (!isAuthenticationEnabled || !isReady) { + return + } const handleAuthEvent = (event: MessageEvent) => { // Listen for all auth events, not just login/logout if (event.data?.type?.startsWith('auth:')) { const authStore = getAuthStore() - authStore.loadAuthState() + + // Handle different auth events + if (event.data.type === 'auth:logout') { + // Reset all app data first on logout + resetAppData() + } + + // Reload auth state when auth events are received + // For login events, force refresh the user profile + if (event.data.type === 'auth:login') { + // Force refresh user profile on login events (forceRefresh=true) + authStore.loadAuthState(true).then(() => { + // Also fetch user data (threads, messages) + fetchUserData() + }) + } else { + // For other events, just reload auth state without forcing refresh + authStore.loadAuthState() + } } } // Use the auth store's subscribeToAuthEvents method const authStore = getAuthStore() + + if (!authStore.authService) { + return + } + const cleanupAuthListener = authStore.subscribeToAuthEvents(handleAuthEvent) return () => { cleanupAuthListener() } - }, [isAuthenticationEnabled]) + }, [isAuthenticationEnabled, isReady]) return <>{isReady && children} } diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index 49b1e20e6..80740935d 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -63,6 +63,16 @@ function ThreadDetail() { .fetchMessages(threadId) .then((fetchedMessages) => { if (fetchedMessages) { + // For web platform: preserve local messages if server fetch is empty but we have local messages + if (PlatformFeatures[PlatformFeature.FIRST_MESSAGE_PERSISTED_THREAD] && + fetchedMessages.length === 0 && + messages && + messages.length > 0) { + console.log('!!!Preserving local messages as server fetch is empty:', messages.length) + // Don't override local messages with empty server response + return + } + // Update the messages in the store setMessages(threadId, fetchedMessages) } diff --git a/web-app/vite.config.web.ts b/web-app/vite.config.web.ts index 3da738ae2..0f96b2213 100644 --- a/web-app/vite.config.web.ts +++ b/web-app/vite.config.web.ts @@ -49,6 +49,7 @@ export default defineConfig({ IS_WEB_APP: JSON.stringify(true), // Disable auto-updater on web (not applicable) AUTO_UPDATER_DISABLED: JSON.stringify(true), + IS_DEV: JSON.stringify(false), IS_MACOS: JSON.stringify(false), IS_WINDOWS: JSON.stringify(false), IS_LINUX: JSON.stringify(false),