feat(RAG): [wip] RAG capabilities

This commit is contained in:
Jake Turner 2026-01-27 04:46:11 +00:00 committed by Jake Turner
parent c78736c8da
commit 50174d2edb
19 changed files with 2165 additions and 54 deletions

View File

@ -1,7 +1,7 @@
FROM node:22.16.0-alpine3.22 AS base
# Install bash & curl for entrypoint script compatibility
RUN apk add --no-cache bash curl
# Install bash & curl for entrypoint script compatibility, graphicsmagick for pdf2pic, and vips-dev & build-base for sharp
RUN apk add --no-cache bash curl graphicsmagick vips-dev build-base
# All deps stage
FROM base AS deps

View File

@ -0,0 +1,18 @@
import { cuid } from '@adonisjs/core/helpers'
import type { HttpContext } from '@adonisjs/core/http'
import app from '@adonisjs/core/services/app'
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}`
await uploadedFile.move(app.makePath('storage/uploads'), {
name: fileName,
})
}
}

View File

@ -0,0 +1,46 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
import type { KVStoreKey, KVStoreValue } from '../../types/kv_store.js'
/**
* Generic key-value store model for storing various settings
* that don't necessitate their own dedicated models.
*/
export default class KVStore extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
@column()
declare key: KVStoreKey
@column()
declare value: KVStoreValue
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
/**
* Get a setting value by key
*/
static async getValue(key: KVStoreKey): Promise<KVStoreValue> {
const setting = await this.findBy('key', key)
return setting?.value ?? null
}
/**
* Set a setting value by key (creates if not exists)
*/
static async setValue(key: KVStoreKey, value: KVStoreValue): Promise<KVStore> {
const setting = await this.firstOrCreate({ key }, { key, value })
if (setting.value !== value) {
setting.value = value
await setting.save()
}
return setting
}
}

View File

@ -426,21 +426,10 @@ export class BenchmarkService {
}
// Check if the benchmark model is available, pull if not
const modelsResponse = await axios.get(`${ollamaAPIURL}/api/tags`)
const models = modelsResponse.data.models || []
const hasModel = models.some((m: any) => m.name === AI_BENCHMARK_MODEL || m.name.startsWith(AI_BENCHMARK_MODEL.split(':')[0]))
if (!hasModel) {
this._updateStatus('downloading_ai_model', `Downloading AI benchmark model (${AI_BENCHMARK_MODEL})... This may take a few minutes on first run.`)
logger.info(`[BenchmarkService] Model ${AI_BENCHMARK_MODEL} not found, downloading...`)
try {
// Model pull can take several minutes, use longer timeout
await axios.post(`${ollamaAPIURL}/api/pull`, { name: AI_BENCHMARK_MODEL }, { timeout: 600000 })
logger.info(`[BenchmarkService] Model ${AI_BENCHMARK_MODEL} downloaded successfully`)
} catch (pullError) {
throw new Error(`Failed to download AI benchmark model (${AI_BENCHMARK_MODEL}): ${pullError.message}`)
}
const openWebUIService = new (await import('./openwebui_service.js')).OpenWebUIService(this.dockerService)
const modelResponse = await openWebUIService.downloadModelSync(AI_BENCHMARK_MODEL)
if (!modelResponse.success) {
throw new Error(`Model does not exist and failed to download: ${modelResponse.message}`)
}
// Run inference benchmark

View File

@ -13,6 +13,7 @@ export class DockerService {
private activeInstallations: Set<string> = new Set()
public static KIWIX_SERVICE_NAME = 'nomad_kiwix_serve'
public static OLLAMA_SERVICE_NAME = 'nomad_ollama'
public static QDRANT_SERVICE_NAME = 'nomad_qdrant'
public static OPEN_WEBUI_SERVICE_NAME = 'nomad_open_webui'
public static CYBERCHEF_SERVICE_NAME = 'nomad_cyberchef'
public static FLATNOTES_SERVICE_NAME = 'nomad_flatnotes'

View File

@ -8,6 +8,9 @@ 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')
@ -15,6 +18,10 @@ 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,
@ -200,6 +207,45 @@ export class OpenWebUIService {
})
}
/**
* 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 {
@ -529,6 +575,163 @@ export class OpenWebUIService {
})
}
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> {

View File

@ -0,0 +1,262 @@
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'
@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 async _initializeQdrantClient() {
if (!this.qdrantInitPromise) {
this.qdrantInitPromise = (async () => {
const qdrantUrl = await this.dockerService.getServiceURL(DockerService.QDRANT_SERVICE_NAME)
if (!qdrantUrl) {
throw new Error('Qdrant service is not installed or running.')
}
this.qdrant = new QdrantClient({ url: `http://${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(
collectionName: string,
dimensions: number = RagService.EMBEDDING_DIMENSION
) {
try {
await this._ensureDependencies()
const collections = await this.qdrant!.getCollections()
const collectionExists = collections.collections.some((col) => col.name === collectionName)
if (!collectionExists) {
await this.qdrant!.createCollection(collectionName, {
vectors: {
size: dimensions,
distance: 'Cosine',
},
})
}
} catch (error) {
logger.error('Error ensuring Qdrant collection:', error)
throw error
}
}
public async embedAndStoreText(
text: string,
metadata: Record<string, any> = {}
): Promise<{ chunks: number } | null> {
try {
await this._ensureCollection(
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 chunks = chunk(text, {
// These settings should provide a good balance between context and precision
minLength: 512,
maxLength: 1024,
overlap: 200,
})
if (!chunks || chunks.length === 0) {
throw new Error('No text chunks generated for embedding.')
}
const embeddings: number[][] = []
for (const chunkText of chunks) {
const response = await this.ollama!.embeddings({
model: RagService.EMBEDDING_MODEL,
prompt: chunkText,
})
embeddings.push(response.embedding)
}
const points = chunks.map((chunkText, index) => ({
id: `${Date.now()}_${index}`,
vector: embeddings[index],
payload: {
...metadata,
text: chunkText,
chunk_index: index,
},
}))
await this.qdrant!.upsert(RagService.CONTENT_COLLECTION_NAME, { points })
return { chunks: chunks.length }
} catch (error) {
logger.error('Error embedding text:', error)
return null
}
}
/**
* Preprocess an image to enhance text extraction quality.
* Normalizes, grayscales, sharpens, and resizes the image to a manageable size.
* @param filebuffer Buffer of the image file
* @returns - Processed image buffer
*/
private async preprocessImage(filebuffer: Buffer): Promise<Buffer> {
return await sharp(filebuffer)
.grayscale()
.normalize()
.sharpen()
.resize({ width: 2000, fit: 'inside' })
.toBuffer()
}
/**
* If the original PDF has little to no extractable text,
* we can use this method to convert each page to an image for OCR processing.
* @param filebuffer - Buffer of the PDF file
* @returns - Array of image buffers, one per page
*/
private async convertPDFtoImages(filebuffer: Buffer): Promise<Buffer[]> {
const converted = await fromBuffer(filebuffer, {
quality: 50,
density: 200,
format: 'png',
}).bulk(-1, {
responseType: 'buffer',
})
return converted.filter((res) => res.buffer).map((res) => res.buffer!)
}
/**
* Extract text from a PDF file using pdf-parse.
* @param filebuffer - Buffer of the PDF file
* @returns - Extracted text
*/
private async extractPDFText(filebuffer: Buffer): Promise<string> {
const parser = new PDFParse({ data: filebuffer })
const data = await parser.getText()
await parser.destroy()
return data.text
}
/**
* Extract text from a plain text file.
* @param filebuffer - Buffer of the text file
* @returns - Extracted text
*/
private async extractTXTText(filebuffer: Buffer): Promise<string> {
return filebuffer.toString('utf-8')
}
/**
* Extract text from an image file using Tesseract.js OCR.
* @param filebuffer - Buffer of the image file
* @returns - Extracted text
*/
private async extractImageText(filebuffer: Buffer): Promise<string> {
const worker = await createWorker('eng')
const result = await worker.recognize(filebuffer)
await worker.terminate()
return result.data.text
}
/**
* Main pipeline to process and embed an uploaded file into the RAG knowledge base.
* This includes text extraction, chunking, embedding, and storing in Qdrant.
*/
public async processAndEmbedFile(
filepath: string
): Promise<{ success: boolean; message: string }> {
try {
const fileType = determineFileType(filepath)
if (fileType === 'unknown') {
return { success: false, message: 'Unsupported file type.' }
}
const origFileBuffer = await getFile(filepath, 'buffer')
if (!origFileBuffer) {
return { success: false, message: 'Failed to read the uploaded file.' }
}
let extractedText = ''
if (fileType === 'image') {
const preprocessedBuffer = await this.preprocessImage(origFileBuffer)
extractedText = await this.extractImageText(preprocessedBuffer)
} else if (fileType === 'pdf') {
extractedText = await this.extractPDFText(origFileBuffer)
// Check if there was no extracted text or it was very minimal
if (!extractedText || extractedText.trim().length < 100) {
// Convert PDF pages to images for OCR
const imageBuffers = await this.convertPDFtoImages(origFileBuffer)
for (const imgBuffer of imageBuffers) {
const preprocessedImg = await this.preprocessImage(imgBuffer)
const pageText = await this.extractImageText(preprocessedImg)
extractedText += pageText + '\n'
}
}
} else {
extractedText = await this.extractTXTText(origFileBuffer)
}
if (!extractedText || extractedText.trim().length === 0) {
return { success: false, message: 'No text could be extracted from the file.' }
}
const embedResult = await this.embedAndStoreText(extractedText, {})
return { success: true, message: 'File processed and embedded successfully.' }
} catch (error) {
logger.error('Error processing and embedding file:', error)
return { success: false, message: 'Error processing and embedding file.' }
}
}
}

View File

@ -1,5 +1,5 @@
import { mkdir, readdir, readFile, stat, unlink } from 'fs/promises'
import { join } from 'path'
import path, { join } from 'path'
import { FileEntry } from '../../types/files.js'
import { createReadStream } from 'fs'
import { LSBlockDevice, NomadDiskInfoRaw } from '../../types/system.js'
@ -151,3 +151,16 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean {
return false
}
export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'unknown' {
const ext = path.extname(filename).toLowerCase()
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'].includes(ext)) {
return 'image'
} else if (ext === '.pdf') {
return 'pdf'
} else if (['.txt', '.md', '.docx', '.rtf'].includes(ext)) {
return 'text'
} else {
return 'unknown'
}
}

View File

@ -0,0 +1,19 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'kv_store'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('key').unique().notNullable()
table.text('value').nullable()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -3,17 +3,25 @@ import { DockerService } from '#services/docker_service'
import { BaseSeeder } from '@adonisjs/lucid/seeders'
import { ModelAttributes } from '@adonisjs/lucid/types/model'
import env from '#start/env'
import { RagService } from '#services/rag_service'
export default class ServiceSeeder extends BaseSeeder {
// Use environment variable with fallback to production default
private static NOMAD_STORAGE_ABS_PATH = env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')
private static DEFAULT_SERVICES: Omit<ModelAttributes<Service>, 'created_at' | 'updated_at' | 'metadata' | 'id'>[] = [
private static NOMAD_STORAGE_ABS_PATH = env.get(
'NOMAD_STORAGE_PATH',
'/opt/project-nomad/storage'
)
private static DEFAULT_SERVICES: Omit<
ModelAttributes<Service>,
'created_at' | 'updated_at' | 'metadata' | 'id'
>[] = [
{
service_name: DockerService.KIWIX_SERVICE_NAME,
friendly_name: 'Information Library',
powered_by: 'Kiwix',
display_order: 1,
description: 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias',
description:
'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias',
icon: 'IconBooks',
container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',
container_command: '*.zim --address=all',
@ -21,9 +29,9 @@ export default class ServiceSeeder extends BaseSeeder {
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/zim:/data`],
PortBindings: { '8080/tcp': [{ HostPort: '8090' }] }
PortBindings: { '8080/tcp': [{ HostPort: '8090' }] },
},
ExposedPorts: { '8080/tcp': {} }
ExposedPorts: { '8080/tcp': {} },
}),
ui_location: '8090',
installed: false,
@ -31,6 +39,29 @@ export default class ServiceSeeder extends BaseSeeder {
is_dependency_service: false,
depends_on: null,
},
{
service_name: DockerService.QDRANT_SERVICE_NAME,
friendly_name: 'Qdrant Vector Database',
powered_by: null,
display_order: 100, // Dependency service, not shown directly
description: 'Vector database for storing and searching embeddings',
icon: 'IconRobot',
container_image: 'qdrant/qdrant:latest',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/qdrant:/qdrant/storage`],
PortBindings: { '6333/tcp': [{ HostPort: '6333' }], '6334/tcp': [{ HostPort: '6334' }] },
},
ExposedPorts: { '6333/tcp': {}, '6334/tcp': {} },
}),
ui_location: '6333',
installed: false,
installation_status: 'idle',
is_dependency_service: true,
depends_on: null,
},
{
service_name: DockerService.OLLAMA_SERVICE_NAME,
friendly_name: 'Ollama',
@ -44,15 +75,15 @@ export default class ServiceSeeder extends BaseSeeder {
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/ollama:/root/.ollama`],
PortBindings: { '11434/tcp': [{ HostPort: '11434' }] }
PortBindings: { '11434/tcp': [{ HostPort: '11434' }] },
},
ExposedPorts: { '11434/tcp': {} }
ExposedPorts: { '11434/tcp': {} },
}),
ui_location: null,
installed: false,
installation_status: 'idle',
is_dependency_service: true,
depends_on: null,
depends_on: DockerService.QDRANT_SERVICE_NAME,
},
{
service_name: DockerService.OPEN_WEBUI_SERVICE_NAME,
@ -68,9 +99,17 @@ export default class ServiceSeeder extends BaseSeeder {
RestartPolicy: { Name: 'unless-stopped' },
NetworkMode: 'host',
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/open-webui:/app/backend/data`],
PortBindings: { '8080/tcp': [{ HostPort: '3000' }] }
PortBindings: { '8080/tcp': [{ HostPort: '3000' }] },
},
Env: ['WEBUI_AUTH=False', 'PORT=3000', 'OLLAMA_BASE_URL=http://127.0.0.1:11434']
Env: [
'WEBUI_AUTH=False',
'PORT=3000',
'OLLAMA_BASE_URL=http://127.0.0.1:11434',
'VECTOR_DB=qdrant',
'QDRANT_URI=http://127.0.0.1:6333',
'RAG_EMBEDDING_ENGINE=ollama',
`RAG_EMBEDDING_MODEL=${RagService.EMBEDDING_MODEL}`,
],
}),
ui_location: '3000',
installed: false,
@ -90,9 +129,9 @@ export default class ServiceSeeder extends BaseSeeder {
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '80/tcp': [{ HostPort: '8100' }] }
PortBindings: { '80/tcp': [{ HostPort: '8100' }] },
},
ExposedPorts: { '80/tcp': {} }
ExposedPorts: { '80/tcp': {} },
}),
ui_location: '8100',
installed: false,
@ -113,10 +152,10 @@ export default class ServiceSeeder extends BaseSeeder {
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8080/tcp': [{ HostPort: '8200' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/flatnotes:/data`]
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/flatnotes:/data`],
},
ExposedPorts: { '8080/tcp': {} },
Env: ['FLATNOTES_AUTH_TYPE=none']
Env: ['FLATNOTES_AUTH_TYPE=none'],
}),
ui_location: '8200',
installed: false,
@ -137,7 +176,7 @@ export default class ServiceSeeder extends BaseSeeder {
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8080/tcp': [{ HostPort: '8300' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri:/root/.kolibri`]
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri:/root/.kolibri`],
},
ExposedPorts: { '8080/tcp': {} },
}),
@ -146,15 +185,17 @@ export default class ServiceSeeder extends BaseSeeder {
installation_status: 'idle',
is_dependency_service: false,
depends_on: null,
}
},
]
async run() {
const existingServices = await Service.query().select('service_name')
const existingServiceNames = new Set(existingServices.map(service => service.service_name))
const existingServiceNames = new Set(existingServices.map((service) => service.service_name))
const newServices = ServiceSeeder.DEFAULT_SERVICES.filter(service => !existingServiceNames.has(service.service_name))
const newServices = ServiceSeeder.DEFAULT_SERVICES.filter(
(service) => !existingServiceNames.has(service.service_name)
)
await Service.createMany([...newServices])
}
}
}

View File

@ -0,0 +1,3 @@
.uppy-size--md .uppy-Dashboard-AddFiles-title {
font-size: 1.15rem !important;
}

View File

@ -0,0 +1,84 @@
import React, { useState } from 'react'
import Uppy from '@uppy/core'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
import { useUppyEvent } from '@uppy/react'
import Dashboard from '@uppy/react/dashboard'
import classNames from 'classnames'
import './index.css' // Custom styles for the uploader
interface FileUploaderProps {
minFiles?: number // minimum number of files required
maxFiles?: number
maxFileSize?: number // in bytes, e.g., 10485760 for 10MB
fileTypes?: string[] // e.g., ['image/*', 'application/pdf']
disabled?: boolean
onUpload: (files: FileList) => void
className?: string
}
/**
* 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 [uppy] = useState(() => {
const uppy = new Uppy({
debug: true,
restrictions: {
maxFileSize: maxFileSize,
minNumberOfFiles: minFiles,
maxNumberOfFiles: maxFiles,
allowedFileTypes: fileTypes || undefined,
},
})
return uppy
})
useUppyEvent(uppy, 'state-update', (_, newState) => {
const stateFiles = Object.values(newState.files)
const dataTransfer = new DataTransfer()
stateFiles.forEach((file) => {
if (file.data) {
if (file.data instanceof File) {
dataTransfer.items.add(file.data)
} else if (file.data instanceof Blob) {
const newFile = new File(
[file.data],
file.name || `${crypto.randomUUID()}.${file.extension}`,
{
type: file.type,
lastModified: new Date().getTime(),
}
)
dataTransfer.items.add(newFile)
}
}
})
const fileList = dataTransfer.files
onUpload(fileList) // Always send new file list even if empty
})
return (
<Dashboard
uppy={uppy}
width={'100%'}
height={'250px'}
hideUploadButton
disabled={disabled}
className={classNames(className)}
/>
)
}
export default FileUploader

View File

@ -1,5 +1,6 @@
import {
IconBolt,
IconBrain,
IconHelp,
IconMapRoute,
IconPlus,
@ -80,6 +81,17 @@ interface DashboardItem {
poweredBy: string | null
}
const KNOWLEDGE_BASE_ITEM: DashboardItem = {
label: 'Knowledge Base',
to: '/knowledge-base',
target: '',
description: 'Upload documents to your personal knowledge base for AI access',
icon: <IconBrain size={48} />,
installed: true,
displayOrder: 5,
poweredBy: null,
}
export default function Home(props: {
system: {
services: ServiceSlim[]
@ -114,6 +126,9 @@ export default function Home(props: {
// Add system items
items.push(...SYSTEM_ITEMS)
if (props.system.services.find((s) => s.service_name === 'nomad_open_webui' && s.installed)) {
items.push(KNOWLEDGE_BASE_ITEM)
}
// Sort all items by display order
items.sort((a, b) => a.displayOrder - b.displayOrder)
@ -130,9 +145,7 @@ export default function Home(props: {
>
<div className="flex items-center justify-center mb-2">{item.icon}</div>
<h3 className="font-bold text-2xl">{item.label}</h3>
{item.poweredBy && (
<p className="text-sm opacity-80">Powered by {item.poweredBy}</p>
)}
{item.poweredBy && <p className="text-sm opacity-80">Powered by {item.poweredBy}</p>}
<p className="xl:text-lg mt-2">{item.description}</p>
</div>
</a>

View File

@ -0,0 +1,97 @@
import { Head } from '@inertiajs/react'
import { useState } from 'react'
import FileUploader from '~/components/file-uploader'
import StyledButton from '~/components/StyledButton'
import AppLayout from '~/layouts/AppLayout'
export default function KnowledgeBase() {
const [loading, setLoading] = useState(false)
const [files, setFiles] = useState<File[]>([])
return (
<AppLayout>
<Head title="Knowledge Base" />
<main className="px-6 lg:px-12 py-6 lg:py-8">
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
<div className="p-6">
<FileUploader
minFiles={1}
maxFiles={1}
onUpload={(files) => {
setLoading(true)
setFiles(Array.from(files))
setLoading(false)
}}
/>
<div className="flex justify-center gap-4 my-6">
<StyledButton
variant="primary"
size="lg"
icon="ArrowUpCircleIcon"
onClick={() => {}}
disabled={files.length === 0 || loading}
loading={loading}
>
Upload
</StyledButton>
</div>
</div>
<div className="border-t bg-white p-6">
<h3 className="text-lg font-semibold text-desert-green mb-4">
Why upload documents to your Knowledge Base?
</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
1
</div>
<div>
<p className="font-medium text-desert-stone-dark">
AI Assistant Knowledge Base Integration
</p>
<p className="text-sm text-desert-stone">
When you upload documents to your Knowledge Base, NOMAD processes and embeds the
content, making it directly accessible to the AI Assistant. This allows the AI
Assistant to reference your specific documents during conversations, providing
more accurate and personalized responses based on your uploaded data.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
2
</div>
<div>
<p className="font-medium text-desert-stone-dark">
Enhanced Document Processing with OCR
</p>
<p className="text-sm text-desert-stone">
NOMAD includes built-in Optical Character Recognition (OCR) capabilities,
allowing it to extract text from image-based documents such as scanned PDFs or
photos. This means that even if your documents are not in a standard text
format, NOMAD can still process and embed their content for AI access.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold">
3
</div>
<div>
<p className="font-medium text-desert-stone-dark">
Information Library Integration
</p>
<p className="text-sm text-desert-stone">
NOMAD will automatically discover and extract any content you save to your
Information Library (if installed), making it instantly available to the AI
Assistant without any extra steps.
</p>
</div>
</div>
</div>
</div>
</div>
</main>
</AppLayout>
)
}

1325
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -76,11 +76,15 @@
"@inertiajs/react": "^2.0.13",
"@markdoc/markdoc": "^0.5.2",
"@protomaps/basemaps": "^5.7.0",
"@qdrant/js-client-rest": "^1.16.2",
"@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query-devtools": "^5.83.0",
"@tanstack/react-virtual": "^3.13.12",
"@uppy/core": "^5.2.0",
"@uppy/dashboard": "^5.1.0",
"@uppy/react": "^5.1.1",
"@vinejs/vine": "^3.0.1",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21",
@ -90,10 +94,15 @@
"dockerode": "^4.0.7",
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.2.5",
"llm-chunk": "^0.0.1",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
"mysql2": "^3.14.1",
"ollama": "^0.6.3",
"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",
@ -101,9 +110,11 @@
"react-dom": "^19.1.0",
"react-map-gl": "^8.1.0",
"reflect-metadata": "^0.2.2",
"sharp": "^0.34.5",
"systeminformation": "^5.27.14",
"tailwindcss": "^4.1.10",
"tar": "^7.5.6",
"tesseract.js": "^7.0.0",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.1",
"yaml": "^2.8.0"

View File

@ -24,6 +24,7 @@ transmit.registerRoutes()
router.get('/', [HomeController, 'index'])
router.get('/home', [HomeController, 'home'])
router.on('/about').renderInertia('about')
router.on('/knowledge-base').renderInertia('knowledge-base')
router.get('/maps', [MapsController, 'index'])
router.get('/easy-setup', [EasySetupController, 'index'])

3
admin/types/kv_store.ts Normal file
View File

@ -0,0 +1,3 @@
export type KVStoreKey = 'open_webui_knowledge_id'
export type KVStoreValue = string | null

View File

@ -27,3 +27,13 @@ export type OllamaModelListing = {
size: string
modified: string
}
export type OpenWebUIKnowledgeFileMetadata = {
source: string
name: string
created_by: string
file_id: string
start_index: number
hash: string
}