feat: [wip] native AI chat interface

This commit is contained in:
Jake Turner 2026-01-31 21:38:44 +00:00 committed by Jake Turner
parent 50174d2edb
commit 243f749090
43 changed files with 3693 additions and 1116 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<typeof ChatSession>
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
}

View File

@ -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<typeof ChatMessage>
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
}

View File

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

View File

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

View File

@ -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<void> | 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<NomadOllamaModel[] | null> {
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<NomadOllamaModel[] | null> {
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<NomadOllamaModel[] | null> {
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<void> {
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
}
}

View File

@ -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<NomadOllamaModel[] | null> {
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<OllamaModelListing[] | null> {
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<string | null> {
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<boolean> {
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<NomadOllamaModel[] | null> {
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<NomadOllamaModel[] | null> {
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<void> {
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
}
}

View File

@ -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<void> | null = null
private ollamaInitPromise: Promise<void> | 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<Array<{ text: string; score: number }>> {
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<string[]> {
try {
await this._ensureCollection(
RagService.CONTENT_COLLECTION_NAME,
RagService.EMBEDDING_DIMENSION
)
const sources = new Set<string>()
let offset: string | number | null | Record<string, unknown> = 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 []
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
`,
}

View File

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

View File

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

View File

@ -42,7 +42,7 @@ createInertiaApp({
<NotificationsProvider>
<ModalsProvider>
<App {...props} />
{showDevtools && <ReactQueryDevtools initialIsOpen={false} />}
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
</ModalsProvider>
</NotificationsProvider>
</TransmitProvider>

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<NotificationsProvider>
<ModalsProvider>
{children}
{['development', 'staging'].includes(environment) && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</ModalsProvider>
</NotificationsProvider>
</TransmitProvider>
</QueryClientProvider>
)
}

View File

@ -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<HTMLButtonElement> {
children: React.ReactNode
@ -56,71 +57,77 @@ const StyledButton: React.FC<StyledButtonProps> = ({
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<StyledButtonProps> = ({
const getLoadingSpinner = () => {
const spinnerSize = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4'
return (
<Icons.ArrowPathIcon
className={`${spinnerSize} animate-spin ${fullWidth ? 'mx-auto' : ''}`}
<Icons.ArrowPathIcon
className={clsx(spinnerSize, 'animate-spin')}
/>
)
}
@ -146,16 +153,13 @@ const StyledButton: React.FC<StyledButtonProps> = ({
return (
<button
type="button"
className={`
${fullWidth ? 'w-full' : 'inline-flex'}
items-center justify-center
rounded-md font-semibold
${getSizeClasses()}
${getVariantClasses()}
focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand
disabled:cursor-not-allowed disabled:shadow-none
${isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer'}
`}
className={clsx(
fullWidth ? 'flex w-full' : 'inline-flex',
getSizeClasses(),
getVariantClasses(),
isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer',
'items-center justify-center rounded-md font-semibold focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand disabled:cursor-not-allowed disabled:shadow-none'
)}
{...props}
disabled={isDisabled}
onClick={onClickHandler}
@ -165,11 +169,11 @@ const StyledButton: React.FC<StyledButtonProps> = ({
) : (
<>
{icon && <IconComponent />}
<span className={fullWidth ? 'block text-center' : ''}>{children}</span>
{children}
</>
)}
</button>
)
}
export default StyledButton
export default StyledButton

View File

@ -0,0 +1,11 @@
import { IconWand } from "@tabler/icons-react";
export default function ChatAssistantAvatar() {
return (
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-desert-green flex items-center justify-center">
<IconWand className="h-5 w-5 text-white" />
</div>
</div>
)
}

View File

@ -0,0 +1,17 @@
import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline'
interface ChatButtonProps {
onClick: () => void
}
export default function ChatButton({ onClick }: ChatButtonProps) {
return (
<button
onClick={onClick}
className="fixed bottom-6 right-6 z-40 p-4 bg-desert-green text-white rounded-full shadow-lg hover:bg-desert-green/90 transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2 cursor-pointer"
aria-label="Open chat"
>
<ChatBubbleLeftRightIcon className="h-6 w-6" />
</button>
)
}

View File

@ -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<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value)
// Auto-resize textarea
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`
}
return (
<div className="flex-1 flex flex-col min-h-0 bg-white">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center max-w-md">
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium text-gray-700 mb-2">Start a conversation</h3>
<p className="text-gray-500 text-sm">
Interact with your installed language models directly in the Command Center.
</p>
</div>
</div>
) : (
<>
{messages.map((message) => (
<div
key={message.id}
className={classNames(
'flex gap-4',
message.role === 'user' ? 'justify-end' : 'justify-start'
)}
>
{message.role === 'assistant' && <ChatAssistantAvatar />}
<ChatMessageBubble message={message} />
</div>
))}
{/* Loading/thinking indicator */}
{isLoading && (
<div className="flex gap-4 justify-start">
<ChatAssistantAvatar />
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-gray-100 text-gray-800">
<div className="flex items-center gap-2">
<span className="text-gray-600">Thinking</span>
<span className="flex gap-1 mt-1">
<span
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
<div className="border-t border-gray-200 bg-white px-6 py-4 flex-shrink-0 min-h-[90px]">
<form onSubmit={handleSubmit} className="flex gap-3 items-end">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Shift+Enter for new line)"
className="w-full resize-none rounded-lg border border-gray-300 px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-gray-50 disabled:text-gray-500"
rows={1}
disabled={isLoading}
style={{ maxHeight: '200px' }}
/>
</div>
<button
type="submit"
disabled={!input.trim() || isLoading}
className={classNames(
'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',
!input.trim() || isLoading
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'
)}
>
{isLoading ? (
<div className="h-6 w-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<PaperAirplaneIcon className="h-6 w-6" />
)}
</button>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,82 @@
import classNames from '~/lib/classNames'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { ChatMessage } from '../../../types/chat'
export interface ChatMessageBubbleProps {
message: ChatMessage
}
export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
return (
<div
className={classNames(
'max-w-[70%] rounded-lg px-4 py-3',
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-gray-100 text-gray-800'
)}
>
<div
className={classNames(
'break-words',
message.role === 'assistant' ? 'prose prose-sm max-w-none' : 'whitespace-pre-wrap'
)}
>
{message.role === 'assistant' ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: ({ node, className, children, ...props }) => (
<code
className="block bg-gray-800 text-gray-100 p-3 rounded-lg overflow-x-auto font-mono text-sm"
{...props}
>
{children}
</code>
),
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-5 mb-2">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 mb-2">{children}</ol>,
li: ({ children }) => <li className="mb-1">{children}</li>,
h1: ({ children }) => <h1 className="text-xl font-bold mb-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-lg font-bold mb-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-base font-bold mb-2">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-400 pl-4 italic my-2">
{children}
</blockquote>
),
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-desert-green underline hover:text-desert-green/80"
>
{children}
</a>
),
}}
>
{message.content}
</ReactMarkdown>
) : (
message.content
)}
{message.isStreaming && (
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
)}
</div>
<div
className={classNames(
'text-xs mt-2',
message.role === 'user' ? 'text-white/70' : 'text-gray-500'
)}
>
{message.timestamp.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
)
}

View File

@ -0,0 +1,27 @@
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
import Chat from './index'
interface ChatModalProps {
open: boolean
onClose: () => void
}
export default function ChatModal({ open, onClose }: ChatModalProps) {
return (
<Dialog open={open} onClose={onClose} className="relative z-50">
<DialogBackdrop
transition
className="fixed inset-0 bg-black/30 backdrop-blur-sm transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel
transition
className="relative bg-white rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
>
<Chat enabled={open} isInModal onClose={onClose} />
</DialogPanel>
</div>
</Dialog>
)
}

View File

@ -0,0 +1,128 @@
import { ChatBubbleLeftIcon } from '@heroicons/react/24/outline'
import classNames from '~/lib/classNames'
import StyledButton from '../StyledButton'
import { router } from '@inertiajs/react'
import { ChatSession } from '../../../types/chat'
interface ChatSidebarProps {
sessions: ChatSession[]
activeSessionId: string | null
onSessionSelect: (id: string) => void
onNewChat: () => void
onClearHistory: () => void
isInModal?: boolean
}
export default function ChatSidebar({
sessions,
activeSessionId,
onSessionSelect,
onNewChat,
onClearHistory,
isInModal = false,
}: ChatSidebarProps) {
return (
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full">
<div className="p-4 border-b border-gray-200 h-[75px] flex items-center justify-center">
<StyledButton onClick={onNewChat} icon="PlusIcon" variant="primary" fullWidth>
New Chat
</StyledButton>
</div>
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No previous chats</div>
) : (
<div className="p-2 space-y-1">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => onSessionSelect(session.id)}
className={classNames(
'w-full text-left px-3 py-2 rounded-lg transition-colors group',
activeSessionId === session.id
? 'bg-desert-green text-white'
: 'hover:bg-gray-200 text-gray-700'
)}
>
<div className="flex items-start gap-2">
<ChatBubbleLeftIcon
className={classNames(
'h-5 w-5 mt-0.5 flex-shrink-0',
activeSessionId === session.id ? 'text-white' : 'text-gray-400'
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{session.title}</div>
{session.lastMessage && (
<div
className={classNames(
'text-xs truncate mt-0.5',
activeSessionId === session.id ? 'text-white/80' : 'text-gray-500'
)}
>
{session.lastMessage}
</div>
)}
</div>
</div>
</button>
))}
</div>
)}
</div>
{sessions.length > 0 && (
<div className="p-4 flex flex-col items-center justify-center gap-y-2">
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-28 w-28 mb-6" />
<StyledButton
onClick={() => {
if (isInModal) {
window.open('/chat', '_blank')
} else {
router.visit('/home')
}
}}
icon={isInModal ? 'ArrowTopRightOnSquareIcon' : 'HomeIcon'}
variant="outline"
size="sm"
fullWidth
>
{isInModal ? 'Open in New Tab' : 'Back to Home'}
</StyledButton>
<StyledButton
onClick={() => {
router.visit('/settings/models')
}}
icon="CircleStackIcon"
variant="primary"
size="sm"
fullWidth
>
Models
</StyledButton>
<StyledButton
onClick={() => {
router.visit('/knowledge-base')
}}
icon="AcademicCapIcon"
variant="primary"
size="sm"
fullWidth
>
Knowledge Base
</StyledButton>
<StyledButton
onClick={onClearHistory}
icon="TrashIcon"
variant="danger"
size="sm"
fullWidth
>
Clear History
</StyledButton>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,270 @@
import { XMarkIcon } from '@heroicons/react/24/outline'
import { useState, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import ChatSidebar from './ChatSidebar'
import ChatInterface from './ChatInterface'
import StyledModal from '../StyledModal'
import api from '~/lib/api'
import { formatBytes } from '~/lib/util'
import { useModals } from '~/context/ModalContext'
import { ChatMessage } from '../../../types/chat'
import classNames from '~/lib/classNames'
interface ChatProps {
enabled: boolean
isInModal?: boolean
onClose?: () => void
}
export default function Chat({ enabled, isInModal, onClose }: ChatProps) {
const queryClient = useQueryClient()
const { openModal, closeAllModals } = useModals()
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [selectedModel, setSelectedModel] = useState<string>('')
// Fetch all sessions
const { data: sessions = [] } = useQuery({
queryKey: ['chatSessions'],
queryFn: () => api.getChatSessions(),
enabled,
select: (data) =>
data?.map((s) => ({
id: s.id,
title: s.title,
model: s.model || undefined,
timestamp: new Date(s.timestamp),
lastMessage: s.lastMessage || undefined,
})) || [],
})
const activeSession = sessions.find((s) => s.id === activeSessionId)
const { data: installedModels = [], isLoading: isLoadingModels } = useQuery({
queryKey: ['installedModels'],
queryFn: () => api.getInstalledModels(),
enabled,
select: (data) => data || [],
})
const deleteAllSessionsMutation = useMutation({
mutationFn: () => api.deleteAllChatSessions(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
setActiveSessionId(null)
setMessages([])
closeAllModals()
},
})
const chatMutation = useMutation({
mutationFn: (request: {
model: string
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
}) => api.sendChatMessage({ ...request, stream: false }),
onSuccess: async (data, variables) => {
if (!data || !activeSessionId) {
throw new Error('No response from Ollama')
}
// Add assistant message
const assistantMessage: ChatMessage = {
id: `msg-${Date.now()}-assistant`,
role: 'assistant',
content: data.message?.content || 'Sorry, I could not generate a response.',
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
// Save assistant message to backend
await api.addChatMessage(activeSessionId, 'assistant', assistantMessage.content)
// Update session title if it's a new chat
const currentSession = sessions.find((s) => s.id === activeSessionId)
if (currentSession && currentSession.title === 'New Chat') {
const userContent = variables.messages[variables.messages.length - 1].content
const newTitle = userContent.slice(0, 50) + (userContent.length > 50 ? '...' : '')
await api.updateChatSession(activeSessionId, { title: newTitle })
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
}
},
onError: (error) => {
console.error('Error sending message:', error)
const errorMessage: ChatMessage = {
id: `msg-${Date.now()}-error`,
role: 'assistant',
content: 'Sorry, there was an error processing your request. Please try again.',
timestamp: new Date(),
}
setMessages((prev) => [...prev, errorMessage])
},
})
// Set first model as selected by default
useEffect(() => {
if (installedModels.length > 0 && !selectedModel) {
setSelectedModel(installedModels[0].name)
}
}, [installedModels, selectedModel])
const handleNewChat = useCallback(() => {
// Just clear the active session and messages - don't create a session yet
setActiveSessionId(null)
setMessages([])
}, [])
const handleClearHistory = useCallback(() => {
openModal(
<StyledModal
title="Clear All Chat History?"
onConfirm={() => deleteAllSessionsMutation.mutate()}
onCancel={closeAllModals}
open={true}
confirmText="Clear All"
cancelText="Cancel"
confirmVariant="danger"
>
<p className="text-gray-700">
Are you sure you want to delete all chat sessions? This action cannot be undone and all
conversations will be permanently deleted.
</p>
</StyledModal>,
'confirm-clear-history-modal'
)
}, [openModal, closeAllModals, deleteAllSessionsMutation])
const handleSessionSelect = useCallback(
async (sessionId: string) => {
setActiveSessionId(sessionId)
// Load messages for this session
const sessionData = await api.getChatSession(sessionId)
if (sessionData?.messages) {
setMessages(
sessionData.messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
timestamp: new Date(m.timestamp),
}))
)
} else {
setMessages([])
}
// Set the model to match the session's model if it exists and is available
if (sessionData?.model) {
setSelectedModel(sessionData.model)
}
},
[installedModels]
)
const handleSendMessage = useCallback(
async (content: string) => {
let sessionId = activeSessionId
// Create a new session if none exists
if (!sessionId) {
const newSession = await api.createChatSession('New Chat', selectedModel)
if (newSession) {
sessionId = newSession.id
setActiveSessionId(sessionId)
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
} else {
return
}
}
// Add user message to UI
const userMessage: ChatMessage = {
id: `msg-${Date.now()}`,
role: 'user',
content,
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
// Save user message to backend
await api.addChatMessage(sessionId, 'user', content)
// Send chat request using mutation
chatMutation.mutate({
model: selectedModel || 'llama3.2',
messages: [
...messages.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user', content },
],
})
},
[activeSessionId, messages, selectedModel, chatMutation, queryClient]
)
return (
<div
className={classNames(
'flex border border-gray-200 overflow-hidden shadow-sm w-full',
isInModal ? 'h-full rounded-lg' : 'h-screen'
)}
>
<ChatSidebar
sessions={sessions}
activeSessionId={activeSessionId}
onSessionSelect={handleSessionSelect}
onNewChat={handleNewChat}
onClearHistory={handleClearHistory}
isInModal={isInModal}
/>
<div className="flex-1 flex flex-col min-h-0">
<div className="px-6 py-3 border-b border-gray-200 bg-gray-50 flex items-center justify-between h-[75px] flex-shrink-0">
<h2 className="text-lg font-semibold text-gray-800">
{activeSession?.title || 'New Chat'}
</h2>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="model-select" className="text-sm text-gray-600">
Model:
</label>
{isLoadingModels ? (
<div className="text-sm text-gray-500">Loading models...</div>
) : installedModels.length === 0 ? (
<div className="text-sm text-red-600">No models installed</div>
) : (
<select
id="model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-white"
>
{installedModels.map((model) => (
<option key={model.name} value={model.name}>
{model.name} ({formatBytes(model.size)})
</option>
))}
</select>
)}
</div>
{isInModal && (
<button
onClick={() => {
if (onClose) {
onClose()
}
}}
className="rounded-lg hover:bg-gray-100 transition-colors"
>
<XMarkIcon className="h-6 w-6 text-gray-500" />
</button>
)}
</div>
</div>
<ChatInterface
messages={messages}
onSendMessage={handleSendMessage}
isLoading={chatMutation.isPending}
/>
</div>
</div>
)
}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import { forwardRef, useImperativeHandle, useState } from 'react'
import Uppy from '@uppy/core'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
@ -17,19 +17,25 @@ interface FileUploaderProps {
className?: string
}
export interface FileUploaderRef {
clear: () => void
}
/**
* A drag-and-drop (or click) file upload area with customizations for
* multiple and maximum numbers of files.
*/
const FileUploader: React.FC<FileUploaderProps> = ({
minFiles = 0,
maxFiles = 1,
maxFileSize = 10485760, // default to 10MB
fileTypes,
disabled = false,
onUpload,
className,
}) => {
const FileUploader = forwardRef<FileUploaderRef, FileUploaderProps>((props, ref) => {
const {
minFiles = 0,
maxFiles = 1,
maxFileSize = 10485760, // default to 10MB
fileTypes,
disabled = false,
onUpload,
className,
} = props
const [uppy] = useState(() => {
const uppy = new Uppy({
debug: true,
@ -43,6 +49,12 @@ const FileUploader: React.FC<FileUploaderProps> = ({
return uppy
})
useImperativeHandle(ref, () => ({
clear: () => {
uppy.clear()
},
}))
useUppyEvent(uppy, 'state-update', (_, newState) => {
const stateFiles = Object.values(newState.files)
@ -79,6 +91,8 @@ const FileUploader: React.FC<FileUploaderProps> = ({
className={classNames(className)}
/>
)
}
})
FileUploader.displayName = 'FileUploader'
export default FileUploader

View File

@ -1,6 +1,11 @@
import { useState } from 'react'
import Footer from '~/components/Footer'
import ChatButton from '~/components/chat/ChatButton'
import ChatModal from '~/components/chat/ChatModal'
export default function AppLayout({ children }: { children: React.ReactNode }) {
const [isChatOpen, setIsChatOpen] = useState(false)
return (
<div className="min-h-screen flex flex-col">
<div
@ -13,6 +18,9 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
<hr className="text-desert-green font-semibold h-[1.5px] bg-desert-green border-none" />
<div className="flex-1 w-full bg-desert">{children}</div>
<Footer />
<ChatButton onClick={() => setIsChatOpen(true)} />
<ChatModal open={isChatOpen} onClose={() => setIsChatOpen(false)} />
</div>
)
}

View File

@ -9,7 +9,8 @@ import {
DownloadJobWithProgress,
} from '../../types/downloads'
import { catchInternal } from './util'
import { NomadOllamaModel } from '../../types/ollama'
import { NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
import { ChatResponse, ModelResponse } from 'ollama'
class API {
private client: AxiosInstance
@ -35,7 +36,7 @@ class API {
async deleteModel(model: string): Promise<{ success: boolean; message: string }> {
return catchInternal(async () => {
const response = await this.client.post('/openwebui/delete-model', { model })
const response = await this.client.delete('/ollama/models', { data: { model } })
return response.data
})()
}
@ -60,7 +61,7 @@ class API {
async downloadModel(model: string): Promise<{ success: boolean; message: string }> {
return catchInternal(async () => {
const response = await this.client.post('/openwebui/download-model', { model })
const response = await this.client.post('/ollama/models', { model })
return response.data
})()
}
@ -138,15 +139,120 @@ class API {
})()
}
async getInstalledModels() {
return catchInternal(async () => {
const response = await this.client.get<ModelResponse[]>('/ollama/installed-models')
return response.data
})()
}
async getRecommendedModels(): Promise<NomadOllamaModel[] | undefined> {
return catchInternal(async () => {
const response = await this.client.get<NomadOllamaModel[]>('/openwebui/models', {
const response = await this.client.get<NomadOllamaModel[]>('/ollama/models', {
params: { sort: 'pulls', recommendedOnly: true },
})
return response.data
})()
}
async sendChatMessage(chatRequest: OllamaChatRequest) {
return catchInternal(async () => {
const response = await this.client.post<ChatResponse>('/ollama/chat', chatRequest)
return response.data
})()
}
async getChatSessions() {
return catchInternal(async () => {
const response = await this.client.get<
Array<{
id: string
title: string
model: string | null
timestamp: string
lastMessage: string | null
}>
>('/chat/sessions')
return response.data
})()
}
async getChatSession(sessionId: string) {
return catchInternal(async () => {
const response = await this.client.get<{
id: string
title: string
model: string | null
timestamp: string
messages: Array<{
id: string
role: 'system' | 'user' | 'assistant'
content: string
timestamp: string
}>
}>(`/chat/sessions/${sessionId}`)
return response.data
})()
}
async createChatSession(title: string, model?: string) {
return catchInternal(async () => {
const response = await this.client.post<{
id: string
title: string
model: string | null
timestamp: string
}>('/chat/sessions', { title, model })
return response.data
})()
}
async updateChatSession(sessionId: string, data: { title?: string; model?: string }) {
return catchInternal(async () => {
const response = await this.client.put<{
id: string
title: string
model: string | null
timestamp: string
}>(`/chat/sessions/${sessionId}`, data)
return response.data
})()
}
async deleteChatSession(sessionId: string) {
return catchInternal(async () => {
await this.client.delete(`/chat/sessions/${sessionId}`)
})()
}
async deleteAllChatSessions() {
return catchInternal(async () => {
const response = await this.client.delete<{ success: boolean; message: string }>(
'/chat/sessions/all'
)
return response.data
})()
}
async addChatMessage(sessionId: string, role: 'system' | 'user' | 'assistant', content: string) {
return catchInternal(async () => {
const response = await this.client.post<{
id: string
role: 'system' | 'user' | 'assistant'
content: string
timestamp: string
}>(`/chat/sessions/${sessionId}/messages`, { role, content })
return response.data
})()
}
async getStoredRAGFiles() {
return catchInternal(async () => {
const response = await this.client.get<{ files: string[] }>('/rag/files')
return response.data.files
})()
}
async getSystemInfo() {
return catchInternal(async () => {
const response = await this.client.get<SystemInformationResponse>('/system/info')
@ -295,6 +401,23 @@ class API {
return response.data
})()
}
async uploadDocument(file: File) {
return catchInternal(async () => {
const formData = new FormData()
formData.append('file', file)
const response = await this.client.post<{ message: string; file_path: string }>(
'/rag/upload',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data
})()
}
}
export default new API()

View File

@ -0,0 +1,11 @@
import { Head } from '@inertiajs/react'
import ChatComponent from '~/components/chat'
export default function Chat() {
return (
<div className="w-full h-full">
<Head title="AI Assistant" />
<ChatComponent enabled={true} />
</div>
)
}

View File

@ -1,12 +1,50 @@
import { Head } from '@inertiajs/react'
import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import FileUploader from '~/components/file-uploader'
import StyledButton from '~/components/StyledButton'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import StyledTable from '~/components/StyledTable'
import { useNotifications } from '~/context/NotificationContext'
import AppLayout from '~/layouts/AppLayout'
import api from '~/lib/api'
export default function KnowledgeBase() {
const [loading, setLoading] = useState(false)
const { addNotification } = useNotifications()
const [files, setFiles] = useState<File[]>([])
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({
queryKey: ['storedFiles'],
queryFn: () => api.getStoredRAGFiles(),
select: (data) => data || [],
})
const uploadMutation = useMutation({
mutationFn: (file: File) => api.uploadDocument(file),
onSuccess: (data) => {
addNotification({
type: 'success',
message: data?.message || 'Document uploaded and queued for processing',
})
setFiles([])
if (fileUploaderRef.current) {
fileUploaderRef.current.clear()
}
},
onError: (error: any) => {
addNotification({
type: 'error',
message: error?.message || 'Failed to upload document',
})
},
})
const handleUpload = () => {
if (files.length > 0) {
uploadMutation.mutate(files[0])
}
}
return (
<AppLayout>
@ -15,12 +53,11 @@ export default function KnowledgeBase() {
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
<div className="p-6">
<FileUploader
ref={fileUploaderRef}
minFiles={1}
maxFiles={1}
onUpload={(files) => {
setLoading(true)
setFiles(Array.from(files))
setLoading(false)
onUpload={(uploadedFiles) => {
setFiles(Array.from(uploadedFiles))
}}
/>
<div className="flex justify-center gap-4 my-6">
@ -28,9 +65,9 @@ export default function KnowledgeBase() {
variant="primary"
size="lg"
icon="ArrowUpCircleIcon"
onClick={() => {}}
disabled={files.length === 0 || loading}
loading={loading}
onClick={handleUpload}
disabled={files.length === 0 || uploadMutation.isPending}
loading={uploadMutation.isPending}
>
Upload
</StyledButton>
@ -91,6 +128,25 @@ export default function KnowledgeBase() {
</div>
</div>
</div>
<div className="my-12">
<StyledSectionHeader title="Stored Knowledge Base Files" />
<StyledTable<{ source: string }>
className="font-semibold"
rowLines={true}
columns={[
{
accessor: 'source',
title: 'File Name',
render(record) {
return <span className="text-gray-700">{record.source}</span>
},
},
]}
data={storedFiles.map((source) => ({ source }))}
loading={isLoadingFiles}
/>
</div>
</main>
</AppLayout>
)

View File

@ -50,11 +50,6 @@ export default function LegalPage() {
<br />
<a href="https://learningequality.org/kolibri" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://learningequality.org/kolibri</a>
</li>
<li>
<strong>Open WebUI</strong> - Web interface for local AI models (MIT License)
<br />
<a href="https://openwebui.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://openwebui.com</a>
</li>
<li>
<strong>Ollama</strong> - Local large language model runtime (MIT License)
<br />

View File

@ -1,7 +1,7 @@
import { Head, router } from '@inertiajs/react'
import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
import { NomadOllamaModel, OllamaModelListing } from '../../../types/ollama'
import { NomadOllamaModel } from '../../../types/ollama'
import StyledButton from '~/components/StyledButton'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import Alert from '~/components/Alert'
@ -9,9 +9,10 @@ import { useNotifications } from '~/context/NotificationContext'
import api from '~/lib/api'
import { useModals } from '~/context/ModalContext'
import StyledModal from '~/components/StyledModal'
import { ModelResponse } from 'ollama'
export default function ModelsPage(props: {
models: { availableModels: NomadOllamaModel[]; installedModels: OllamaModelListing[] }
models: { availableModels: NomadOllamaModel[]; installedModels: ModelResponse[] }
}) {
const { isInstalled } = useServiceInstalledStatus('nomad_open_webui')
const { addNotification } = useNotifications()

1460
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -102,14 +102,15 @@
"pdf-parse": "^2.4.5",
"pdf2pic": "^3.2.0",
"pino-pretty": "^13.0.0",
"playwright": "^1.58.0",
"pmtiles": "^4.3.0",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-adonis-transmit": "^1.0.1",
"react-dom": "^19.1.0",
"react-map-gl": "^8.1.0",
"react-markdown": "^10.1.0",
"reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"systeminformation": "^5.27.14",
"tailwindcss": "^4.1.10",

View File

@ -7,12 +7,14 @@
|
*/
import BenchmarkController from '#controllers/benchmark_controller'
import ChatsController from '#controllers/chats_controller'
import DocsController from '#controllers/docs_controller'
import DownloadsController from '#controllers/downloads_controller'
import EasySetupController from '#controllers/easy_setup_controller'
import HomeController from '#controllers/home_controller'
import MapsController from '#controllers/maps_controller'
import OpenWebUIController from '#controllers/openwebui_controller'
import OllamaController from '#controllers/ollama_controller'
import RagController from '#controllers/rag_controller'
import SettingsController from '#controllers/settings_controller'
import SystemController from '#controllers/system_controller'
import ZimController from '#controllers/zim_controller'
@ -24,6 +26,7 @@ transmit.registerRoutes()
router.get('/', [HomeController, 'index'])
router.get('/home', [HomeController, 'home'])
router.on('/about').renderInertia('about')
router.on('/chat').renderInertia('chat')
router.on('/knowledge-base').renderInertia('knowledge-base')
router.get('/maps', [MapsController, 'index'])
@ -90,12 +93,32 @@ router.get('/api/health', () => {
router
.group(() => {
router.get('/models', [OpenWebUIController, 'models'])
router.get('/installed-models', [OpenWebUIController, 'installedModels'])
router.post('/download-model', [OpenWebUIController, 'dispatchModelDownload'])
router.post('/delete-model', [OpenWebUIController, 'deleteModel'])
router.post('/chat', [OllamaController, 'chat'])
router.get('/models', [OllamaController, 'availableModels'])
router.post('/models', [OllamaController, 'dispatchModelDownload'])
router.delete('/models', [OllamaController, 'deleteModel'])
router.get('/installed-models', [OllamaController, 'installedModels'])
})
.prefix('/api/openwebui')
.prefix('/api/ollama')
router
.group(() => {
router.get('/', [ChatsController, 'index'])
router.post('/', [ChatsController, 'store'])
router.delete('/all', [ChatsController, 'destroyAll'])
router.get('/:id', [ChatsController, 'show'])
router.put('/:id', [ChatsController, 'update'])
router.delete('/:id', [ChatsController, 'destroy'])
router.post('/:id/messages', [ChatsController, 'addMessage'])
})
.prefix('/api/chat/sessions')
router
.group(() => {
router.post('/upload', [RagController, 'upload'])
router.get('/files', [RagController, 'getStoredFiles'])
})
.prefix('/api/rag')
router
.group(() => {

14
admin/types/chat.ts Normal file
View File

@ -0,0 +1,14 @@
export interface ChatMessage {
id: string
role: 'system' | 'user' | 'assistant'
content: string
timestamp: Date
isStreaming?: boolean
}
export interface ChatSession {
id: string
title: string
lastMessage?: string
timestamp: Date
}

View File

@ -21,19 +21,23 @@ export type NomadOllamaModelAPIResponse = {
models: NomadOllamaModel[]
}
export type OllamaModelListing = {
name: string
id: string
size: string
modified: string
export type OllamaChatMessage = {
role: 'system' | 'user' | 'assistant'
content: string
}
export type OllamaChatRequest = {
model: string
messages: OllamaChatMessage[]
stream?: boolean
}
export type OpenWebUIKnowledgeFileMetadata = {
source: string
name: string
created_by: string
file_id: string
start_index: number
hash: string
}
export type OllamaChatResponse = {
model: string
created_at: string
message: {
role: string
content: string
}
done: boolean
}