Dinh Long Nguyen a30eb7f968
feat: Jan Web (reusing Jan Desktop UI) (#6298)
* add platform guards

* add service management

* fix types

* move to zustand for servicehub

* update App Updater

* update tauri missing move

* update app updater

* refactor: move PlatformFeatures to separate const file

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* change tauri fetch name

* update implementation

* update extension fetch

* make web version run properly

* disabled unused web settings

* fix all tests

* fix lint

* fix tests

* add mock for extension

* fix build

* update make and mise

* fix tsconfig for web-extensions

* fix loader type

* cleanup

* fix test

* update error handling + mcp should be working

* Update mcp init

* use separate is_web_app build property

* Remove fixed model catalog url

* fix additional tests

* fix download issue (event emitter not implemented correctly)

* Update Title html

* fix app logs

* update root tsx render timing

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-05 01:47:46 +07:00

347 lines
10 KiB
TypeScript

/**
* Web Conversational Extension
* Implements thread and message management using IndexedDB
*/
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)
}
})
}
}