diff --git a/README.md b/README.md index 26683bb..54caed3 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Project N.O.M.A.D. is now installed on your device! Open a browser and navigate ## How It Works From a technical standpoint, N.O.M.A.D. is primarily a management UI ("Command Center") and API that orchestrates a goodie basket of containerized offline archive tools and resources such as -[Kiwix](https://kiwix.org/), [OpenStreetMap](https://www.openstreetmap.org/), [Ollama](https://ollama.com/), [OpenWebUI](https://openwebui.com/), and more. +[Kiwix](https://kiwix.org/), [ProtoMaps](https://protomaps.com), [Ollama](https://ollama.com/), and more. By abstracting the installation of each of these awesome tools, N.O.M.A.D. makes getting your offline survival computer up and running a breeze! N.O.M.A.D. also includes some additional built-in handy tools, such as a ZIM library managment interface, calculators, and more. diff --git a/admin/app/controllers/chats_controller.ts b/admin/app/controllers/chats_controller.ts new file mode 100644 index 0000000..59739f6 --- /dev/null +++ b/admin/app/controllers/chats_controller.ts @@ -0,0 +1,85 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' +import { ChatService } from '#services/chat_service' +import { createSessionSchema, updateSessionSchema, addMessageSchema } from '#validators/chat' + +@inject() +export default class ChatsController { + constructor(private chatService: ChatService) {} + + async index({}: HttpContext) { + return await this.chatService.getAllSessions() + } + + async show({ params, response }: HttpContext) { + const sessionId = parseInt(params.id) + const session = await this.chatService.getSession(sessionId) + + if (!session) { + return response.status(404).json({ error: 'Session not found' }) + } + + return session + } + + async store({ request, response }: HttpContext) { + try { + const data = await request.validateUsing(createSessionSchema) + const session = await this.chatService.createSession(data.title, data.model) + return response.status(201).json(session) + } catch (error) { + return response.status(500).json({ + error: error instanceof Error ? error.message : 'Failed to create session', + }) + } + } + + async update({ params, request, response }: HttpContext) { + try { + const sessionId = parseInt(params.id) + const data = await request.validateUsing(updateSessionSchema) + const session = await this.chatService.updateSession(sessionId, data) + return session + } catch (error) { + return response.status(500).json({ + error: error instanceof Error ? error.message : 'Failed to update session', + }) + } + } + + async destroy({ params, response }: HttpContext) { + try { + const sessionId = parseInt(params.id) + await this.chatService.deleteSession(sessionId) + return response.status(204) + } catch (error) { + return response.status(500).json({ + error: error instanceof Error ? error.message : 'Failed to delete session', + }) + } + } + + async addMessage({ params, request, response }: HttpContext) { + try { + const sessionId = parseInt(params.id) + const data = await request.validateUsing(addMessageSchema) + const message = await this.chatService.addMessage(sessionId, data.role, data.content) + return response.status(201).json(message) + } catch (error) { + return response.status(500).json({ + error: error instanceof Error ? error.message : 'Failed to add message', + }) + } + } + + async destroyAll({ response }: HttpContext) { + try { + const result = await this.chatService.deleteAllSessions() + return response.status(200).json(result) + } catch (error) { + return response.status(500).json({ + error: error instanceof Error ? error.message : 'Failed to delete all sessions', + }) + } + } +} diff --git a/admin/app/controllers/ollama_controller.ts b/admin/app/controllers/ollama_controller.ts new file mode 100644 index 0000000..0148672 --- /dev/null +++ b/admin/app/controllers/ollama_controller.ts @@ -0,0 +1,92 @@ +import { OllamaService } from '#services/ollama_service' +import { RagService } from '#services/rag_service' +import { modelNameSchema } from '#validators/download' +import { chatSchema, getAvailableModelsSchema } from '#validators/ollama' +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' +import { SYSTEM_PROMPTS } from '../../constants/ollama.js' + +@inject() +export default class OllamaController { + constructor( + private ollamaService: OllamaService, + private ragService: RagService + ) {} + + async availableModels({ request }: HttpContext) { + const reqData = await request.validateUsing(getAvailableModelsSchema) + return await this.ollamaService.getAvailableModels({ + sort: reqData.sort, + recommendedOnly: reqData.recommendedOnly, + }) + } + + async chat({ request }: HttpContext) { + const reqData = await request.validateUsing(chatSchema) + + /**If there are no system messages in the chat + *(i.e. first message from the user)inject system prompts + **/ + const hasSystemMessage = reqData.messages.some((msg) => msg.role === 'system') + if (!hasSystemMessage) { + const systemPrompt = { + role: 'system' as const, + content: SYSTEM_PROMPTS.default, + } + reqData.messages.unshift(systemPrompt) + } + + // Get the last user message to use for RAG context retrieval + const lastUserMessage = [...reqData.messages].reverse().find((msg) => msg.role === 'user') + + if (lastUserMessage) { + // Search for relevant context in the knowledge base + const relevantDocs = await this.ragService.searchSimilarDocuments( + lastUserMessage.content, + 5, // Retrieve top 5 most relevant chunks + 0.7 // Minimum similarity score of 0.7 + ) + + // If relevant context is found, inject as a system message + if (relevantDocs.length > 0) { + const contextText = relevantDocs + .map((doc, idx) => `[Context ${idx + 1}]\n${doc.text}`) + .join('\n\n') + + const systemMessage = { + role: 'system' as const, + content: SYSTEM_PROMPTS.rag_context(contextText), + } + + // Insert system message at the beginning (after any existing system messages) + const firstNonSystemIndex = reqData.messages.findIndex((msg) => msg.role !== 'system') + const insertIndex = firstNonSystemIndex === -1 ? 0 : firstNonSystemIndex + reqData.messages.splice(insertIndex, 0, systemMessage) + } + } + + return await this.ollamaService.chat(reqData) + } + + async deleteModel({ request }: HttpContext) { + const reqData = await request.validateUsing(modelNameSchema) + await this.ollamaService.deleteModel(reqData.model) + return { + success: true, + message: `Model deleted: ${reqData.model}`, + } + } + + async dispatchModelDownload({ request }: HttpContext) { + const reqData = await request.validateUsing(modelNameSchema) + await this.ollamaService.dispatchModelDownload(reqData.model) + return { + success: true, + message: `Download job dispatched for model: ${reqData.model}`, + } + } + + async installedModels({}: HttpContext) { + return await this.ollamaService.getModels() + } +} diff --git a/admin/app/controllers/openwebui_controller.ts b/admin/app/controllers/openwebui_controller.ts deleted file mode 100644 index d1b9240..0000000 --- a/admin/app/controllers/openwebui_controller.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { OpenWebUIService } from '#services/openwebui_service' -import { modelNameSchema } from '#validators/download' -import { getAvailableModelsSchema } from '#validators/openwebui' -import { inject } from '@adonisjs/core' -import type { HttpContext } from '@adonisjs/core/http' - -@inject() -export default class OpenWebUIController { - constructor(private openWebUIService: OpenWebUIService) {} - - async models({ request }: HttpContext) { - const reqData = await request.validateUsing(getAvailableModelsSchema) - return await this.openWebUIService.getAvailableModels({ - sort: reqData.sort, - recommendedOnly: reqData.recommendedOnly, - }) - } - - async installedModels({}: HttpContext) { - return await this.openWebUIService.getInstalledModels() - } - - async deleteModel({ request }: HttpContext) { - const reqData = await request.validateUsing(modelNameSchema) - await this.openWebUIService.deleteModel(reqData.model) - return { - success: true, - message: `Model deleted: ${reqData.model}`, - } - } - - async dispatchModelDownload({ request }: HttpContext) { - const reqData = await request.validateUsing(modelNameSchema) - await this.openWebUIService.dispatchModelDownload(reqData.model) - return { - success: true, - message: `Download job dispatched for model: ${reqData.model}`, - } - } -} diff --git a/admin/app/controllers/rag_controller.ts b/admin/app/controllers/rag_controller.ts index c11a45c..73d6906 100644 --- a/admin/app/controllers/rag_controller.ts +++ b/admin/app/controllers/rag_controller.ts @@ -1,18 +1,41 @@ -import { cuid } from '@adonisjs/core/helpers' +import { RagService } from '#services/rag_service' +import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' import app from '@adonisjs/core/services/app' +import { randomBytes } from 'node:crypto' +import { sanitizeFilename } from '../utils/fs.js' + +@inject() +export default class RagController { + constructor(private ragService: RagService) {} -export default class RagsController { public async upload({ request, response }: HttpContext) { const uploadedFile = request.file('file') if (!uploadedFile) { return response.status(400).json({ error: 'No file uploaded' }) } - const fileName = `${cuid()}.${uploadedFile.extname}` + const randomSuffix = randomBytes(6).toString('hex') + const sanitizedName = sanitizeFilename(uploadedFile.clientName) + + const fileName = `${sanitizedName}-${randomSuffix}.${uploadedFile.extname || 'txt'}` + const fullPath = app.makePath('storage/uploads', fileName) await uploadedFile.move(app.makePath('storage/uploads'), { name: fileName, }) + + // Don't await this - process in background + this.ragService.processAndEmbedFile(fullPath) + + return response.status(200).json({ + message: 'File has been uploaded and queued for processing.', + file_path: `/uploads/${fileName}`, + }) + } + + public async getStoredFiles({ response }: HttpContext) { + const files = await this.ragService.getStoredFiles() + return response.status(200).json({ files }) } } diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index ea734b6..609f684 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -1,6 +1,6 @@ import { BenchmarkService } from '#services/benchmark_service'; import { MapService } from '#services/map_service'; -import { OpenWebUIService } from '#services/openwebui_service'; +import { OllamaService } from '#services/ollama_service'; import { SystemService } from '#services/system_service'; import { inject } from '@adonisjs/core'; import type { HttpContext } from '@adonisjs/core/http' @@ -10,8 +10,8 @@ export default class SettingsController { constructor( private systemService: SystemService, private mapService: MapService, - private openWebUIService: OpenWebUIService, - private benchmarkService: BenchmarkService + private benchmarkService: BenchmarkService, + private ollamaService: OllamaService ) { } async system({ inertia }: HttpContext) { @@ -48,8 +48,8 @@ export default class SettingsController { } async models({ inertia }: HttpContext) { - const availableModels = await this.openWebUIService.getAvailableModels(); - const installedModels = await this.openWebUIService.getInstalledModels(); + const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false }); + const installedModels = await this.ollamaService.getModels(); return inertia.render('settings/models', { models: { availableModels: availableModels || [], diff --git a/admin/app/jobs/download_model_job.ts b/admin/app/jobs/download_model_job.ts index e634e2a..2706887 100644 --- a/admin/app/jobs/download_model_job.ts +++ b/admin/app/jobs/download_model_job.ts @@ -1,9 +1,8 @@ import { Job } from 'bullmq' import { QueueService } from '#services/queue_service' -import { OpenWebUIService } from '#services/openwebui_service' import { createHash } from 'crypto' import logger from '@adonisjs/core/services/logger' -import { DockerService } from '#services/docker_service' +import { OllamaService } from '#services/ollama_service' export interface DownloadModelJobParams { modelName: string @@ -27,26 +26,23 @@ export class DownloadModelJob { logger.info(`[DownloadModelJob] Attempting to download model: ${modelName}`) - // Check if OpenWebUI/Ollama services are ready - const dockerService = new DockerService() - const openWebUIService = new OpenWebUIService(dockerService) + const ollamaService = new OllamaService() - // Use getInstalledModels to check if the service is ready // Even if no models are installed, this should return an empty array if ready - const existingModels = await openWebUIService.getInstalledModels() + const existingModels = await ollamaService.getModels() if (!existingModels) { logger.warn( - `[DownloadModelJob] OpenWebUI service not ready yet for model ${modelName}. Will retry...` + `[DownloadModelJob] Ollama service not ready yet for model ${modelName}. Will retry...` ) - throw new Error('OpenWebUI service not ready yet') + throw new Error('Ollama service not ready yet') } logger.info( - `[DownloadModelJob] OpenWebUI service is ready. Initiating download for ${modelName}` + `[DownloadModelJob] Ollama service is ready. Initiating download for ${modelName}` ) // Services are ready, initiate the download with progress tracking - const result = await openWebUIService._downloadModel(modelName, (progress) => { + const result = await ollamaService._downloadModel(modelName, (progress) => { // Update job progress in BullMQ const progressData = { status: progress.status, diff --git a/admin/app/models/chat_message.ts b/admin/app/models/chat_message.ts new file mode 100644 index 0000000..da93e00 --- /dev/null +++ b/admin/app/models/chat_message.ts @@ -0,0 +1,29 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, belongsTo, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import ChatSession from './chat_session.js' + +export default class ChatMessage extends BaseModel { + static namingStrategy = new SnakeCaseNamingStrategy() + + @column({ isPrimary: true }) + declare id: number + + @column() + declare session_id: number + + @column() + declare role: 'system' | 'user' | 'assistant' + + @column() + declare content: string + + @belongsTo(() => ChatSession, { foreignKey: 'id', localKey: 'session_id' }) + declare session: BelongsTo + + @column.dateTime({ autoCreate: true }) + declare created_at: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updated_at: DateTime +} diff --git a/admin/app/models/chat_session.ts b/admin/app/models/chat_session.ts new file mode 100644 index 0000000..f63638a --- /dev/null +++ b/admin/app/models/chat_session.ts @@ -0,0 +1,29 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, hasMany, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm' +import type { HasMany } from '@adonisjs/lucid/types/relations' +import ChatMessage from './chat_message.js' + +export default class ChatSession extends BaseModel { + static namingStrategy = new SnakeCaseNamingStrategy() + + @column({ isPrimary: true }) + declare id: number + + @column() + declare title: string + + @column() + declare model: string | null + + @hasMany(() => ChatMessage, { + foreignKey: 'session_id', + localKey: 'id', + }) + declare messages: HasMany + + @column.dateTime({ autoCreate: true }) + declare created_at: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updated_at: DateTime +} \ No newline at end of file diff --git a/admin/app/services/benchmark_service.ts b/admin/app/services/benchmark_service.ts index b7ac41c..fcab1fa 100644 --- a/admin/app/services/benchmark_service.ts +++ b/admin/app/services/benchmark_service.ts @@ -426,8 +426,8 @@ export class BenchmarkService { } // Check if the benchmark model is available, pull if not - const openWebUIService = new (await import('./openwebui_service.js')).OpenWebUIService(this.dockerService) - const modelResponse = await openWebUIService.downloadModelSync(AI_BENCHMARK_MODEL) + const ollamaService = new (await import('./ollama_service.js')).OllamaService() + const modelResponse = await ollamaService.downloadModelSync(AI_BENCHMARK_MODEL) if (!modelResponse.success) { throw new Error(`Model does not exist and failed to download: ${modelResponse.message}`) } diff --git a/admin/app/services/chat_service.ts b/admin/app/services/chat_service.ts new file mode 100644 index 0000000..3a8a700 --- /dev/null +++ b/admin/app/services/chat_service.ts @@ -0,0 +1,181 @@ +import ChatSession from '#models/chat_session' +import ChatMessage from '#models/chat_message' +import logger from '@adonisjs/core/services/logger' +import { DateTime } from 'luxon' +import { inject } from '@adonisjs/core' +import { OllamaService } from './ollama_service.js' +import { ChatRequest } from 'ollama' + +@inject() +export class ChatService { + constructor(private ollamaService: OllamaService) {} + + async chat(chatRequest: ChatRequest & { stream?: false }) { + try { + return await this.ollamaService.chat(chatRequest) + } catch (error) { + logger.error(`[ChatService] Chat error: ${error instanceof Error ? error.message : error}`) + throw new Error('Chat processing failed') + } + } + + async getAllSessions() { + try { + const sessions = await ChatSession.query().orderBy('updated_at', 'desc') + return sessions.map((session) => ({ + id: session.id.toString(), + title: session.title, + model: session.model, + timestamp: session.updated_at.toJSDate(), + lastMessage: null, // Will be populated from messages if needed + })) + } catch (error) { + logger.error( + `[ChatService] Failed to get sessions: ${error instanceof Error ? error.message : error}` + ) + return [] + } + } + + async getSession(sessionId: number) { + try { + console.log('Fetching session with ID:', sessionId); + const session = await ChatSession.query().where('id', sessionId).preload('messages').first() + + if (!session) { + return null + } + + return { + id: session.id.toString(), + title: session.title, + model: session.model, + timestamp: session.updated_at.toJSDate(), + messages: session.messages.map((msg) => ({ + id: msg.id.toString(), + role: msg.role, + content: msg.content, + timestamp: msg.created_at.toJSDate(), + })), + } + } catch (error) { + logger.error( + `[ChatService] Failed to get session ${sessionId}: ${ + error instanceof Error ? error.message : error + }` + ) + return null + } + } + + async createSession(title: string, model?: string) { + try { + const session = await ChatSession.create({ + title, + model: model || null, + }) + + return { + id: session.id.toString(), + title: session.title, + model: session.model, + timestamp: session.created_at.toJSDate(), + } + } catch (error) { + logger.error( + `[ChatService] Failed to create session: ${error instanceof Error ? error.message : error}` + ) + throw new Error('Failed to create chat session') + } + } + + async updateSession(sessionId: number, data: { title?: string; model?: string }) { + try { + const session = await ChatSession.findOrFail(sessionId) + + if (data.title) { + session.title = data.title + } + if (data.model !== undefined) { + session.model = data.model + } + + await session.save() + + return { + id: session.id.toString(), + title: session.title, + model: session.model, + timestamp: session.updated_at.toJSDate(), + } + } catch (error) { + logger.error( + `[ChatService] Failed to update session ${sessionId}: ${ + error instanceof Error ? error.message : error + }` + ) + throw new Error('Failed to update chat session') + } + } + + async addMessage(sessionId: number, role: 'system' | 'user' | 'assistant', content: string) { + try { + const message = await ChatMessage.create({ + session_id: sessionId, + role, + content, + }) + + // Update session's updated_at timestamp + const session = await ChatSession.findOrFail(sessionId) + session.updated_at = DateTime.now() + await session.save() + + return { + id: message.id.toString(), + role: message.role, + content: message.content, + timestamp: message.created_at.toJSDate(), + } + } catch (error) { + logger.error( + `[ChatService] Failed to add message to session ${sessionId}: ${ + error instanceof Error ? error.message : error + }` + ) + throw new Error('Failed to add message') + } + } + + async deleteSession(sessionId: number) { + try { + const session = await ChatSession.findOrFail(sessionId) + await session.delete() + return { success: true } + } catch (error) { + logger.error( + `[ChatService] Failed to delete session ${sessionId}: ${ + error instanceof Error ? error.message : error + }` + ) + throw new Error('Failed to delete chat session') + } + } + + /** + * Delete all chat sessions and messages + */ + async deleteAllSessions() { + try { + await ChatSession.query().delete() + return { success: true, message: 'All chat sessions deleted' } + } catch (error) { + logger.error( + `[ChatService] Failed to delete all sessions: ${ + error instanceof Error ? error.message : error + }` + ) + throw new Error('Failed to delete all chat sessions') + } + } +} diff --git a/admin/app/services/ollama_service.ts b/admin/app/services/ollama_service.ts new file mode 100644 index 0000000..3e77a54 --- /dev/null +++ b/admin/app/services/ollama_service.ts @@ -0,0 +1,492 @@ +import { inject } from '@adonisjs/core' +import { ChatRequest, Ollama } from 'ollama' +import { DockerService } from './docker_service.js' +import { NomadOllamaModel } from '../../types/ollama.js' +import { FALLBACK_RECOMMENDED_OLLAMA_MODELS } from '../../constants/ollama.js' +import fs from 'node:fs/promises' +import path from 'node:path' +import logger from '@adonisjs/core/services/logger' +import axios from 'axios' +import { DownloadModelJob } from '#jobs/download_model_job' +import { PassThrough } from 'node:stream' + +const NOMAD_MODELS_API_BASE_URL = 'https://api.projectnomad.us/api/v1/ollama/models' +const MODELS_CACHE_FILE = path.join(process.cwd(), 'storage', 'ollama-models-cache.json') +const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours + +@inject() +export class OllamaService { + private ollama: Ollama | null = null + private ollamaInitPromise: Promise | null = null + + constructor() {} + + private async _initializeOllamaClient() { + if (!this.ollamaInitPromise) { + this.ollamaInitPromise = (async () => { + const dockerService = new (await import('./docker_service.js')).DockerService() + const qdrantUrl = await dockerService.getServiceURL(DockerService.OLLAMA_SERVICE_NAME) + if (!qdrantUrl) { + throw new Error('Ollama service is not installed or running.') + } + this.ollama = new Ollama({ host: qdrantUrl }) + })() + } + return this.ollamaInitPromise + } + + private async _ensureDependencies() { + if (!this.ollama) { + await this._initializeOllamaClient() + } + } + + /** We need to call this in the DownloadModelJob, so it can't be private, + * but shouldn't be called directly (dispatch job instead) + */ + async _downloadModel( + model: string, + onProgress?: (progress: { + status: string + completed?: number + total?: number + percent?: number + }) => void + ): Promise<{ success: boolean; message: string }> { + return new Promise(async (resolve) => { + try { + const dockerService = new (await import('./docker_service.js')).DockerService() + const container = dockerService.docker.getContainer(DockerService.OLLAMA_SERVICE_NAME) + if (!container) { + logger.warn('[OllamaService] Ollama container is not running. Cannot download model.') + resolve({ + success: false, + message: 'Ollama is not running. Please start Ollama and try again.', + }) + return + } + + container.exec( + { + Cmd: ['ollama', 'pull', model], + AttachStdout: true, + AttachStderr: true, + }, + (err, exec) => { + if (err) { + logger.error( + `[OllamaService] Failed to execute model download command: ${ + err instanceof Error ? err.message : err + }` + ) + resolve({ success: false, message: 'Failed to execute download command.' }) + return + } + + if (!exec) { + logger.error('[OllamaService] No exec instance returned from exec command') + resolve({ success: false, message: 'Failed to create exec instance.' }) + return + } + + exec.start( + { + hijack: true, + stdin: false, + }, + (startErr, stream) => { + if (startErr) { + logger.error( + `[OllamaService] Failed to start exec stream: ${ + startErr instanceof Error ? startErr.message : startErr + }` + ) + resolve({ success: false, message: 'Failed to start download stream.' }) + return + } + + if (!stream) { + logger.error('[OllamaService] No stream returned when starting exec') + resolve({ success: false, message: 'No stream available.' }) + return + } + + // Create PassThrough streams to capture output + const stdout = new PassThrough() + const stderr = new PassThrough() + + // Demultiplex the Docker stream + dockerService.docker.modem.demuxStream(stream, stdout, stderr) + + // Capture and parse stdout (if any) + stdout.on('data', (chunk) => { + const output = chunk.toString() + logger.info(`[OllamaService] Model download (stdout): ${output}`) + }) + + // Capture stderr - ollama sends progress/status here (not necessarily errors) + stderr.on('data', (chunk) => { + const output = chunk.toString() + + // Check if this is an actual error message + if ( + output.toLowerCase().includes('error') || + output.toLowerCase().includes('failed') + ) { + logger.error(`[OllamaService] Model download error: ${output}`) + } else { + // This is normal progress/status output from ollama + logger.info(`[OllamaService] Model download progress: ${output}`) + + // Parse JSON progress if available + try { + const lines = output + .split('\n') + .filter( + (line: any) => typeof line.trim() === 'string' && line.trim().length > 0 + ) + for (const line of lines) { + const parsed = JSON.parse(line) + if (parsed.status) { + const progressData: { + status: string + completed?: number + total?: number + percent?: number + } = { + status: parsed.status, + } + + // Extract byte progress if available + if (parsed.completed !== undefined && parsed.total !== undefined) { + progressData.completed = parsed.completed + progressData.total = parsed.total + progressData.percent = Math.round( + (parsed.completed / parsed.total) * 100 + ) + } + + // Call progress callback + if (onProgress) { + onProgress(progressData) + } + + // Log structured progress + if (progressData.percent !== undefined) { + logger.info( + `[OllamaService] ${progressData.status}: ${progressData.percent}% (${progressData.completed}/${progressData.total} bytes)` + ) + } else { + logger.info(`[OllamaService] ${progressData.status}`) + } + } + } + } catch { + // Not JSON, already logged above + } + } + }) + + // Handle stream end + stream.on('end', () => { + logger.info( + `[OllamaService] Model download process ended for model "${model}"` + ) + resolve({ + success: true, + message: 'Model download completed successfully.', + }) + }) + + // Handle stream errors + stream.on('error', (streamErr) => { + logger.error( + `[OllamaService] Error during model download stream: ${ + streamErr instanceof Error ? streamErr.message : streamErr + }` + ) + resolve({ + success: false, + message: 'Error occurred during model download.', + }) + }) + } + ) + } + ) + } catch (error) { + logger.error( + `[OllamaService] Failed to download model "${model}": ${ + error instanceof Error ? error.message : error + }` + ) + resolve({ success: false, message: 'Failed to download model.' }) + } + }) + } + + /** + * Synchronous version of model download (waits for completion). Should only be used for + * small models or in contexts where a background job is incompatible. + * @param model Model name to download + * @returns Success status and message + */ + async downloadModelSync(model: string): Promise<{ success: boolean; message: string }> { + try { + // See if model is already installed + const installedModels = await this.getModels() + if (installedModels && installedModels.some((m) => m.name === model)) { + logger.info(`[OllamaService] Model "${model}" is already installed.`) + return { success: true, message: 'Model is already installed.' } + } + + const dockerService = new (await import('./docker_service.js')).DockerService() + + const ollamAPIURL = await dockerService.getServiceURL(DockerService.OLLAMA_SERVICE_NAME) + if (!ollamAPIURL) { + logger.warn('[OllamaService] Ollama service is not running. Cannot download model.') + return { + success: false, + message: 'Ollama is not running. Please start Ollama and try again.', + } + } + + // 10 minutes timeout for large model downloads + await axios.post(`${ollamAPIURL}/api/pull`, { name: model }, { timeout: 600000 }) + + logger.info(`[OllamaService] Model "${model}" downloaded via API.`) + return { success: true, message: 'Model downloaded successfully.' } + } catch (error) { + logger.error( + `[OllamaService] Failed to download model "${model}": ${ + error instanceof Error ? error.message : error + }` + ) + return { success: false, message: 'Failed to download model.' } + } + } + + async dispatchModelDownload(modelName: string): Promise<{ success: boolean; message: string }> { + try { + logger.info(`[OllamaService] Dispatching model download for ${modelName} via job queue`) + + await DownloadModelJob.dispatch({ + modelName, + }) + + return { + success: true, + message: + 'Model download has been queued successfully. It will start shortly after Ollama and Open WebUI are ready (if not already).', + } + } catch (error) { + logger.error( + `[OllamaService] Failed to dispatch model download for ${modelName}: ${error instanceof Error ? error.message : error}` + ) + return { + success: false, + message: 'Failed to queue model download. Please try again.', + } + } + } + + public async getClient() { + await this._ensureDependencies() + return this.ollama! + } + + public async chat(chatRequest: ChatRequest & { stream?: boolean }) { + await this._ensureDependencies() + if (!this.ollama) { + throw new Error('Ollama client is not initialized.') + } + return await this.ollama.chat({ + ...chatRequest, + stream: false, + }) + } + + public async deleteModel(modelName: string) { + await this._ensureDependencies() + if (!this.ollama) { + throw new Error('Ollama client is not initialized.') + } + + return await this.ollama.delete({ + model: modelName, + }) + } + + public async getModels(includeEmbeddings = false) { + await this._ensureDependencies() + if (!this.ollama) { + throw new Error('Ollama client is not initialized.') + } + const response = await this.ollama.list() + if (includeEmbeddings) { + return response.models + } + // Filter out embedding models + return response.models.filter((model) => !model.name.includes('embed')) + } + + async getAvailableModels( + { sort, recommendedOnly }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean } = { + sort: 'pulls', + recommendedOnly: false, + } + ): Promise { + try { + const models = await this.retrieveAndRefreshModels(sort) + if (!models) { + // If we fail to get models from the API, return the fallback recommended models + logger.warn( + '[OllamaService] Returning fallback recommended models due to failure in fetching available models' + ) + return FALLBACK_RECOMMENDED_OLLAMA_MODELS + } + + if (!recommendedOnly) { + return models + } + + // If recommendedOnly is true, only return the first three models (if sorted by pulls, these will be the top 3) + const sortedByPulls = sort === 'pulls' ? models : this.sortModels(models, 'pulls') + const firstThree = sortedByPulls.slice(0, 3) + + // Only return the first tag of each of these models (should be the most lightweight variant) + const recommendedModels = firstThree.map((model) => { + return { + ...model, + tags: model.tags && model.tags.length > 0 ? [model.tags[0]] : [], + } + }) + return recommendedModels + } catch (error) { + logger.error( + `[OllamaService] Failed to get available models: ${error instanceof Error ? error.message : error}` + ) + return null + } + } + + private async retrieveAndRefreshModels( + sort?: 'pulls' | 'name' + ): Promise { + try { + const cachedModels = await this.readModelsFromCache() + if (cachedModels) { + logger.info('[OllamaService] Using cached available models data') + return this.sortModels(cachedModels, sort) + } + + logger.info('[OllamaService] Fetching fresh available models from API') + const response = await axios.get(NOMAD_MODELS_API_BASE_URL) + if (!response.data || !Array.isArray(response.data.models)) { + logger.warn( + `[OllamaService] Invalid response format when fetching available models: ${JSON.stringify(response.data)}` + ) + return null + } + + const models = response.data.models as NomadOllamaModel[] + + await this.writeModelsToCache(models) + return this.sortModels(models, sort) + } catch (error) { + logger.error( + `[OllamaService] Failed to retrieve models from Nomad API: ${ + error instanceof Error ? error.message : error + }` + ) + return null + } + } + + private async readModelsFromCache(): Promise { + try { + const stats = await fs.stat(MODELS_CACHE_FILE) + const cacheAge = Date.now() - stats.mtimeMs + + if (cacheAge > CACHE_MAX_AGE_MS) { + logger.info('[OllamaService] Cache is stale, will fetch fresh data') + return null + } + + const cacheData = await fs.readFile(MODELS_CACHE_FILE, 'utf-8') + const models = JSON.parse(cacheData) as NomadOllamaModel[] + + if (!Array.isArray(models)) { + logger.warn('[OllamaService] Invalid cache format, will fetch fresh data') + return null + } + + return models + } catch (error) { + // Cache doesn't exist or is invalid + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn( + `[OllamaService] Error reading cache: ${error instanceof Error ? error.message : error}` + ) + } + return null + } + } + + private async writeModelsToCache(models: NomadOllamaModel[]): Promise { + try { + await fs.mkdir(path.dirname(MODELS_CACHE_FILE), { recursive: true }) + await fs.writeFile(MODELS_CACHE_FILE, JSON.stringify(models, null, 2), 'utf-8') + logger.info('[OllamaService] Successfully cached available models') + } catch (error) { + logger.warn( + `[OllamaService] Failed to write models cache: ${error instanceof Error ? error.message : error}` + ) + } + } + + private sortModels(models: NomadOllamaModel[], sort?: 'pulls' | 'name'): NomadOllamaModel[] { + if (sort === 'pulls') { + // Sort by estimated pulls (it should be a string like "1.2K", "500", "4M" etc.) + models.sort((a, b) => { + const parsePulls = (pulls: string) => { + const multiplier = pulls.endsWith('K') + ? 1_000 + : pulls.endsWith('M') + ? 1_000_000 + : pulls.endsWith('B') + ? 1_000_000_000 + : 1 + return parseFloat(pulls) * multiplier + } + return parsePulls(b.estimated_pulls) - parsePulls(a.estimated_pulls) + }) + } else if (sort === 'name') { + models.sort((a, b) => a.name.localeCompare(b.name)) + } + + // Always sort model.tags by the size field in descending order + // Size is a string like '75GB', '8.5GB', '2GB' etc. Smaller models first + models.forEach((model) => { + if (model.tags && Array.isArray(model.tags)) { + model.tags.sort((a, b) => { + const parseSize = (size: string) => { + const multiplier = size.endsWith('KB') + ? 1 / 1_000 + : size.endsWith('MB') + ? 1 / 1_000_000 + : size.endsWith('GB') + ? 1 + : size.endsWith('TB') + ? 1_000 + : 0 // Unknown size format + return parseFloat(size) * multiplier + } + return parseSize(a.size) - parseSize(b.size) + }) + } + }) + + return models + } +} diff --git a/admin/app/services/openwebui_service.ts b/admin/app/services/openwebui_service.ts deleted file mode 100644 index 3acf299..0000000 --- a/admin/app/services/openwebui_service.ts +++ /dev/null @@ -1,854 +0,0 @@ -import { inject } from '@adonisjs/core' -import logger from '@adonisjs/core/services/logger' -import { DockerService } from './docker_service.js' -import axios from 'axios' -import { NomadOllamaModel, OllamaModelListing } from '../../types/ollama.js' -import fs from 'node:fs/promises' -import path from 'node:path' -import { PassThrough } from 'node:stream' -import { DownloadModelJob } from '#jobs/download_model_job' -import { FALLBACK_RECOMMENDED_OLLAMA_MODELS } from '../../constants/ollama.js' -import { chromium } from 'playwright' -import KVStore from '#models/kv_store' -import { getFile } from '../utils/fs.js' - -const NOMAD_MODELS_API_BASE_URL = 'https://api.projectnomad.us/api/v1/ollama/models' -const MODELS_CACHE_FILE = path.join(process.cwd(), 'storage', 'ollama-models-cache.json') -const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours - -@inject() -export class OpenWebUIService { - public static NOMAD_KNOWLEDGE_BASE_NAME = 'nomad-knowledge-base' - public static NOMAD_KNOWLEDGE_BASE_DESCRIP = - 'Knowledge base managed by Project NOMAD, used to enhance LLM responses with up-to-date information. Do not delete.' - - constructor(private dockerService: DockerService) {} - - /** We need to call this in the DownloadModelJob, so it can't be private, - * but shouldn't be called directly (dispatch job instead) - */ - async _downloadModel( - model: string, - onProgress?: (progress: { - status: string - completed?: number - total?: number - percent?: number - }) => void - ): Promise<{ success: boolean; message: string }> { - return new Promise((resolve) => { - try { - const container = this.dockerService.docker.getContainer(DockerService.OLLAMA_SERVICE_NAME) - if (!container) { - logger.warn('[OpenWebUIService] Ollama container is not running. Cannot download model.') - resolve({ - success: false, - message: 'Ollama is not running. Please start Ollama and try again.', - }) - return - } - - container.exec( - { - Cmd: ['ollama', 'pull', model], - AttachStdout: true, - AttachStderr: true, - }, - (err, exec) => { - if (err) { - logger.error( - `[OpenWebUIService] Failed to execute model download command: ${ - err instanceof Error ? err.message : err - }` - ) - resolve({ success: false, message: 'Failed to execute download command.' }) - return - } - - if (!exec) { - logger.error('[OpenWebUIService] No exec instance returned from exec command') - resolve({ success: false, message: 'Failed to create exec instance.' }) - return - } - - exec.start( - { - hijack: true, - stdin: false, - }, - (startErr, stream) => { - if (startErr) { - logger.error( - `[OpenWebUIService] Failed to start exec stream: ${ - startErr instanceof Error ? startErr.message : startErr - }` - ) - resolve({ success: false, message: 'Failed to start download stream.' }) - return - } - - if (!stream) { - logger.error('[OpenWebUIService] No stream returned when starting exec') - resolve({ success: false, message: 'No stream available.' }) - return - } - - // Create PassThrough streams to capture output - const stdout = new PassThrough() - const stderr = new PassThrough() - - // Demultiplex the Docker stream - this.dockerService.docker.modem.demuxStream(stream, stdout, stderr) - - // Capture and parse stdout (if any) - stdout.on('data', (chunk) => { - const output = chunk.toString() - logger.info(`[OpenWebUIService] Model download (stdout): ${output}`) - }) - - // Capture stderr - ollama sends progress/status here (not necessarily errors) - stderr.on('data', (chunk) => { - const output = chunk.toString() - - // Check if this is an actual error message - if ( - output.toLowerCase().includes('error') || - output.toLowerCase().includes('failed') - ) { - logger.error(`[OpenWebUIService] Model download error: ${output}`) - } else { - // This is normal progress/status output from ollama - logger.info(`[OpenWebUIService] Model download progress: ${output}`) - - // Parse JSON progress if available - try { - const lines = output - .split('\n') - .filter( - (line: any) => typeof line.trim() === 'string' && line.trim().length > 0 - ) - for (const line of lines) { - const parsed = JSON.parse(line) - if (parsed.status) { - const progressData: { - status: string - completed?: number - total?: number - percent?: number - } = { - status: parsed.status, - } - - // Extract byte progress if available - if (parsed.completed !== undefined && parsed.total !== undefined) { - progressData.completed = parsed.completed - progressData.total = parsed.total - progressData.percent = Math.round( - (parsed.completed / parsed.total) * 100 - ) - } - - // Call progress callback - if (onProgress) { - onProgress(progressData) - } - - // Log structured progress - if (progressData.percent !== undefined) { - logger.info( - `[OpenWebUIService] ${progressData.status}: ${progressData.percent}% (${progressData.completed}/${progressData.total} bytes)` - ) - } else { - logger.info(`[OpenWebUIService] ${progressData.status}`) - } - } - } - } catch { - // Not JSON, already logged above - } - } - }) - - // Handle stream end - stream.on('end', () => { - logger.info( - `[OpenWebUIService] Model download process ended for model "${model}"` - ) - resolve({ - success: true, - message: 'Model download completed successfully.', - }) - }) - - // Handle stream errors - stream.on('error', (streamErr) => { - logger.error( - `[OpenWebUIService] Error during model download stream: ${ - streamErr instanceof Error ? streamErr.message : streamErr - }` - ) - resolve({ - success: false, - message: 'Error occurred during model download.', - }) - }) - } - ) - } - ) - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to download model "${model}": ${ - error instanceof Error ? error.message : error - }` - ) - resolve({ success: false, message: 'Failed to download model.' }) - } - }) - } - - /** - * Synchronous version of model download (waits for completion). Should only be used for - * small models or in contexts where a background job is incompatible. - * @param model Model name to download - * @returns Success status and message - */ - async downloadModelSync(model: string): Promise<{ success: boolean; message: string }> { - try { - // See if model is already installed - const installedModels = await this.getInstalledModels() - if (installedModels && installedModels.some((m) => m.name === model)) { - logger.info(`[OpenWebUIService] Model "${model}" is already installed.`) - return { success: true, message: 'Model is already installed.' } - } - - const ollamAPIURL = await this.dockerService.getServiceURL(DockerService.OLLAMA_SERVICE_NAME) - if (!ollamAPIURL) { - logger.warn('[OpenWebUIService] Ollama service is not running. Cannot download model.') - return { - success: false, - message: 'Ollama is not running. Please start Ollama and try again.', - } - } - - // 10 minutes timeout for large model downloads - await axios.post(`${ollamAPIURL}/api/pull`, { name: model }, { timeout: 600000 }) - - logger.info(`[OpenWebUIService] Model "${model}" downloaded via API.`) - return { success: true, message: 'Model downloaded successfully.' } - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to download model "${model}": ${ - error instanceof Error ? error.message : error - }` - ) - return { success: false, message: 'Failed to download model.' } - } - } - - async deleteModel(model: string): Promise<{ success: boolean; message: string }> { - return new Promise((resolve) => { - try { - const container = this.dockerService.docker.getContainer(DockerService.OLLAMA_SERVICE_NAME) - if (!container) { - logger.warn('[OpenWebUIService] Ollama container is not running. Cannot remove model.') - resolve({ - success: false, - message: 'Ollama is not running. Please start Ollama and try again.', - }) - return - } - - container.exec( - { - Cmd: ['ollama', 'rm', model], - AttachStdout: true, - AttachStderr: true, - }, - (err, exec) => { - if (err) { - logger.error( - `[OpenWebUIService] Failed to execute model remove command: ${ - err instanceof Error ? err.message : err - }` - ) - resolve({ success: false, message: 'Failed to execute remove command.' }) - return - } - - if (!exec) { - logger.error('[OpenWebUIService] No exec instance returned from remove command') - resolve({ success: false, message: 'Failed to create exec instance.' }) - return - } - - exec.start( - { - hijack: true, - stdin: false, - }, - (startErr, stream) => { - if (startErr) { - logger.error( - `[OpenWebUIService] Failed to start exec stream for remove: ${ - startErr instanceof Error ? startErr.message : startErr - }` - ) - resolve({ success: false, message: 'Failed to start remove command.' }) - return - } - - if (!stream) { - logger.error('[OpenWebUIService] No stream returned for remove command') - resolve({ success: false, message: 'No stream available.' }) - return - } - - const stdout = new PassThrough() - const stderr = new PassThrough() - let output = '' - let errorOutput = '' - - this.dockerService.docker.modem.demuxStream(stream, stdout, stderr) - - stdout.on('data', (chunk) => { - output += chunk.toString() - }) - - stderr.on('data', (chunk) => { - errorOutput += chunk.toString() - }) - - stream.on('end', () => { - if (errorOutput) { - logger.error(`[OpenWebUIService] Error removing model: ${errorOutput}`) - resolve({ - success: false, - message: errorOutput.trim() || 'Failed to remove model.', - }) - return - } - - logger.info(`[OpenWebUIService] Successfully removed model "${model}"`) - if (output) { - logger.info(`[OpenWebUIService] Remove output: ${output}`) - } - - resolve({ - success: true, - message: 'Model removed successfully.', - }) - }) - - stream.on('error', (streamErr) => { - logger.error( - `[OpenWebUIService] Stream error during model remove: ${ - streamErr instanceof Error ? streamErr.message : streamErr - }` - ) - resolve({ - success: false, - message: 'Error occurred while removing model.', - }) - }) - } - ) - } - ) - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to remove model "${model}": ${ - error instanceof Error ? error.message : error - }` - ) - resolve({ success: false, message: 'Failed to remove model.' }) - } - }) - } - - async dispatchModelDownload(modelName: string): Promise<{ success: boolean; message: string }> { - try { - logger.info(`[OpenWebUIService] Dispatching model download for ${modelName} via job queue`) - - await DownloadModelJob.dispatch({ - modelName, - }) - - return { - success: true, - message: - 'Model download has been queued successfully. It will start shortly after Ollama and Open WebUI are ready (if not already).', - } - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to dispatch model download for ${modelName}: ${error instanceof Error ? error.message : error}` - ) - return { - success: false, - message: 'Failed to queue model download. Please try again.', - } - } - } - - async getAvailableModels( - { sort, recommendedOnly }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean } = { - sort: 'pulls', - recommendedOnly: false, - } - ): Promise { - try { - const models = await this.retrieveAndRefreshModels(sort) - if (!models) { - // If we fail to get models from the API, return the fallback recommended models - logger.warn( - '[OpenWebUIService] Returning fallback recommended models due to failure in fetching available models' - ) - return FALLBACK_RECOMMENDED_OLLAMA_MODELS; - } - - if (!recommendedOnly) { - return models - } - - // If recommendedOnly is true, only return the first three models (if sorted by pulls, these will be the top 3) - const sortedByPulls = sort === 'pulls' ? models : this.sortModels(models, 'pulls') - const firstThree = sortedByPulls.slice(0, 3) - - // Only return the first tag of each of these models (should be the most lightweight variant) - const recommendedModels = firstThree.map((model) => { - return { - ...model, - tags: model.tags && model.tags.length > 0 ? [model.tags[0]] : [], - } - }) - return recommendedModels - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to get available models: ${error instanceof Error ? error.message : error}` - ) - return null - } - } - - async getInstalledModels(): Promise { - return new Promise((resolve) => { - try { - const container = this.dockerService.docker.getContainer(DockerService.OLLAMA_SERVICE_NAME) - if (!container) { - logger.warn('[OpenWebUIService] Ollama container is not running. Cannot list models.') - resolve(null) - return - } - - container.exec( - { - Cmd: ['ollama', 'list'], - AttachStdout: true, - AttachStderr: true, - }, - (err, exec) => { - if (err) { - logger.error( - `[OpenWebUIService] Failed to execute ollama list command: ${ - err instanceof Error ? err.message : err - }` - ) - resolve(null) - return - } - - if (!exec) { - logger.error('[OpenWebUIService] No exec instance returned from ollama list') - resolve(null) - return - } - - exec.start( - { - hijack: true, - stdin: false, - }, - (startErr, stream) => { - if (startErr) { - logger.error( - `[OpenWebUIService] Failed to start exec stream for ollama list: ${ - startErr instanceof Error ? startErr.message : startErr - }` - ) - resolve(null) - return - } - - if (!stream) { - logger.error('[OpenWebUIService] No stream returned for ollama list') - resolve(null) - return - } - - const stdout = new PassThrough() - const stderr = new PassThrough() - let output = '' - let errorOutput = '' - - this.dockerService.docker.modem.demuxStream(stream, stdout, stderr) - - stdout.on('data', (chunk) => { - output += chunk.toString() - }) - - stderr.on('data', (chunk) => { - errorOutput += chunk.toString() - }) - - stream.on('end', () => { - if (errorOutput) { - logger.error( - `[OpenWebUIService] Error from ollama list command: ${errorOutput}` - ) - } - - if (!output) { - logger.info('[OpenWebUIService] No models installed') - resolve([]) - return - } - - try { - // Parse the tabular output from ollama list - // Expected format: - // NAME ID SIZE MODIFIED - // llama2:latest abc123def456 3.8 GB 2 days ago - const lines = output.split('\n').filter((line) => line.trim()) - - // Skip header line and parse model entries - const models: OllamaModelListing[] = [] - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim() - if (!line) continue - - // Split by whitespace (2+ spaces to handle columns with spaces) - const parts = line.split(/\s{2,}/) - - if (parts.length >= 4) { - models.push({ - name: parts[0].trim(), - id: parts[1].trim(), - size: parts[2].trim(), - modified: parts[3].trim(), - }) - } - } - - logger.info(`[OpenWebUIService] Found ${models.length} installed models`) - resolve(models) - } catch (parseError) { - logger.error( - `[OpenWebUIService] Failed to parse ollama list output: ${ - parseError instanceof Error ? parseError.message : parseError - }` - ) - logger.debug(`[OpenWebUIService] Raw output: ${output}`) - resolve(null) - } - }) - - stream.on('error', (streamErr) => { - logger.error( - `[OpenWebUIService] Stream error during ollama list: ${ - streamErr instanceof Error ? streamErr.message : streamErr - }` - ) - resolve(null) - }) - } - ) - } - ) - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to get installed models: ${ - error instanceof Error ? error.message : error - }` - ) - resolve(null) - } - }) - } - - async getOrCreateKnowledgeBase(): Promise { - try { - // See if we already have the knowledge base ID stored - const existing = await KVStore.getValue('open_webui_knowledge_id') - if (existing) { - return existing as string - } - - // Create a new knowledge base via Open WebUI API - const tokenData = await this.getOpenWebUIToken() - if (!tokenData) { - logger.warn( - '[OpenWebUIService] Cannot get or create knowledge base because Open WebUI token is unavailable.' - ) - return null - } - - const response = await axios.post( - `${tokenData.url}/api/v1/knowledge/create`, - { - name: OpenWebUIService.NOMAD_KNOWLEDGE_BASE_NAME, - description: OpenWebUIService.NOMAD_KNOWLEDGE_BASE_DESCRIP, - }, - { - headers: { - Authorization: `Bearer ${tokenData.token}`, - }, - } - ) - - if (response.data && response.data.id) { - await KVStore.setValue('open_webui_knowledge_id', response.data.id) - return response.data.id - } - - logger.error( - `[OpenWebUIService] Invalid response when creating knowledge base: ${JSON.stringify( - response.data - )}` - ) - return null - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to get or create knowledge base: ${ - error instanceof Error ? error.message : error - }` - ) - return null - } - } - - async uploadFileToKnowledgeBase(filepath: string): Promise { - try { - const knowledgeBaseId = await this.getOrCreateKnowledgeBase() - if (!knowledgeBaseId) { - logger.warn( - '[OpenWebUIService] Cannot upload file because knowledge base ID is unavailable and could not be created.' - ) - return false - } - - const tokenData = await this.getOpenWebUIToken() - if (!tokenData) { - logger.warn( - '[OpenWebUIService] Cannot upload file because Open WebUI token is unavailable.' - ) - return false - } - - const fileStream = await getFile(filepath, 'stream') - if (!fileStream) { - logger.warn( - `[OpenWebUIService] Cannot upload file because it could not be read: ${filepath}` - ) - return false - } - - const formData = new FormData() - formData.append('file', fileStream) - - const uploadRes = await axios.post( - `${tokenData.url}/api/v1/files/`, // Trailing slash seems to be required by OWUI - formData, - { - headers: { - 'Authorization': `Bearer ${tokenData.token}`, - 'Content-Type': 'multipart/form-data', - 'Accept': 'application/json', - }, - } - ) - - if (!uploadRes.data || !uploadRes.data.id) { - logger.error( - `[OpenWebUIService] Invalid response when uploading file: ${JSON.stringify( - uploadRes.data - )}` - ) - return false - } - - const fileId = uploadRes.data.id - - // Now associate the uploaded file with the knowledge base - const associateRes = await axios.post( - `${tokenData.url}/api/v1/knowledge/${knowledgeBaseId}/file/add`, - { - file_id: fileId, - }, - { - headers: { - Authorization: `Bearer ${tokenData.token}`, - }, - } - ) - - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to upload file to knowledge base: ${ - error instanceof Error ? error.message : error - }` - ) - return false - } - } - - private async getOpenWebUIToken(): Promise<{ token: string; url: string } | null> { - try { - const openWebUIURL = await this.dockerService.getServiceURL( - DockerService.OPEN_WEBUI_SERVICE_NAME - ) - if (!openWebUIURL) { - logger.warn('[OpenWebUIService] Open WebUI service is not running. Cannot retrieve token.') - return null - } - const browser = await chromium.launch({ headless: true }) - const context = await browser.newContext() - const page = await context.newPage() - - await page.goto(openWebUIURL) - await page.waitForLoadState('networkidle') - - const cookies = await context.cookies() - const tokenCookie = cookies.find((cookie) => cookie.name === 'token') - await browser.close() - - return tokenCookie ? { token: tokenCookie.value, url: openWebUIURL } : null - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to retrieve Open WebUI token: ${ - error instanceof Error ? error.message : error - }` - ) - return null - } - } - - private async retrieveAndRefreshModels( - sort?: 'pulls' | 'name' - ): Promise { - try { - const cachedModels = await this.readModelsFromCache() - if (cachedModels) { - logger.info('[OpenWebUIService] Using cached available models data') - return this.sortModels(cachedModels, sort) - } - - logger.info('[OpenWebUIService] Fetching fresh available models from API') - const response = await axios.get(NOMAD_MODELS_API_BASE_URL) - if (!response.data || !Array.isArray(response.data.models)) { - logger.warn( - `[OpenWebUIService] Invalid response format when fetching available models: ${JSON.stringify(response.data)}` - ) - return null - } - - const models = response.data.models as NomadOllamaModel[] - - await this.writeModelsToCache(models) - return this.sortModels(models, sort) - } catch (error) { - logger.error( - `[OpenWebUIService] Failed to retrieve models from Nomad API: ${ - error instanceof Error ? error.message : error - }` - ) - return null - } - } - - private async readModelsFromCache(): Promise { - try { - const stats = await fs.stat(MODELS_CACHE_FILE) - const cacheAge = Date.now() - stats.mtimeMs - - if (cacheAge > CACHE_MAX_AGE_MS) { - logger.info('[OpenWebUIService] Cache is stale, will fetch fresh data') - return null - } - - const cacheData = await fs.readFile(MODELS_CACHE_FILE, 'utf-8') - const models = JSON.parse(cacheData) as NomadOllamaModel[] - - if (!Array.isArray(models)) { - logger.warn('[OpenWebUIService] Invalid cache format, will fetch fresh data') - return null - } - - return models - } catch (error) { - // Cache doesn't exist or is invalid - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.warn( - `[OpenWebUIService] Error reading cache: ${error instanceof Error ? error.message : error}` - ) - } - return null - } - } - - private async writeModelsToCache(models: NomadOllamaModel[]): Promise { - try { - await fs.mkdir(path.dirname(MODELS_CACHE_FILE), { recursive: true }) - await fs.writeFile(MODELS_CACHE_FILE, JSON.stringify(models, null, 2), 'utf-8') - logger.info('[OpenWebUIService] Successfully cached available models') - } catch (error) { - logger.warn( - `[OpenWebUIService] Failed to write models cache: ${error instanceof Error ? error.message : error}` - ) - } - } - - private sortModels(models: NomadOllamaModel[], sort?: 'pulls' | 'name'): NomadOllamaModel[] { - if (sort === 'pulls') { - // Sort by estimated pulls (it should be a string like "1.2K", "500", "4M" etc.) - models.sort((a, b) => { - const parsePulls = (pulls: string) => { - const multiplier = pulls.endsWith('K') - ? 1_000 - : pulls.endsWith('M') - ? 1_000_000 - : pulls.endsWith('B') - ? 1_000_000_000 - : 1 - return parseFloat(pulls) * multiplier - } - return parsePulls(b.estimated_pulls) - parsePulls(a.estimated_pulls) - }) - } else if (sort === 'name') { - models.sort((a, b) => a.name.localeCompare(b.name)) - } - - // Always sort model.tags by the size field in descending order - // Size is a string like '75GB', '8.5GB', '2GB' etc. Smaller models first - models.forEach((model) => { - if (model.tags && Array.isArray(model.tags)) { - model.tags.sort((a, b) => { - const parseSize = (size: string) => { - const multiplier = size.endsWith('KB') - ? 1 / 1_000 - : size.endsWith('MB') - ? 1 / 1_000_000 - : size.endsWith('GB') - ? 1 - : size.endsWith('TB') - ? 1_000 - : 0 // Unknown size format - return parseFloat(size) * multiplier - } - return parseSize(a.size) - parseSize(b.size) - }) - } - }) - - return models - } -} diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index 31fbb12..b4415be 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -1,29 +1,26 @@ -import { Ollama } from 'ollama' import { QdrantClient } from '@qdrant/js-client-rest' import { DockerService } from './docker_service.js' import { inject } from '@adonisjs/core' import logger from '@adonisjs/core/services/logger' import { chunk } from 'llm-chunk' -import { OpenWebUIService } from './openwebui_service.js' import sharp from 'sharp' import { determineFileType, getFile } from '../utils/fs.js' import { PDFParse } from 'pdf-parse' import { createWorker } from 'tesseract.js' import { fromBuffer } from 'pdf2pic' +import { OllamaService } from './ollama_service.js' @inject() export class RagService { private qdrant: QdrantClient | null = null - private ollama: Ollama | null = null private qdrantInitPromise: Promise | null = null - private ollamaInitPromise: Promise | null = null public static CONTENT_COLLECTION_NAME = 'open-webui_knowledge' // This is the collection name OWUI uses for uploaded knowledge public static EMBEDDING_MODEL = 'nomic-embed-text:v1.5' public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768 constructor( private dockerService: DockerService, - private openWebUIService: OpenWebUIService + private ollamaService: OllamaService ) {} private async _initializeQdrantClient() { @@ -33,32 +30,16 @@ export class RagService { if (!qdrantUrl) { throw new Error('Qdrant service is not installed or running.') } - this.qdrant = new QdrantClient({ url: `http://${qdrantUrl}` }) + this.qdrant = new QdrantClient({ url: qdrantUrl }) })() } return this.qdrantInitPromise } - private async _initializeOllamaClient() { - if (!this.ollamaInitPromise) { - this.ollamaInitPromise = (async () => { - const ollamaUrl = await this.dockerService.getServiceURL(DockerService.OLLAMA_SERVICE_NAME) - if (!ollamaUrl) { - throw new Error('Ollama service is not installed or running.') - } - this.ollama = new Ollama({ host: `http://${ollamaUrl}` }) - })() - } - return this.ollamaInitPromise - } - private async _ensureDependencies() { if (!this.qdrant) { await this._initializeQdrantClient() } - if (!this.ollama) { - await this._initializeOllamaClient() - } } private async _ensureCollection( @@ -93,13 +74,13 @@ export class RagService { RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION ) - const initModelResponse = await this.openWebUIService.downloadModelSync( - RagService.EMBEDDING_MODEL - ) - if (!initModelResponse.success) { - throw new Error( - `${RagService.EMBEDDING_MODEL} does not exist and could not be downloaded: ${initModelResponse.message}` - ) + + const allModels = await this.ollamaService.getModels(true) + const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL) + + // TODO: Attempt to download the embedding model if not found + if (!embeddingModel) { + throw new Error(`${RagService.EMBEDDING_MODEL} does not exist and could not be downloaded.`) } const chunks = chunk(text, { @@ -114,8 +95,9 @@ export class RagService { } const embeddings: number[][] = [] + const ollamaClient = await this.ollamaService.getClient() for (const chunkText of chunks) { - const response = await this.ollama!.embeddings({ + const response = await ollamaClient.embeddings({ model: RagService.EMBEDDING_MODEL, prompt: chunkText, }) @@ -213,7 +195,7 @@ export class RagService { */ public async processAndEmbedFile( filepath: string - ): Promise<{ success: boolean; message: string }> { + ): Promise<{ success: boolean; message: string; chunks?: number }> { try { const fileType = determineFileType(filepath) if (fileType === 'unknown') { @@ -252,11 +234,113 @@ export class RagService { const embedResult = await this.embedAndStoreText(extractedText, {}) - - return { success: true, message: 'File processed and embedded successfully.' } + return { + success: true, + message: 'File processed and embedded successfully.', + chunks: embedResult?.chunks, + } } catch (error) { logger.error('Error processing and embedding file:', error) return { success: false, message: 'Error processing and embedding file.' } } } + + /** + * Search for documents similar to the query text in the Qdrant knowledge base. + * Returns the most relevant text chunks based on semantic similarity. + * @param query - The search query text + * @param limit - Maximum number of results to return (default: 5) + * @param scoreThreshold - Minimum similarity score threshold (default: 0.7) + * @returns Array of relevant text chunks with their scores + */ + public async searchSimilarDocuments( + query: string, + limit: number = 5, + scoreThreshold: number = 0.7 + ): Promise> { + try { + await this._ensureCollection( + RagService.CONTENT_COLLECTION_NAME, + RagService.EMBEDDING_DIMENSION + ) + + const allModels = await this.ollamaService.getModels(true) + const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL) + + if (!embeddingModel) { + logger.warn( + `${RagService.EMBEDDING_MODEL} not found. Cannot perform similarity search.` + ) + return [] + } + + // Generate embedding for the query + const ollamaClient = await this.ollamaService.getClient() + const response = await ollamaClient.embeddings({ + model: RagService.EMBEDDING_MODEL, + prompt: query, + }) + + // Search for similar vectors in Qdrant + const searchResults = await this.qdrant!.search(RagService.CONTENT_COLLECTION_NAME, { + vector: response.embedding, + limit: limit, + score_threshold: scoreThreshold, + with_payload: true, + }) + + console.log("Got search results:", searchResults); + + return searchResults.map((result) => ({ + text: (result.payload?.text as string) || '', + score: result.score, + })) + } catch (error) { + logger.error('Error searching similar documents:', error) + return [] + } + } + + /** + * Retrieve all unique source files that have been stored in the knowledge base. + * @returns Array of unique source file identifiers + */ + public async getStoredFiles(): Promise { + try { + await this._ensureCollection( + RagService.CONTENT_COLLECTION_NAME, + RagService.EMBEDDING_DIMENSION + ) + + const sources = new Set() + let offset: string | number | null | Record = null + const batchSize = 100 + + // Scroll through all points in the collection + do { + const scrollResult = await this.qdrant!.scroll(RagService.CONTENT_COLLECTION_NAME, { + limit: batchSize, + offset: offset, + with_payload: true, + with_vector: false, + }) + + // Extract unique source values from payloads + scrollResult.points.forEach((point) => { + const metadata = point.payload?.metadata + if (metadata && typeof metadata === 'object' && 'source' in metadata) { + const source = metadata.source as string + sources.add(source) + } + }) + + offset = scrollResult.next_page_offset || null + } while (offset !== null) + + return Array.from(sources) + } catch (error) { + logger.error('Error retrieving stored files:', error) + return [] + } + } } diff --git a/admin/app/utils/fs.ts b/admin/app/utils/fs.ts index 4b0e295..bc26523 100644 --- a/admin/app/utils/fs.ts +++ b/admin/app/utils/fs.ts @@ -164,3 +164,12 @@ export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | return 'unknown' } } + +/** + * Sanitize a filename by removing potentially dangerous characters. + * @param filename The original filename + * @returns The sanitized filename + */ +export function sanitizeFilename(filename: string): string { + return filename.replace(/[^a-zA-Z0-9._-]/g, '_') +} \ No newline at end of file diff --git a/admin/app/validators/chat.ts b/admin/app/validators/chat.ts new file mode 100644 index 0000000..49b01b1 --- /dev/null +++ b/admin/app/validators/chat.ts @@ -0,0 +1,22 @@ +import vine from '@vinejs/vine' + +export const createSessionSchema = vine.compile( + vine.object({ + title: vine.string().trim().minLength(1).maxLength(200), + model: vine.string().trim().optional(), + }) +) + +export const updateSessionSchema = vine.compile( + vine.object({ + title: vine.string().trim().minLength(1).maxLength(200).optional(), + model: vine.string().trim().optional(), + }) +) + +export const addMessageSchema = vine.compile( + vine.object({ + role: vine.enum(['system', 'user', 'assistant'] as const), + content: vine.string().trim().minLength(1), + }) +) \ No newline at end of file diff --git a/admin/app/validators/ollama.ts b/admin/app/validators/ollama.ts new file mode 100644 index 0000000..c83d4a9 --- /dev/null +++ b/admin/app/validators/ollama.ts @@ -0,0 +1,21 @@ +import vine from '@vinejs/vine' + +export const chatSchema = vine.compile( + vine.object({ + model: vine.string().trim().minLength(1), + messages: vine.array( + vine.object({ + role: vine.enum(['system', 'user', 'assistant'] as const), + content: vine.string(), + }) + ), + stream: vine.boolean().optional(), + }) +) + +export const getAvailableModelsSchema = vine.compile( + vine.object({ + sort: vine.enum(['pulls', 'name'] as const).optional(), + recommendedOnly: vine.boolean().optional(), + }) +) diff --git a/admin/app/validators/openwebui.ts b/admin/app/validators/openwebui.ts deleted file mode 100644 index c7051e5..0000000 --- a/admin/app/validators/openwebui.ts +++ /dev/null @@ -1,8 +0,0 @@ -import vine from '@vinejs/vine' - -export const getAvailableModelsSchema = vine.compile( - vine.object({ - sort: vine.enum(['pulls', 'name'] as const).optional(), - recommendedOnly: vine.boolean().optional(), - }) -) diff --git a/admin/constants/ollama.ts b/admin/constants/ollama.ts index 13b43cb..65136be 100644 --- a/admin/constants/ollama.ts +++ b/admin/constants/ollama.ts @@ -55,3 +55,22 @@ export const FALLBACK_RECOMMENDED_OLLAMA_MODELS: NomadOllamaModel[] = [ ], }, ] + +export const SYSTEM_PROMPTS = { + default: ` + Format all responses using markdown for better readability. Vanilla markdown or GitHub-flavored markdown is preferred. + - Use **bold** and *italic* for emphasis. + - Use code blocks with language identifiers for code snippets. + - Use headers (##, ###) to organize longer responses. + - Use bullet points or numbered lists for clarity. + - Use tables when presenting structured data. +`, + rag_context: (context: string) => ` +You have access to the following relevant information from the knowledge base. Use this context to provide accurate and informed responses when relevant: + +[Context] +${context} + +If the user's question is related to this context, incorporate it into your response. Otherwise, respond normally. +`, +} diff --git a/admin/database/migrations/1769646771604_create_create_chat_sessions_table.ts b/admin/database/migrations/1769646771604_create_create_chat_sessions_table.ts new file mode 100644 index 0000000..29dba66 --- /dev/null +++ b/admin/database/migrations/1769646771604_create_create_chat_sessions_table.ts @@ -0,0 +1,19 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'chat_sessions' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('title').notNullable() + table.string('model').nullable() + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} \ No newline at end of file diff --git a/admin/database/migrations/1769646798266_create_create_chat_messages_table.ts b/admin/database/migrations/1769646798266_create_create_chat_messages_table.ts new file mode 100644 index 0000000..87d813b --- /dev/null +++ b/admin/database/migrations/1769646798266_create_create_chat_messages_table.ts @@ -0,0 +1,20 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'chat_messages' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.integer('session_id').unsigned().references('id').inTable('chat_sessions').onDelete('CASCADE') + table.enum('role', ['system', 'user', 'assistant']).notNullable() + table.text('content').notNullable() + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} \ No newline at end of file diff --git a/admin/inertia/app/app.tsx b/admin/inertia/app/app.tsx index 318fa8d..2eabe10 100644 --- a/admin/inertia/app/app.tsx +++ b/admin/inertia/app/app.tsx @@ -42,7 +42,7 @@ createInertiaApp({ - {showDevtools && } + {showDevtools && } diff --git a/admin/inertia/components/Providers.tsx b/admin/inertia/components/Providers.tsx deleted file mode 100644 index acdb929..0000000 --- a/admin/inertia/components/Providers.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import { TransmitProvider } from 'react-adonis-transmit' -import ModalsProvider from '~/providers/ModalProvider' -import NotificationsProvider from '~/providers/NotificationProvider' -import { UsePageProps } from '../../types/system' -import { usePage } from '@inertiajs/react' - -export default function Providers({ - children, - queryClient, -}: { - children: React.ReactNode - queryClient: QueryClient -}) { - const { environment } = usePage().props as unknown as UsePageProps - - return ( - - - - - {children} - {['development', 'staging'].includes(environment) && ( - - )} - - - - - ) -} diff --git a/admin/inertia/components/StyledButton.tsx b/admin/inertia/components/StyledButton.tsx index 97b9989..b5eaf2e 100644 --- a/admin/inertia/components/StyledButton.tsx +++ b/admin/inertia/components/StyledButton.tsx @@ -1,5 +1,6 @@ import * as Icons from '@heroicons/react/24/outline' import { useMemo } from 'react' +import clsx from 'clsx' export interface StyledButtonProps extends React.HTMLAttributes { children: React.ReactNode @@ -56,71 +57,77 @@ const StyledButton: React.FC = ({ const getVariantClasses = () => { const baseTransition = 'transition-all duration-200 ease-in-out' const baseHover = 'hover:shadow-md active:scale-[0.98]' - + switch (variant) { case 'primary': - return ` - bg-desert-green text-desert-white - hover:bg-desert-green-dark hover:shadow-lg - active:bg-desert-green-darker - disabled:bg-desert-green-light disabled:text-desert-stone-light - ${baseTransition} ${baseHover} - ` - + return clsx( + 'bg-desert-green text-desert-white', + 'hover:bg-desert-green-dark hover:shadow-lg', + 'active:bg-desert-green-darker', + 'disabled:bg-desert-green-light disabled:text-desert-stone-light', + baseTransition, + baseHover + ) + case 'secondary': - return ` - bg-desert-tan text-desert-white - hover:bg-desert-tan-dark hover:shadow-lg - active:bg-desert-tan-dark - disabled:bg-desert-tan-lighter disabled:text-desert-stone-light - ${baseTransition} ${baseHover} - ` - + return clsx( + 'bg-desert-tan text-desert-white', + 'hover:bg-desert-tan-dark hover:shadow-lg', + 'active:bg-desert-tan-dark', + 'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light', + baseTransition, + baseHover + ) + case 'danger': - return ` - bg-desert-red text-desert-white - hover:bg-desert-red-dark hover:shadow-lg - active:bg-desert-red-dark - disabled:bg-desert-red-lighter disabled:text-desert-stone-light - ${baseTransition} ${baseHover} - ` - + return clsx( + 'bg-desert-red text-desert-white', + 'hover:bg-desert-red-dark hover:shadow-lg', + 'active:bg-desert-red-dark', + 'disabled:bg-desert-red-lighter disabled:text-desert-stone-light', + baseTransition, + baseHover + ) + case 'action': - return ` - bg-desert-orange text-desert-white - hover:bg-desert-orange-light hover:shadow-lg - active:bg-desert-orange-dark - disabled:bg-desert-orange-lighter disabled:text-desert-stone-light - ${baseTransition} ${baseHover} - ` - + return clsx( + 'bg-desert-orange text-desert-white', + 'hover:bg-desert-orange-light hover:shadow-lg', + 'active:bg-desert-orange-dark', + 'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light', + baseTransition, + baseHover + ) + case 'success': - return ` - bg-desert-olive text-desert-white - hover:bg-desert-olive-dark hover:shadow-lg - active:bg-desert-olive-dark - disabled:bg-desert-olive-lighter disabled:text-desert-stone-light - ${baseTransition} ${baseHover} - ` - + return clsx( + 'bg-desert-olive text-desert-white', + 'hover:bg-desert-olive-dark hover:shadow-lg', + 'active:bg-desert-olive-dark', + 'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light', + baseTransition, + baseHover + ) + case 'ghost': - return ` - bg-transparent text-desert-green - hover:bg-desert-sand hover:text-desert-green-dark - active:bg-desert-green-lighter - disabled:text-desert-stone-light - ${baseTransition} - ` - + return clsx( + 'bg-transparent text-desert-green', + 'hover:bg-desert-sand hover:text-desert-green-dark', + 'active:bg-desert-green-lighter', + 'disabled:text-desert-stone-light', + baseTransition + ) + case 'outline': - return ` - bg-transparent border-2 border-desert-green text-desert-green - hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark - active:bg-desert-green-dark active:border-desert-green-darker - disabled:border-desert-green-lighter disabled:text-desert-stone-light - ${baseTransition} ${baseHover} - ` - + return clsx( + 'bg-transparent border-2 border-desert-green text-desert-green', + 'hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark', + 'active:bg-desert-green-dark active:border-desert-green-darker', + 'disabled:border-desert-green-lighter disabled:text-desert-stone-light', + baseTransition, + baseHover + ) + default: return '' } @@ -129,8 +136,8 @@ const StyledButton: React.FC = ({ const getLoadingSpinner = () => { const spinnerSize = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4' return ( - ) } @@ -146,16 +153,13 @@ const StyledButton: React.FC = ({ return ( ) } -export default StyledButton \ No newline at end of file +export default StyledButton diff --git a/admin/inertia/components/chat/ChatAssistantAvatar.tsx b/admin/inertia/components/chat/ChatAssistantAvatar.tsx new file mode 100644 index 0000000..4e3063a --- /dev/null +++ b/admin/inertia/components/chat/ChatAssistantAvatar.tsx @@ -0,0 +1,11 @@ +import { IconWand } from "@tabler/icons-react"; + +export default function ChatAssistantAvatar() { + return ( +
+
+ +
+
+ ) +} diff --git a/admin/inertia/components/chat/ChatButton.tsx b/admin/inertia/components/chat/ChatButton.tsx new file mode 100644 index 0000000..6d20999 --- /dev/null +++ b/admin/inertia/components/chat/ChatButton.tsx @@ -0,0 +1,17 @@ +import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline' + +interface ChatButtonProps { + onClick: () => void +} + +export default function ChatButton({ onClick }: ChatButtonProps) { + return ( + + ) +} diff --git a/admin/inertia/components/chat/ChatInterface.tsx b/admin/inertia/components/chat/ChatInterface.tsx new file mode 100644 index 0000000..8a54828 --- /dev/null +++ b/admin/inertia/components/chat/ChatInterface.tsx @@ -0,0 +1,149 @@ +import { PaperAirplaneIcon } from '@heroicons/react/24/outline' +import { IconWand } from '@tabler/icons-react' +import { useState, useRef, useEffect } from 'react' +import classNames from '~/lib/classNames' +import { ChatMessage } from '../../../types/chat' +import ChatMessageBubble from './ChatMessageBubble' +import ChatAssistantAvatar from './ChatAssistantAvatar' + +interface ChatInterfaceProps { + messages: ChatMessage[] + onSendMessage: (message: string) => void + isLoading?: boolean +} + +export default function ChatInterface({ + messages, + onSendMessage, + isLoading = false, +}: ChatInterfaceProps) { + const [input, setInput] = useState('') + const messagesEndRef = useRef(null) + const textareaRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (input.trim() && !isLoading) { + onSendMessage(input.trim()) + setInput('') + if (textareaRef.current) { + textareaRef.current.style.height = 'auto' + } + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit(e) + } + } + + const handleInput = (e: React.ChangeEvent) => { + setInput(e.target.value) + // Auto-resize textarea + e.target.style.height = 'auto' + e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px` + } + + return ( +
+
+ {messages.length === 0 ? ( +
+
+ +

Start a conversation

+

+ Interact with your installed language models directly in the Command Center. +

+
+
+ ) : ( + <> + {messages.map((message) => ( +
+ {message.role === 'assistant' && } + +
+ ))} + {/* Loading/thinking indicator */} + {isLoading && ( +
+ +
+
+ Thinking + + + + + +
+
+
+ )} + +
+ + )} +
+
+
+
+