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:
Dinh Long Nguyen 2025-09-23 15:09:45 +07:00 committed by GitHub
parent 292941e1d0
commit df61546942
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 949 additions and 492 deletions

View File

@ -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

View 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' }
)
}
}

View 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,
}

View 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' } }
}
}

View File

@ -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

View 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
}

View 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
}

View File

@ -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,
}),
})

View File

@ -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,

View File

@ -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',

View File

@ -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())
}
}
/**

View File

@ -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',

View File

@ -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:

View File

@ -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
}
}

View File

@ -1,3 +1 @@
export { getSharedDB } from './db'
export * from './auth'

View File

@ -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,

View File

@ -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,

View File

@ -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: {} })
},
}))

View File

@ -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(

View File

@ -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,

View File

@ -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(),
}

View File

@ -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',
}

View File

@ -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}</>
}

View File

@ -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)
}

View File

@ -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),