mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-08 09:46:15 +02:00
feat: [wip] native AI chat interface
This commit is contained in:
parent
50174d2edb
commit
243f749090
|
|
@ -30,7 +30,7 @@ Project N.O.M.A.D. is now installed on your device! Open a browser and navigate
|
||||||
|
|
||||||
## How It Works
|
## 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
|
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
85
admin/app/controllers/chats_controller.ts
Normal file
85
admin/app/controllers/chats_controller.ts
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
admin/app/controllers/ollama_controller.ts
Normal file
92
admin/app/controllers/ollama_controller.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 type { HttpContext } from '@adonisjs/core/http'
|
||||||
import app from '@adonisjs/core/services/app'
|
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) {
|
public async upload({ request, response }: HttpContext) {
|
||||||
const uploadedFile = request.file('file')
|
const uploadedFile = request.file('file')
|
||||||
if (!uploadedFile) {
|
if (!uploadedFile) {
|
||||||
return response.status(400).json({ error: 'No file uploaded' })
|
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'), {
|
await uploadedFile.move(app.makePath('storage/uploads'), {
|
||||||
name: fileName,
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { BenchmarkService } from '#services/benchmark_service';
|
import { BenchmarkService } from '#services/benchmark_service';
|
||||||
import { MapService } from '#services/map_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 { SystemService } from '#services/system_service';
|
||||||
import { inject } from '@adonisjs/core';
|
import { inject } from '@adonisjs/core';
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
|
@ -10,8 +10,8 @@ export default class SettingsController {
|
||||||
constructor(
|
constructor(
|
||||||
private systemService: SystemService,
|
private systemService: SystemService,
|
||||||
private mapService: MapService,
|
private mapService: MapService,
|
||||||
private openWebUIService: OpenWebUIService,
|
private benchmarkService: BenchmarkService,
|
||||||
private benchmarkService: BenchmarkService
|
private ollamaService: OllamaService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async system({ inertia }: HttpContext) {
|
async system({ inertia }: HttpContext) {
|
||||||
|
|
@ -48,8 +48,8 @@ export default class SettingsController {
|
||||||
}
|
}
|
||||||
|
|
||||||
async models({ inertia }: HttpContext) {
|
async models({ inertia }: HttpContext) {
|
||||||
const availableModels = await this.openWebUIService.getAvailableModels();
|
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false });
|
||||||
const installedModels = await this.openWebUIService.getInstalledModels();
|
const installedModels = await this.ollamaService.getModels();
|
||||||
return inertia.render('settings/models', {
|
return inertia.render('settings/models', {
|
||||||
models: {
|
models: {
|
||||||
availableModels: availableModels || [],
|
availableModels: availableModels || [],
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { Job } from 'bullmq'
|
import { Job } from 'bullmq'
|
||||||
import { QueueService } from '#services/queue_service'
|
import { QueueService } from '#services/queue_service'
|
||||||
import { OpenWebUIService } from '#services/openwebui_service'
|
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import { DockerService } from '#services/docker_service'
|
import { OllamaService } from '#services/ollama_service'
|
||||||
|
|
||||||
export interface DownloadModelJobParams {
|
export interface DownloadModelJobParams {
|
||||||
modelName: string
|
modelName: string
|
||||||
|
|
@ -27,26 +26,23 @@ export class DownloadModelJob {
|
||||||
|
|
||||||
logger.info(`[DownloadModelJob] Attempting to download model: ${modelName}`)
|
logger.info(`[DownloadModelJob] Attempting to download model: ${modelName}`)
|
||||||
|
|
||||||
// Check if OpenWebUI/Ollama services are ready
|
const ollamaService = new OllamaService()
|
||||||
const dockerService = new DockerService()
|
|
||||||
const openWebUIService = new OpenWebUIService(dockerService)
|
|
||||||
|
|
||||||
// Use getInstalledModels to check if the service is ready
|
|
||||||
// Even if no models are installed, this should return an empty array if 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) {
|
if (!existingModels) {
|
||||||
logger.warn(
|
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(
|
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
|
// 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
|
// Update job progress in BullMQ
|
||||||
const progressData = {
|
const progressData = {
|
||||||
status: progress.status,
|
status: progress.status,
|
||||||
|
|
|
||||||
29
admin/app/models/chat_message.ts
Normal file
29
admin/app/models/chat_message.ts
Normal 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
|
||||||
|
}
|
||||||
29
admin/app/models/chat_session.ts
Normal file
29
admin/app/models/chat_session.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -426,8 +426,8 @@ export class BenchmarkService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the benchmark model is available, pull if not
|
// Check if the benchmark model is available, pull if not
|
||||||
const openWebUIService = new (await import('./openwebui_service.js')).OpenWebUIService(this.dockerService)
|
const ollamaService = new (await import('./ollama_service.js')).OllamaService()
|
||||||
const modelResponse = await openWebUIService.downloadModelSync(AI_BENCHMARK_MODEL)
|
const modelResponse = await ollamaService.downloadModelSync(AI_BENCHMARK_MODEL)
|
||||||
if (!modelResponse.success) {
|
if (!modelResponse.success) {
|
||||||
throw new Error(`Model does not exist and failed to download: ${modelResponse.message}`)
|
throw new Error(`Model does not exist and failed to download: ${modelResponse.message}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
181
admin/app/services/chat_service.ts
Normal file
181
admin/app/services/chat_service.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
492
admin/app/services/ollama_service.ts
Normal file
492
admin/app/services/ollama_service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +1,26 @@
|
||||||
import { Ollama } from 'ollama'
|
|
||||||
import { QdrantClient } from '@qdrant/js-client-rest'
|
import { QdrantClient } from '@qdrant/js-client-rest'
|
||||||
import { DockerService } from './docker_service.js'
|
import { DockerService } from './docker_service.js'
|
||||||
import { inject } from '@adonisjs/core'
|
import { inject } from '@adonisjs/core'
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import { chunk } from 'llm-chunk'
|
import { chunk } from 'llm-chunk'
|
||||||
import { OpenWebUIService } from './openwebui_service.js'
|
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { determineFileType, getFile } from '../utils/fs.js'
|
import { determineFileType, getFile } from '../utils/fs.js'
|
||||||
import { PDFParse } from 'pdf-parse'
|
import { PDFParse } from 'pdf-parse'
|
||||||
import { createWorker } from 'tesseract.js'
|
import { createWorker } from 'tesseract.js'
|
||||||
import { fromBuffer } from 'pdf2pic'
|
import { fromBuffer } from 'pdf2pic'
|
||||||
|
import { OllamaService } from './ollama_service.js'
|
||||||
|
|
||||||
@inject()
|
@inject()
|
||||||
export class RagService {
|
export class RagService {
|
||||||
private qdrant: QdrantClient | null = null
|
private qdrant: QdrantClient | null = null
|
||||||
private ollama: Ollama | null = null
|
|
||||||
private qdrantInitPromise: Promise<void> | 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 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_MODEL = 'nomic-embed-text:v1.5'
|
||||||
public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768
|
public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private dockerService: DockerService,
|
private dockerService: DockerService,
|
||||||
private openWebUIService: OpenWebUIService
|
private ollamaService: OllamaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async _initializeQdrantClient() {
|
private async _initializeQdrantClient() {
|
||||||
|
|
@ -33,32 +30,16 @@ export class RagService {
|
||||||
if (!qdrantUrl) {
|
if (!qdrantUrl) {
|
||||||
throw new Error('Qdrant service is not installed or running.')
|
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
|
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() {
|
private async _ensureDependencies() {
|
||||||
if (!this.qdrant) {
|
if (!this.qdrant) {
|
||||||
await this._initializeQdrantClient()
|
await this._initializeQdrantClient()
|
||||||
}
|
}
|
||||||
if (!this.ollama) {
|
|
||||||
await this._initializeOllamaClient()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _ensureCollection(
|
private async _ensureCollection(
|
||||||
|
|
@ -93,13 +74,13 @@ export class RagService {
|
||||||
RagService.CONTENT_COLLECTION_NAME,
|
RagService.CONTENT_COLLECTION_NAME,
|
||||||
RagService.EMBEDDING_DIMENSION
|
RagService.EMBEDDING_DIMENSION
|
||||||
)
|
)
|
||||||
const initModelResponse = await this.openWebUIService.downloadModelSync(
|
|
||||||
RagService.EMBEDDING_MODEL
|
const allModels = await this.ollamaService.getModels(true)
|
||||||
)
|
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
|
||||||
if (!initModelResponse.success) {
|
|
||||||
throw new Error(
|
// TODO: Attempt to download the embedding model if not found
|
||||||
`${RagService.EMBEDDING_MODEL} does not exist and could not be downloaded: ${initModelResponse.message}`
|
if (!embeddingModel) {
|
||||||
)
|
throw new Error(`${RagService.EMBEDDING_MODEL} does not exist and could not be downloaded.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = chunk(text, {
|
const chunks = chunk(text, {
|
||||||
|
|
@ -114,8 +95,9 @@ export class RagService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const embeddings: number[][] = []
|
const embeddings: number[][] = []
|
||||||
|
const ollamaClient = await this.ollamaService.getClient()
|
||||||
for (const chunkText of chunks) {
|
for (const chunkText of chunks) {
|
||||||
const response = await this.ollama!.embeddings({
|
const response = await ollamaClient.embeddings({
|
||||||
model: RagService.EMBEDDING_MODEL,
|
model: RagService.EMBEDDING_MODEL,
|
||||||
prompt: chunkText,
|
prompt: chunkText,
|
||||||
})
|
})
|
||||||
|
|
@ -213,7 +195,7 @@ export class RagService {
|
||||||
*/
|
*/
|
||||||
public async processAndEmbedFile(
|
public async processAndEmbedFile(
|
||||||
filepath: string
|
filepath: string
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string; chunks?: number }> {
|
||||||
try {
|
try {
|
||||||
const fileType = determineFileType(filepath)
|
const fileType = determineFileType(filepath)
|
||||||
if (fileType === 'unknown') {
|
if (fileType === 'unknown') {
|
||||||
|
|
@ -252,11 +234,113 @@ export class RagService {
|
||||||
|
|
||||||
const embedResult = await this.embedAndStoreText(extractedText, {})
|
const embedResult = await this.embedAndStoreText(extractedText, {})
|
||||||
|
|
||||||
|
return {
|
||||||
return { success: true, message: 'File processed and embedded successfully.' }
|
success: true,
|
||||||
|
message: 'File processed and embedded successfully.',
|
||||||
|
chunks: embedResult?.chunks,
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error processing and embedding file:', error)
|
logger.error('Error processing and embedding file:', error)
|
||||||
return { success: false, message: 'Error processing and embedding file.' }
|
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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,3 +164,12 @@ export function determineFileType(filename: string): 'image' | 'pdf' | 'text' |
|
||||||
return 'unknown'
|
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, '_')
|
||||||
|
}
|
||||||
22
admin/app/validators/chat.ts
Normal file
22
admin/app/validators/chat.ts
Normal 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),
|
||||||
|
})
|
||||||
|
)
|
||||||
21
admin/app/validators/ollama.ts
Normal file
21
admin/app/validators/ollama.ts
Normal 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(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
@ -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(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
@ -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.
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,7 +42,7 @@ createInertiaApp({
|
||||||
<NotificationsProvider>
|
<NotificationsProvider>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
{showDevtools && <ReactQueryDevtools initialIsOpen={false} />}
|
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</NotificationsProvider>
|
</NotificationsProvider>
|
||||||
</TransmitProvider>
|
</TransmitProvider>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as Icons from '@heroicons/react/24/outline'
|
import * as Icons from '@heroicons/react/24/outline'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export interface StyledButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
export interface StyledButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
|
@ -59,67 +60,73 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
|
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'primary':
|
case 'primary':
|
||||||
return `
|
return clsx(
|
||||||
bg-desert-green text-desert-white
|
'bg-desert-green text-desert-white',
|
||||||
hover:bg-desert-green-dark hover:shadow-lg
|
'hover:bg-desert-green-dark hover:shadow-lg',
|
||||||
active:bg-desert-green-darker
|
'active:bg-desert-green-darker',
|
||||||
disabled:bg-desert-green-light disabled:text-desert-stone-light
|
'disabled:bg-desert-green-light disabled:text-desert-stone-light',
|
||||||
${baseTransition} ${baseHover}
|
baseTransition,
|
||||||
`
|
baseHover
|
||||||
|
)
|
||||||
|
|
||||||
case 'secondary':
|
case 'secondary':
|
||||||
return `
|
return clsx(
|
||||||
bg-desert-tan text-desert-white
|
'bg-desert-tan text-desert-white',
|
||||||
hover:bg-desert-tan-dark hover:shadow-lg
|
'hover:bg-desert-tan-dark hover:shadow-lg',
|
||||||
active:bg-desert-tan-dark
|
'active:bg-desert-tan-dark',
|
||||||
disabled:bg-desert-tan-lighter disabled:text-desert-stone-light
|
'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',
|
||||||
${baseTransition} ${baseHover}
|
baseTransition,
|
||||||
`
|
baseHover
|
||||||
|
)
|
||||||
|
|
||||||
case 'danger':
|
case 'danger':
|
||||||
return `
|
return clsx(
|
||||||
bg-desert-red text-desert-white
|
'bg-desert-red text-desert-white',
|
||||||
hover:bg-desert-red-dark hover:shadow-lg
|
'hover:bg-desert-red-dark hover:shadow-lg',
|
||||||
active:bg-desert-red-dark
|
'active:bg-desert-red-dark',
|
||||||
disabled:bg-desert-red-lighter disabled:text-desert-stone-light
|
'disabled:bg-desert-red-lighter disabled:text-desert-stone-light',
|
||||||
${baseTransition} ${baseHover}
|
baseTransition,
|
||||||
`
|
baseHover
|
||||||
|
)
|
||||||
|
|
||||||
case 'action':
|
case 'action':
|
||||||
return `
|
return clsx(
|
||||||
bg-desert-orange text-desert-white
|
'bg-desert-orange text-desert-white',
|
||||||
hover:bg-desert-orange-light hover:shadow-lg
|
'hover:bg-desert-orange-light hover:shadow-lg',
|
||||||
active:bg-desert-orange-dark
|
'active:bg-desert-orange-dark',
|
||||||
disabled:bg-desert-orange-lighter disabled:text-desert-stone-light
|
'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light',
|
||||||
${baseTransition} ${baseHover}
|
baseTransition,
|
||||||
`
|
baseHover
|
||||||
|
)
|
||||||
|
|
||||||
case 'success':
|
case 'success':
|
||||||
return `
|
return clsx(
|
||||||
bg-desert-olive text-desert-white
|
'bg-desert-olive text-desert-white',
|
||||||
hover:bg-desert-olive-dark hover:shadow-lg
|
'hover:bg-desert-olive-dark hover:shadow-lg',
|
||||||
active:bg-desert-olive-dark
|
'active:bg-desert-olive-dark',
|
||||||
disabled:bg-desert-olive-lighter disabled:text-desert-stone-light
|
'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light',
|
||||||
${baseTransition} ${baseHover}
|
baseTransition,
|
||||||
`
|
baseHover
|
||||||
|
)
|
||||||
|
|
||||||
case 'ghost':
|
case 'ghost':
|
||||||
return `
|
return clsx(
|
||||||
bg-transparent text-desert-green
|
'bg-transparent text-desert-green',
|
||||||
hover:bg-desert-sand hover:text-desert-green-dark
|
'hover:bg-desert-sand hover:text-desert-green-dark',
|
||||||
active:bg-desert-green-lighter
|
'active:bg-desert-green-lighter',
|
||||||
disabled:text-desert-stone-light
|
'disabled:text-desert-stone-light',
|
||||||
${baseTransition}
|
baseTransition
|
||||||
`
|
)
|
||||||
|
|
||||||
case 'outline':
|
case 'outline':
|
||||||
return `
|
return clsx(
|
||||||
bg-transparent border-2 border-desert-green text-desert-green
|
'bg-transparent border-2 border-desert-green text-desert-green',
|
||||||
hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark
|
'hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark',
|
||||||
active:bg-desert-green-dark active:border-desert-green-darker
|
'active:bg-desert-green-dark active:border-desert-green-darker',
|
||||||
disabled:border-desert-green-lighter disabled:text-desert-stone-light
|
'disabled:border-desert-green-lighter disabled:text-desert-stone-light',
|
||||||
${baseTransition} ${baseHover}
|
baseTransition,
|
||||||
`
|
baseHover
|
||||||
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
|
|
@ -130,7 +137,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
const spinnerSize = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4'
|
const spinnerSize = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4'
|
||||||
return (
|
return (
|
||||||
<Icons.ArrowPathIcon
|
<Icons.ArrowPathIcon
|
||||||
className={`${spinnerSize} animate-spin ${fullWidth ? 'mx-auto' : ''}`}
|
className={clsx(spinnerSize, 'animate-spin')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -146,16 +153,13 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`
|
className={clsx(
|
||||||
${fullWidth ? 'w-full' : 'inline-flex'}
|
fullWidth ? 'flex w-full' : 'inline-flex',
|
||||||
items-center justify-center
|
getSizeClasses(),
|
||||||
rounded-md font-semibold
|
getVariantClasses(),
|
||||||
${getSizeClasses()}
|
isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer',
|
||||||
${getVariantClasses()}
|
'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'
|
||||||
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'}
|
|
||||||
`}
|
|
||||||
{...props}
|
{...props}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onClick={onClickHandler}
|
onClick={onClickHandler}
|
||||||
|
|
@ -165,7 +169,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{icon && <IconComponent />}
|
{icon && <IconComponent />}
|
||||||
<span className={fullWidth ? 'block text-center' : ''}>{children}</span>
|
{children}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
11
admin/inertia/components/chat/ChatAssistantAvatar.tsx
Normal file
11
admin/inertia/components/chat/ChatAssistantAvatar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
admin/inertia/components/chat/ChatButton.tsx
Normal file
17
admin/inertia/components/chat/ChatButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
admin/inertia/components/chat/ChatInterface.tsx
Normal file
149
admin/inertia/components/chat/ChatInterface.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
admin/inertia/components/chat/ChatMessageBubble.tsx
Normal file
82
admin/inertia/components/chat/ChatMessageBubble.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
admin/inertia/components/chat/ChatModal.tsx
Normal file
27
admin/inertia/components/chat/ChatModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
admin/inertia/components/chat/ChatSidebar.tsx
Normal file
128
admin/inertia/components/chat/ChatSidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
270
admin/inertia/components/chat/index.tsx
Normal file
270
admin/inertia/components/chat/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react'
|
import { forwardRef, useImperativeHandle, useState } from 'react'
|
||||||
import Uppy from '@uppy/core'
|
import Uppy from '@uppy/core'
|
||||||
import '@uppy/core/css/style.min.css'
|
import '@uppy/core/css/style.min.css'
|
||||||
import '@uppy/dashboard/css/style.min.css'
|
import '@uppy/dashboard/css/style.min.css'
|
||||||
|
|
@ -17,19 +17,25 @@ interface FileUploaderProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileUploaderRef {
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A drag-and-drop (or click) file upload area with customizations for
|
* A drag-and-drop (or click) file upload area with customizations for
|
||||||
* multiple and maximum numbers of files.
|
* multiple and maximum numbers of files.
|
||||||
*/
|
*/
|
||||||
const FileUploader: React.FC<FileUploaderProps> = ({
|
const FileUploader = forwardRef<FileUploaderRef, FileUploaderProps>((props, ref) => {
|
||||||
minFiles = 0,
|
const {
|
||||||
maxFiles = 1,
|
minFiles = 0,
|
||||||
maxFileSize = 10485760, // default to 10MB
|
maxFiles = 1,
|
||||||
fileTypes,
|
maxFileSize = 10485760, // default to 10MB
|
||||||
disabled = false,
|
fileTypes,
|
||||||
onUpload,
|
disabled = false,
|
||||||
className,
|
onUpload,
|
||||||
}) => {
|
className,
|
||||||
|
} = props
|
||||||
|
|
||||||
const [uppy] = useState(() => {
|
const [uppy] = useState(() => {
|
||||||
const uppy = new Uppy({
|
const uppy = new Uppy({
|
||||||
debug: true,
|
debug: true,
|
||||||
|
|
@ -43,6 +49,12 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
||||||
return uppy
|
return uppy
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
clear: () => {
|
||||||
|
uppy.clear()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
useUppyEvent(uppy, 'state-update', (_, newState) => {
|
useUppyEvent(uppy, 'state-update', (_, newState) => {
|
||||||
const stateFiles = Object.values(newState.files)
|
const stateFiles = Object.values(newState.files)
|
||||||
|
|
||||||
|
|
@ -79,6 +91,8 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
||||||
className={classNames(className)}
|
className={classNames(className)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
FileUploader.displayName = 'FileUploader'
|
||||||
|
|
||||||
export default FileUploader
|
export default FileUploader
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import Footer from '~/components/Footer'
|
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 }) {
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [isChatOpen, setIsChatOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<div
|
<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" />
|
<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>
|
<div className="flex-1 w-full bg-desert">{children}</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
|
<ChatButton onClick={() => setIsChatOpen(true)} />
|
||||||
|
<ChatModal open={isChatOpen} onClose={() => setIsChatOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import {
|
||||||
DownloadJobWithProgress,
|
DownloadJobWithProgress,
|
||||||
} from '../../types/downloads'
|
} from '../../types/downloads'
|
||||||
import { catchInternal } from './util'
|
import { catchInternal } from './util'
|
||||||
import { NomadOllamaModel } from '../../types/ollama'
|
import { NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
|
||||||
|
import { ChatResponse, ModelResponse } from 'ollama'
|
||||||
|
|
||||||
class API {
|
class API {
|
||||||
private client: AxiosInstance
|
private client: AxiosInstance
|
||||||
|
|
@ -35,7 +36,7 @@ class API {
|
||||||
|
|
||||||
async deleteModel(model: string): Promise<{ success: boolean; message: string }> {
|
async deleteModel(model: string): Promise<{ success: boolean; message: string }> {
|
||||||
return catchInternal(async () => {
|
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
|
return response.data
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +61,7 @@ class API {
|
||||||
|
|
||||||
async downloadModel(model: string): Promise<{ success: boolean; message: string }> {
|
async downloadModel(model: string): Promise<{ success: boolean; message: string }> {
|
||||||
return catchInternal(async () => {
|
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
|
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> {
|
async getRecommendedModels(): Promise<NomadOllamaModel[] | undefined> {
|
||||||
return catchInternal(async () => {
|
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 },
|
params: { sort: 'pulls', recommendedOnly: true },
|
||||||
})
|
})
|
||||||
return response.data
|
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() {
|
async getSystemInfo() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<SystemInformationResponse>('/system/info')
|
const response = await this.client.get<SystemInformationResponse>('/system/info')
|
||||||
|
|
@ -295,6 +401,23 @@ class API {
|
||||||
return response.data
|
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()
|
export default new API()
|
||||||
|
|
|
||||||
11
admin/inertia/pages/chat.tsx
Normal file
11
admin/inertia/pages/chat.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,50 @@
|
||||||
import { Head } from '@inertiajs/react'
|
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 FileUploader from '~/components/file-uploader'
|
||||||
import StyledButton from '~/components/StyledButton'
|
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 AppLayout from '~/layouts/AppLayout'
|
||||||
|
import api from '~/lib/api'
|
||||||
|
|
||||||
export default function KnowledgeBase() {
|
export default function KnowledgeBase() {
|
||||||
const [loading, setLoading] = useState(false)
|
const { addNotification } = useNotifications()
|
||||||
const [files, setFiles] = useState<File[]>([])
|
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 (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
|
|
@ -15,12 +53,11 @@ export default function KnowledgeBase() {
|
||||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
|
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<FileUploader
|
<FileUploader
|
||||||
|
ref={fileUploaderRef}
|
||||||
minFiles={1}
|
minFiles={1}
|
||||||
maxFiles={1}
|
maxFiles={1}
|
||||||
onUpload={(files) => {
|
onUpload={(uploadedFiles) => {
|
||||||
setLoading(true)
|
setFiles(Array.from(uploadedFiles))
|
||||||
setFiles(Array.from(files))
|
|
||||||
setLoading(false)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-center gap-4 my-6">
|
<div className="flex justify-center gap-4 my-6">
|
||||||
|
|
@ -28,9 +65,9 @@ export default function KnowledgeBase() {
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
icon="ArrowUpCircleIcon"
|
icon="ArrowUpCircleIcon"
|
||||||
onClick={() => {}}
|
onClick={handleUpload}
|
||||||
disabled={files.length === 0 || loading}
|
disabled={files.length === 0 || uploadMutation.isPending}
|
||||||
loading={loading}
|
loading={uploadMutation.isPending}
|
||||||
>
|
>
|
||||||
Upload
|
Upload
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
|
@ -91,6 +128,25 @@ export default function KnowledgeBase() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,6 @@ export default function LegalPage() {
|
||||||
<br />
|
<br />
|
||||||
<a href="https://learningequality.org/kolibri" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://learningequality.org/kolibri</a>
|
<a href="https://learningequality.org/kolibri" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://learningequality.org/kolibri</a>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<strong>Ollama</strong> - Local large language model runtime (MIT License)
|
<strong>Ollama</strong> - Local large language model runtime (MIT License)
|
||||||
<br />
|
<br />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Head, router } from '@inertiajs/react'
|
import { Head, router } from '@inertiajs/react'
|
||||||
import StyledTable from '~/components/StyledTable'
|
import StyledTable from '~/components/StyledTable'
|
||||||
import SettingsLayout from '~/layouts/SettingsLayout'
|
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||||
import { NomadOllamaModel, OllamaModelListing } from '../../../types/ollama'
|
import { NomadOllamaModel } from '../../../types/ollama'
|
||||||
import StyledButton from '~/components/StyledButton'
|
import StyledButton from '~/components/StyledButton'
|
||||||
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
||||||
import Alert from '~/components/Alert'
|
import Alert from '~/components/Alert'
|
||||||
|
|
@ -9,9 +9,10 @@ import { useNotifications } from '~/context/NotificationContext'
|
||||||
import api from '~/lib/api'
|
import api from '~/lib/api'
|
||||||
import { useModals } from '~/context/ModalContext'
|
import { useModals } from '~/context/ModalContext'
|
||||||
import StyledModal from '~/components/StyledModal'
|
import StyledModal from '~/components/StyledModal'
|
||||||
|
import { ModelResponse } from 'ollama'
|
||||||
|
|
||||||
export default function ModelsPage(props: {
|
export default function ModelsPage(props: {
|
||||||
models: { availableModels: NomadOllamaModel[]; installedModels: OllamaModelListing[] }
|
models: { availableModels: NomadOllamaModel[]; installedModels: ModelResponse[] }
|
||||||
}) {
|
}) {
|
||||||
const { isInstalled } = useServiceInstalledStatus('nomad_open_webui')
|
const { isInstalled } = useServiceInstalledStatus('nomad_open_webui')
|
||||||
const { addNotification } = useNotifications()
|
const { addNotification } = useNotifications()
|
||||||
|
|
|
||||||
1460
admin/package-lock.json
generated
1460
admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -102,14 +102,15 @@
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pdf2pic": "^3.2.0",
|
"pdf2pic": "^3.2.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"playwright": "^1.58.0",
|
|
||||||
"pmtiles": "^4.3.0",
|
"pmtiles": "^4.3.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-adonis-transmit": "^1.0.1",
|
"react-adonis-transmit": "^1.0.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-map-gl": "^8.1.0",
|
"react-map-gl": "^8.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"systeminformation": "^5.27.14",
|
"systeminformation": "^5.27.14",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
import BenchmarkController from '#controllers/benchmark_controller'
|
import BenchmarkController from '#controllers/benchmark_controller'
|
||||||
|
import ChatsController from '#controllers/chats_controller'
|
||||||
import DocsController from '#controllers/docs_controller'
|
import DocsController from '#controllers/docs_controller'
|
||||||
import DownloadsController from '#controllers/downloads_controller'
|
import DownloadsController from '#controllers/downloads_controller'
|
||||||
import EasySetupController from '#controllers/easy_setup_controller'
|
import EasySetupController from '#controllers/easy_setup_controller'
|
||||||
import HomeController from '#controllers/home_controller'
|
import HomeController from '#controllers/home_controller'
|
||||||
import MapsController from '#controllers/maps_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 SettingsController from '#controllers/settings_controller'
|
||||||
import SystemController from '#controllers/system_controller'
|
import SystemController from '#controllers/system_controller'
|
||||||
import ZimController from '#controllers/zim_controller'
|
import ZimController from '#controllers/zim_controller'
|
||||||
|
|
@ -24,6 +26,7 @@ transmit.registerRoutes()
|
||||||
router.get('/', [HomeController, 'index'])
|
router.get('/', [HomeController, 'index'])
|
||||||
router.get('/home', [HomeController, 'home'])
|
router.get('/home', [HomeController, 'home'])
|
||||||
router.on('/about').renderInertia('about')
|
router.on('/about').renderInertia('about')
|
||||||
|
router.on('/chat').renderInertia('chat')
|
||||||
router.on('/knowledge-base').renderInertia('knowledge-base')
|
router.on('/knowledge-base').renderInertia('knowledge-base')
|
||||||
router.get('/maps', [MapsController, 'index'])
|
router.get('/maps', [MapsController, 'index'])
|
||||||
|
|
||||||
|
|
@ -90,12 +93,32 @@ router.get('/api/health', () => {
|
||||||
|
|
||||||
router
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.get('/models', [OpenWebUIController, 'models'])
|
router.post('/chat', [OllamaController, 'chat'])
|
||||||
router.get('/installed-models', [OpenWebUIController, 'installedModels'])
|
router.get('/models', [OllamaController, 'availableModels'])
|
||||||
router.post('/download-model', [OpenWebUIController, 'dispatchModelDownload'])
|
router.post('/models', [OllamaController, 'dispatchModelDownload'])
|
||||||
router.post('/delete-model', [OpenWebUIController, 'deleteModel'])
|
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
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
|
|
|
||||||
14
admin/types/chat.ts
Normal file
14
admin/types/chat.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -21,19 +21,23 @@ export type NomadOllamaModelAPIResponse = {
|
||||||
models: NomadOllamaModel[]
|
models: NomadOllamaModel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OllamaModelListing = {
|
export type OllamaChatMessage = {
|
||||||
name: string
|
role: 'system' | 'user' | 'assistant'
|
||||||
id: string
|
content: string
|
||||||
size: string
|
|
||||||
modified: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OllamaChatRequest = {
|
||||||
export type OpenWebUIKnowledgeFileMetadata = {
|
model: string
|
||||||
source: string
|
messages: OllamaChatMessage[]
|
||||||
name: string
|
stream?: boolean
|
||||||
created_by: string
|
}
|
||||||
file_id: string
|
|
||||||
start_index: number
|
export type OllamaChatResponse = {
|
||||||
hash: string
|
model: string
|
||||||
|
created_at: string
|
||||||
|
message: {
|
||||||
|
role: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
done: boolean
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user