feat: web remote conversation (#6554)
* feat: implement conversation endpoint * use conversation aware endpoint * fetch message correctly * preserve first message * fix logout * fix broadcast issue locally + auth not refreshing profile on other tabs+ clean up and sync messages * add is dev tag
This commit is contained in:
parent
292941e1d0
commit
df61546942
@ -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
|
||||
|
||||
160
extensions-web/src/conversational-web/api.ts
Normal file
160
extensions-web/src/conversational-web/api.ts
Normal file
@ -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<ConversationResponse> {
|
||||
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATIONS}`
|
||||
|
||||
return this.authService.makeAuthenticatedRequest<ConversationResponse>(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async updateConversation(
|
||||
conversationId: string,
|
||||
data: Conversation
|
||||
): Promise<ConversationResponse> {
|
||||
const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}`
|
||||
|
||||
return this.authService.makeAuthenticatedRequest<ConversationResponse>(
|
||||
url,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async listConversations(
|
||||
params?: ListConversationsParams
|
||||
): Promise<ListConversationsResponse> {
|
||||
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<ListConversationsResponse>(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic method to fetch all pages of paginated data
|
||||
*/
|
||||
async fetchAllPaginated<T>(
|
||||
fetchFn: (params: PaginationParams) => Promise<PaginatedResponse<T>>,
|
||||
initialParams?: Partial<PaginationParams>
|
||||
): Promise<T[]> {
|
||||
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<ConversationResponse[]> {
|
||||
return this.fetchAllPaginated<ConversationResponse>(
|
||||
(params) => this.listConversations(params)
|
||||
)
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
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<ListConversationItemsParams, 'conversation_id'>
|
||||
): Promise<ListConversationItemsResponse> {
|
||||
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<ListConversationItemsResponse>(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async getAllConversationItems(conversationId: string): Promise<ConversationItem[]> {
|
||||
return this.fetchAllPaginated<ConversationItem>(
|
||||
(params) => this.listConversationItems(conversationId, params),
|
||||
{ limit: 100, order: 'asc' }
|
||||
)
|
||||
}
|
||||
}
|
||||
17
extensions-web/src/conversational-web/const.ts
Normal file
17
extensions-web/src/conversational-web/const.ts
Normal file
@ -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,
|
||||
}
|
||||
154
extensions-web/src/conversational-web/extension.ts
Normal file
154
extensions-web/src/conversational-web/extension.ts
Normal file
@ -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<Thread[]> {
|
||||
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<Thread> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ThreadMessage> {
|
||||
console.log('!!!Created message:', message)
|
||||
return message
|
||||
}
|
||||
|
||||
async listMessages(threadId: string): Promise<ThreadMessage[]> {
|
||||
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<ThreadMessage> {
|
||||
console.log('!!!Modified message:', message)
|
||||
return message
|
||||
}
|
||||
|
||||
async deleteMessage(threadId: string, messageId: string): Promise<void> {
|
||||
console.log('!!!Deleted message:', threadId, messageId)
|
||||
}
|
||||
|
||||
async getThreadAssistant(threadId: string): Promise<ThreadAssistantInfo> {
|
||||
console.log('!!!Getting assistant for thread:', threadId)
|
||||
return { id: 'jan', name: 'Jan', model: { id: 'jan-v1-4b' } }
|
||||
}
|
||||
|
||||
async createThreadAssistant(
|
||||
threadId: string,
|
||||
assistant: ThreadAssistantInfo
|
||||
): Promise<ThreadAssistantInfo> {
|
||||
console.log('!!!Creating assistant for thread:', threadId, assistant)
|
||||
return assistant
|
||||
}
|
||||
|
||||
async modifyThreadAssistant(
|
||||
threadId: string,
|
||||
assistant: ThreadAssistantInfo
|
||||
): Promise<ThreadAssistantInfo> {
|
||||
console.log('!!!Modifying assistant for thread:', threadId, assistant)
|
||||
return assistant
|
||||
}
|
||||
|
||||
async getThreadAssistantInfo(
|
||||
threadId: string
|
||||
): Promise<ThreadAssistantInfo | undefined> {
|
||||
console.log('!!!Getting assistant info for thread:', threadId)
|
||||
return { id: 'jan', name: 'Jan', model: { id: 'jan-v1-4b' } }
|
||||
}
|
||||
}
|
||||
@ -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<Thread[]> {
|
||||
return this.getThreads()
|
||||
}
|
||||
|
||||
async getThreads(): Promise<Thread[]> {
|
||||
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<Thread> {
|
||||
await this.saveThread(thread)
|
||||
return thread
|
||||
}
|
||||
|
||||
async modifyThread(thread: Thread): Promise<void> {
|
||||
await this.saveThread(thread)
|
||||
}
|
||||
|
||||
async saveThread(thread: Thread): Promise<void> {
|
||||
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<void> {
|
||||
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<IDBCursorWithValue>).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<ThreadMessage> {
|
||||
await this.addNewMessage(message)
|
||||
return message
|
||||
}
|
||||
|
||||
async listMessages(threadId: string): Promise<ThreadMessage[]> {
|
||||
return this.getAllMessages(threadId)
|
||||
}
|
||||
|
||||
async modifyMessage(message: ThreadMessage): Promise<ThreadMessage> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<IDBCursorWithValue>).result
|
||||
if (cursor) {
|
||||
cursor.delete()
|
||||
cursor.continue()
|
||||
} else {
|
||||
// After deleting old messages, add new ones
|
||||
const addPromises = messages.map(message => {
|
||||
return new Promise<void>((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<ThreadMessage[]> {
|
||||
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<ThreadAssistantInfo> {
|
||||
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<ThreadAssistantInfo> {
|
||||
await this.saveThreadAssistantInfo(threadId, assistant)
|
||||
return assistant
|
||||
}
|
||||
|
||||
async modifyThreadAssistant(threadId: string, assistant: ThreadAssistantInfo): Promise<ThreadAssistantInfo> {
|
||||
await this.saveThreadAssistantInfo(threadId, assistant)
|
||||
return assistant
|
||||
}
|
||||
|
||||
async saveThreadAssistantInfo(threadId: string, assistantInfo: ThreadAssistantInfo): Promise<void> {
|
||||
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<ThreadAssistantInfo | undefined> {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
export default ConversationalExtensionWeb
|
||||
|
||||
93
extensions-web/src/conversational-web/types.ts
Normal file
93
extensions-web/src/conversational-web/types.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* TypeScript Types for Conversational API
|
||||
*/
|
||||
|
||||
export interface PaginationParams {
|
||||
limit?: number
|
||||
after?: string
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
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<ConversationResponse>
|
||||
|
||||
// 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<ConversationItem> {
|
||||
total?: number
|
||||
}
|
||||
247
extensions-web/src/conversational-web/utils.ts
Normal file
247
extensions-web/src/conversational-web/utils.ts
Normal file
@ -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 = `<think>${reasoningContent}</think>`
|
||||
}
|
||||
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<string, any>()
|
||||
|
||||
// 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
|
||||
}
|
||||
@ -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<JanModelsResponse>(
|
||||
`${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<JanChatCompletionResponse>(
|
||||
`${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,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -13,7 +13,7 @@ declare const JAN_API_BASE: string
|
||||
*/
|
||||
export async function logoutUser(): Promise<void> {
|
||||
const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.LOGOUT}`, {
|
||||
method: 'POST',
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -158,7 +158,7 @@ export class JanAuthService {
|
||||
/**
|
||||
* Get current authenticated user
|
||||
*/
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
async getCurrentUser(forceRefresh: boolean = false): Promise<User | null> {
|
||||
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:
|
||||
|
||||
@ -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<IDBDatabase> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,3 +1 @@
|
||||
export { getSharedDB } from './db'
|
||||
|
||||
export * from './auth'
|
||||
|
||||
@ -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<AppState>()((set) => ({
|
||||
set({
|
||||
tokenSpeed: undefined,
|
||||
}),
|
||||
clearAppState: () =>
|
||||
set({
|
||||
streamingContent: undefined,
|
||||
abortControllers: {},
|
||||
tokenSpeed: undefined,
|
||||
currentToolCall: undefined,
|
||||
cancelToolCall: undefined,
|
||||
errorMessage: undefined,
|
||||
showOutOfContextDialog: false,
|
||||
}),
|
||||
setOutOfContextDialog: (show) => {
|
||||
set(() => ({
|
||||
showOutOfContextDialog: show,
|
||||
|
||||
@ -33,8 +33,8 @@ interface AuthState {
|
||||
|
||||
// Auth actions
|
||||
logout: () => Promise<void>
|
||||
getCurrentUser: () => Promise<User | null>
|
||||
loadAuthState: () => Promise<void>
|
||||
getCurrentUser: (forceRefresh?: boolean) => Promise<User | null>
|
||||
loadAuthState: (forceRefresh?: boolean) => Promise<void>
|
||||
subscribeToAuthEvents: (callback: (event: MessageEvent) => void) => () => void
|
||||
|
||||
// Platform feature check
|
||||
@ -106,28 +106,34 @@ const useAuthStore = create<AuthState>()((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<User | null> => {
|
||||
getCurrentUser: async (forceRefresh: boolean = false): Promise<User | null> => {
|
||||
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<AuthState>()((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<AuthState>()((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,
|
||||
|
||||
@ -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<MessageState>()((set, get) => ({
|
||||
@ -63,4 +64,7 @@ export const useMessages = create<MessageState>()((set, get) => ({
|
||||
},
|
||||
}))
|
||||
},
|
||||
clearAllMessages: () => {
|
||||
set({ messages: {} })
|
||||
},
|
||||
}))
|
||||
|
||||
@ -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<ThreadState>()((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<Thread[]>([], {
|
||||
selector: (item: Thread) => item.title,
|
||||
}),
|
||||
}
|
||||
})
|
||||
},
|
||||
unstarAllThreads: () => {
|
||||
set((state) => {
|
||||
const updatedThreads = Object.keys(state.threads).reduce(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -61,4 +61,7 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
|
||||
|
||||
// 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(),
|
||||
}
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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}</>
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user