mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-30 05:29:25 +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
|
||||
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.
|
||||
|
||||
|
|
|
|||
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 app from '@adonisjs/core/services/app'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { sanitizeFilename } from '../utils/fs.js'
|
||||
|
||||
@inject()
|
||||
export default class RagController {
|
||||
constructor(private ragService: RagService) {}
|
||||
|
||||
export default class RagsController {
|
||||
public async upload({ request, response }: HttpContext) {
|
||||
const uploadedFile = request.file('file')
|
||||
if (!uploadedFile) {
|
||||
return response.status(400).json({ error: 'No file uploaded' })
|
||||
}
|
||||
|
||||
const fileName = `${cuid()}.${uploadedFile.extname}`
|
||||
const randomSuffix = randomBytes(6).toString('hex')
|
||||
const sanitizedName = sanitizeFilename(uploadedFile.clientName)
|
||||
|
||||
const fileName = `${sanitizedName}-${randomSuffix}.${uploadedFile.extname || 'txt'}`
|
||||
const fullPath = app.makePath('storage/uploads', fileName)
|
||||
|
||||
await uploadedFile.move(app.makePath('storage/uploads'), {
|
||||
name: fileName,
|
||||
})
|
||||
|
||||
// Don't await this - process in background
|
||||
this.ragService.processAndEmbedFile(fullPath)
|
||||
|
||||
return response.status(200).json({
|
||||
message: 'File has been uploaded and queued for processing.',
|
||||
file_path: `/uploads/${fileName}`,
|
||||
})
|
||||
}
|
||||
|
||||
public async getStoredFiles({ response }: HttpContext) {
|
||||
const files = await this.ragService.getStoredFiles()
|
||||
return response.status(200).json({ files })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { BenchmarkService } from '#services/benchmark_service';
|
||||
import { MapService } from '#services/map_service';
|
||||
import { OpenWebUIService } from '#services/openwebui_service';
|
||||
import { OllamaService } from '#services/ollama_service';
|
||||
import { SystemService } from '#services/system_service';
|
||||
import { inject } from '@adonisjs/core';
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
|
@ -10,8 +10,8 @@ export default class SettingsController {
|
|||
constructor(
|
||||
private systemService: SystemService,
|
||||
private mapService: MapService,
|
||||
private openWebUIService: OpenWebUIService,
|
||||
private benchmarkService: BenchmarkService
|
||||
private benchmarkService: BenchmarkService,
|
||||
private ollamaService: OllamaService
|
||||
) { }
|
||||
|
||||
async system({ inertia }: HttpContext) {
|
||||
|
|
@ -48,8 +48,8 @@ export default class SettingsController {
|
|||
}
|
||||
|
||||
async models({ inertia }: HttpContext) {
|
||||
const availableModels = await this.openWebUIService.getAvailableModels();
|
||||
const installedModels = await this.openWebUIService.getInstalledModels();
|
||||
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false });
|
||||
const installedModels = await this.ollamaService.getModels();
|
||||
return inertia.render('settings/models', {
|
||||
models: {
|
||||
availableModels: availableModels || [],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { OpenWebUIService } from '#services/openwebui_service'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { DockerService } from '#services/docker_service'
|
||||
import { OllamaService } from '#services/ollama_service'
|
||||
|
||||
export interface DownloadModelJobParams {
|
||||
modelName: string
|
||||
|
|
@ -27,26 +26,23 @@ export class DownloadModelJob {
|
|||
|
||||
logger.info(`[DownloadModelJob] Attempting to download model: ${modelName}`)
|
||||
|
||||
// Check if OpenWebUI/Ollama services are ready
|
||||
const dockerService = new DockerService()
|
||||
const openWebUIService = new OpenWebUIService(dockerService)
|
||||
const ollamaService = new OllamaService()
|
||||
|
||||
// Use getInstalledModels to check if the service is ready
|
||||
// Even if no models are installed, this should return an empty array if ready
|
||||
const existingModels = await openWebUIService.getInstalledModels()
|
||||
const existingModels = await ollamaService.getModels()
|
||||
if (!existingModels) {
|
||||
logger.warn(
|
||||
`[DownloadModelJob] OpenWebUI service not ready yet for model ${modelName}. Will retry...`
|
||||
`[DownloadModelJob] Ollama service not ready yet for model ${modelName}. Will retry...`
|
||||
)
|
||||
throw new Error('OpenWebUI service not ready yet')
|
||||
throw new Error('Ollama service not ready yet')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[DownloadModelJob] OpenWebUI service is ready. Initiating download for ${modelName}`
|
||||
`[DownloadModelJob] Ollama service is ready. Initiating download for ${modelName}`
|
||||
)
|
||||
|
||||
// Services are ready, initiate the download with progress tracking
|
||||
const result = await openWebUIService._downloadModel(modelName, (progress) => {
|
||||
const result = await ollamaService._downloadModel(modelName, (progress) => {
|
||||
// Update job progress in BullMQ
|
||||
const progressData = {
|
||||
status: progress.status,
|
||||
|
|
|
|||
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
|
||||
const openWebUIService = new (await import('./openwebui_service.js')).OpenWebUIService(this.dockerService)
|
||||
const modelResponse = await openWebUIService.downloadModelSync(AI_BENCHMARK_MODEL)
|
||||
const ollamaService = new (await import('./ollama_service.js')).OllamaService()
|
||||
const modelResponse = await ollamaService.downloadModelSync(AI_BENCHMARK_MODEL)
|
||||
if (!modelResponse.success) {
|
||||
throw new Error(`Model does not exist and failed to download: ${modelResponse.message}`)
|
||||
}
|
||||
|
|
|
|||
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 { DockerService } from './docker_service.js'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { chunk } from 'llm-chunk'
|
||||
import { OpenWebUIService } from './openwebui_service.js'
|
||||
import sharp from 'sharp'
|
||||
import { determineFileType, getFile } from '../utils/fs.js'
|
||||
import { PDFParse } from 'pdf-parse'
|
||||
import { createWorker } from 'tesseract.js'
|
||||
import { fromBuffer } from 'pdf2pic'
|
||||
import { OllamaService } from './ollama_service.js'
|
||||
|
||||
@inject()
|
||||
export class RagService {
|
||||
private qdrant: QdrantClient | null = null
|
||||
private ollama: Ollama | null = null
|
||||
private qdrantInitPromise: Promise<void> | null = null
|
||||
private ollamaInitPromise: Promise<void> | null = null
|
||||
public static CONTENT_COLLECTION_NAME = 'open-webui_knowledge' // This is the collection name OWUI uses for uploaded knowledge
|
||||
public static EMBEDDING_MODEL = 'nomic-embed-text:v1.5'
|
||||
public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768
|
||||
|
||||
constructor(
|
||||
private dockerService: DockerService,
|
||||
private openWebUIService: OpenWebUIService
|
||||
private ollamaService: OllamaService
|
||||
) {}
|
||||
|
||||
private async _initializeQdrantClient() {
|
||||
|
|
@ -33,32 +30,16 @@ export class RagService {
|
|||
if (!qdrantUrl) {
|
||||
throw new Error('Qdrant service is not installed or running.')
|
||||
}
|
||||
this.qdrant = new QdrantClient({ url: `http://${qdrantUrl}` })
|
||||
this.qdrant = new QdrantClient({ url: qdrantUrl })
|
||||
})()
|
||||
}
|
||||
return this.qdrantInitPromise
|
||||
}
|
||||
|
||||
private async _initializeOllamaClient() {
|
||||
if (!this.ollamaInitPromise) {
|
||||
this.ollamaInitPromise = (async () => {
|
||||
const ollamaUrl = await this.dockerService.getServiceURL(DockerService.OLLAMA_SERVICE_NAME)
|
||||
if (!ollamaUrl) {
|
||||
throw new Error('Ollama service is not installed or running.')
|
||||
}
|
||||
this.ollama = new Ollama({ host: `http://${ollamaUrl}` })
|
||||
})()
|
||||
}
|
||||
return this.ollamaInitPromise
|
||||
}
|
||||
|
||||
private async _ensureDependencies() {
|
||||
if (!this.qdrant) {
|
||||
await this._initializeQdrantClient()
|
||||
}
|
||||
if (!this.ollama) {
|
||||
await this._initializeOllamaClient()
|
||||
}
|
||||
}
|
||||
|
||||
private async _ensureCollection(
|
||||
|
|
@ -93,13 +74,13 @@ export class RagService {
|
|||
RagService.CONTENT_COLLECTION_NAME,
|
||||
RagService.EMBEDDING_DIMENSION
|
||||
)
|
||||
const initModelResponse = await this.openWebUIService.downloadModelSync(
|
||||
RagService.EMBEDDING_MODEL
|
||||
)
|
||||
if (!initModelResponse.success) {
|
||||
throw new Error(
|
||||
`${RagService.EMBEDDING_MODEL} does not exist and could not be downloaded: ${initModelResponse.message}`
|
||||
)
|
||||
|
||||
const allModels = await this.ollamaService.getModels(true)
|
||||
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
|
||||
|
||||
// TODO: Attempt to download the embedding model if not found
|
||||
if (!embeddingModel) {
|
||||
throw new Error(`${RagService.EMBEDDING_MODEL} does not exist and could not be downloaded.`)
|
||||
}
|
||||
|
||||
const chunks = chunk(text, {
|
||||
|
|
@ -114,8 +95,9 @@ export class RagService {
|
|||
}
|
||||
|
||||
const embeddings: number[][] = []
|
||||
const ollamaClient = await this.ollamaService.getClient()
|
||||
for (const chunkText of chunks) {
|
||||
const response = await this.ollama!.embeddings({
|
||||
const response = await ollamaClient.embeddings({
|
||||
model: RagService.EMBEDDING_MODEL,
|
||||
prompt: chunkText,
|
||||
})
|
||||
|
|
@ -213,7 +195,7 @@ export class RagService {
|
|||
*/
|
||||
public async processAndEmbedFile(
|
||||
filepath: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
): Promise<{ success: boolean; message: string; chunks?: number }> {
|
||||
try {
|
||||
const fileType = determineFileType(filepath)
|
||||
if (fileType === 'unknown') {
|
||||
|
|
@ -252,11 +234,113 @@ export class RagService {
|
|||
|
||||
const embedResult = await this.embedAndStoreText(extractedText, {})
|
||||
|
||||
|
||||
return { success: true, message: 'File processed and embedded successfully.' }
|
||||
return {
|
||||
success: true,
|
||||
message: 'File processed and embedded successfully.',
|
||||
chunks: embedResult?.chunks,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing and embedding file:', error)
|
||||
return { success: false, message: 'Error processing and embedding file.' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for documents similar to the query text in the Qdrant knowledge base.
|
||||
* Returns the most relevant text chunks based on semantic similarity.
|
||||
* @param query - The search query text
|
||||
* @param limit - Maximum number of results to return (default: 5)
|
||||
* @param scoreThreshold - Minimum similarity score threshold (default: 0.7)
|
||||
* @returns Array of relevant text chunks with their scores
|
||||
*/
|
||||
public async searchSimilarDocuments(
|
||||
query: string,
|
||||
limit: number = 5,
|
||||
scoreThreshold: number = 0.7
|
||||
): Promise<Array<{ text: string; score: number }>> {
|
||||
try {
|
||||
await this._ensureCollection(
|
||||
RagService.CONTENT_COLLECTION_NAME,
|
||||
RagService.EMBEDDING_DIMENSION
|
||||
)
|
||||
|
||||
const allModels = await this.ollamaService.getModels(true)
|
||||
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
|
||||
|
||||
if (!embeddingModel) {
|
||||
logger.warn(
|
||||
`${RagService.EMBEDDING_MODEL} not found. Cannot perform similarity search.`
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
// Generate embedding for the query
|
||||
const ollamaClient = await this.ollamaService.getClient()
|
||||
const response = await ollamaClient.embeddings({
|
||||
model: RagService.EMBEDDING_MODEL,
|
||||
prompt: query,
|
||||
})
|
||||
|
||||
// Search for similar vectors in Qdrant
|
||||
const searchResults = await this.qdrant!.search(RagService.CONTENT_COLLECTION_NAME, {
|
||||
vector: response.embedding,
|
||||
limit: limit,
|
||||
score_threshold: scoreThreshold,
|
||||
with_payload: true,
|
||||
})
|
||||
|
||||
console.log("Got search results:", searchResults);
|
||||
|
||||
return searchResults.map((result) => ({
|
||||
text: (result.payload?.text as string) || '',
|
||||
score: result.score,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Error searching similar documents:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all unique source files that have been stored in the knowledge base.
|
||||
* @returns Array of unique source file identifiers
|
||||
*/
|
||||
public async getStoredFiles(): Promise<string[]> {
|
||||
try {
|
||||
await this._ensureCollection(
|
||||
RagService.CONTENT_COLLECTION_NAME,
|
||||
RagService.EMBEDDING_DIMENSION
|
||||
)
|
||||
|
||||
const sources = new Set<string>()
|
||||
let offset: string | number | null | Record<string, unknown> = null
|
||||
const batchSize = 100
|
||||
|
||||
// Scroll through all points in the collection
|
||||
do {
|
||||
const scrollResult = await this.qdrant!.scroll(RagService.CONTENT_COLLECTION_NAME, {
|
||||
limit: batchSize,
|
||||
offset: offset,
|
||||
with_payload: true,
|
||||
with_vector: false,
|
||||
})
|
||||
|
||||
// Extract unique source values from payloads
|
||||
scrollResult.points.forEach((point) => {
|
||||
const metadata = point.payload?.metadata
|
||||
if (metadata && typeof metadata === 'object' && 'source' in metadata) {
|
||||
const source = metadata.source as string
|
||||
sources.add(source)
|
||||
}
|
||||
})
|
||||
|
||||
offset = scrollResult.next_page_offset || null
|
||||
} while (offset !== null)
|
||||
|
||||
return Array.from(sources)
|
||||
} catch (error) {
|
||||
logger.error('Error retrieving stored files:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,3 +164,12 @@ export function determineFileType(filename: string): 'image' | 'pdf' | 'text' |
|
|||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a filename by removing potentially dangerous characters.
|
||||
* @param filename The original filename
|
||||
* @returns The sanitized filename
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
return filename.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
}
|
||||
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>
|
||||
<ModalsProvider>
|
||||
<App {...props} />
|
||||
{showDevtools && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</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 { useMemo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface StyledButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode
|
||||
|
|
@ -56,71 +57,77 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
const getVariantClasses = () => {
|
||||
const baseTransition = 'transition-all duration-200 ease-in-out'
|
||||
const baseHover = 'hover:shadow-md active:scale-[0.98]'
|
||||
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return `
|
||||
bg-desert-green text-desert-white
|
||||
hover:bg-desert-green-dark hover:shadow-lg
|
||||
active:bg-desert-green-darker
|
||||
disabled:bg-desert-green-light disabled:text-desert-stone-light
|
||||
${baseTransition} ${baseHover}
|
||||
`
|
||||
|
||||
return clsx(
|
||||
'bg-desert-green text-desert-white',
|
||||
'hover:bg-desert-green-dark hover:shadow-lg',
|
||||
'active:bg-desert-green-darker',
|
||||
'disabled:bg-desert-green-light disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
)
|
||||
|
||||
case 'secondary':
|
||||
return `
|
||||
bg-desert-tan text-desert-white
|
||||
hover:bg-desert-tan-dark hover:shadow-lg
|
||||
active:bg-desert-tan-dark
|
||||
disabled:bg-desert-tan-lighter disabled:text-desert-stone-light
|
||||
${baseTransition} ${baseHover}
|
||||
`
|
||||
|
||||
return clsx(
|
||||
'bg-desert-tan text-desert-white',
|
||||
'hover:bg-desert-tan-dark hover:shadow-lg',
|
||||
'active:bg-desert-tan-dark',
|
||||
'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
)
|
||||
|
||||
case 'danger':
|
||||
return `
|
||||
bg-desert-red text-desert-white
|
||||
hover:bg-desert-red-dark hover:shadow-lg
|
||||
active:bg-desert-red-dark
|
||||
disabled:bg-desert-red-lighter disabled:text-desert-stone-light
|
||||
${baseTransition} ${baseHover}
|
||||
`
|
||||
|
||||
return clsx(
|
||||
'bg-desert-red text-desert-white',
|
||||
'hover:bg-desert-red-dark hover:shadow-lg',
|
||||
'active:bg-desert-red-dark',
|
||||
'disabled:bg-desert-red-lighter disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
)
|
||||
|
||||
case 'action':
|
||||
return `
|
||||
bg-desert-orange text-desert-white
|
||||
hover:bg-desert-orange-light hover:shadow-lg
|
||||
active:bg-desert-orange-dark
|
||||
disabled:bg-desert-orange-lighter disabled:text-desert-stone-light
|
||||
${baseTransition} ${baseHover}
|
||||
`
|
||||
|
||||
return clsx(
|
||||
'bg-desert-orange text-desert-white',
|
||||
'hover:bg-desert-orange-light hover:shadow-lg',
|
||||
'active:bg-desert-orange-dark',
|
||||
'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
)
|
||||
|
||||
case 'success':
|
||||
return `
|
||||
bg-desert-olive text-desert-white
|
||||
hover:bg-desert-olive-dark hover:shadow-lg
|
||||
active:bg-desert-olive-dark
|
||||
disabled:bg-desert-olive-lighter disabled:text-desert-stone-light
|
||||
${baseTransition} ${baseHover}
|
||||
`
|
||||
|
||||
return clsx(
|
||||
'bg-desert-olive text-desert-white',
|
||||
'hover:bg-desert-olive-dark hover:shadow-lg',
|
||||
'active:bg-desert-olive-dark',
|
||||
'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
)
|
||||
|
||||
case 'ghost':
|
||||
return `
|
||||
bg-transparent text-desert-green
|
||||
hover:bg-desert-sand hover:text-desert-green-dark
|
||||
active:bg-desert-green-lighter
|
||||
disabled:text-desert-stone-light
|
||||
${baseTransition}
|
||||
`
|
||||
|
||||
return clsx(
|
||||
'bg-transparent text-desert-green',
|
||||
'hover:bg-desert-sand hover:text-desert-green-dark',
|
||||
'active:bg-desert-green-lighter',
|
||||
'disabled:text-desert-stone-light',
|
||||
baseTransition
|
||||
)
|
||||
|
||||
case 'outline':
|
||||
return `
|
||||
bg-transparent border-2 border-desert-green text-desert-green
|
||||
hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark
|
||||
active:bg-desert-green-dark active:border-desert-green-darker
|
||||
disabled:border-desert-green-lighter disabled:text-desert-stone-light
|
||||
${baseTransition} ${baseHover}
|
||||
`
|
||||
|
||||
return clsx(
|
||||
'bg-transparent border-2 border-desert-green text-desert-green',
|
||||
'hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark',
|
||||
'active:bg-desert-green-dark active:border-desert-green-darker',
|
||||
'disabled:border-desert-green-lighter disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
)
|
||||
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
|
|
@ -129,8 +136,8 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
const getLoadingSpinner = () => {
|
||||
const spinnerSize = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4'
|
||||
return (
|
||||
<Icons.ArrowPathIcon
|
||||
className={`${spinnerSize} animate-spin ${fullWidth ? 'mx-auto' : ''}`}
|
||||
<Icons.ArrowPathIcon
|
||||
className={clsx(spinnerSize, 'animate-spin')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -146,16 +153,13 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
${fullWidth ? 'w-full' : 'inline-flex'}
|
||||
items-center justify-center
|
||||
rounded-md font-semibold
|
||||
${getSizeClasses()}
|
||||
${getVariantClasses()}
|
||||
focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand
|
||||
disabled:cursor-not-allowed disabled:shadow-none
|
||||
${isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer'}
|
||||
`}
|
||||
className={clsx(
|
||||
fullWidth ? 'flex w-full' : 'inline-flex',
|
||||
getSizeClasses(),
|
||||
getVariantClasses(),
|
||||
isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer',
|
||||
'items-center justify-center rounded-md font-semibold focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand disabled:cursor-not-allowed disabled:shadow-none'
|
||||
)}
|
||||
{...props}
|
||||
disabled={isDisabled}
|
||||
onClick={onClickHandler}
|
||||
|
|
@ -165,11 +169,11 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
) : (
|
||||
<>
|
||||
{icon && <IconComponent />}
|
||||
<span className={fullWidth ? 'block text-center' : ''}>{children}</span>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default StyledButton
|
||||
export default StyledButton
|
||||
|
|
|
|||
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/core/css/style.min.css'
|
||||
import '@uppy/dashboard/css/style.min.css'
|
||||
|
|
@ -17,19 +17,25 @@ interface FileUploaderProps {
|
|||
className?: string
|
||||
}
|
||||
|
||||
export interface FileUploaderRef {
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag-and-drop (or click) file upload area with customizations for
|
||||
* multiple and maximum numbers of files.
|
||||
*/
|
||||
const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
minFiles = 0,
|
||||
maxFiles = 1,
|
||||
maxFileSize = 10485760, // default to 10MB
|
||||
fileTypes,
|
||||
disabled = false,
|
||||
onUpload,
|
||||
className,
|
||||
}) => {
|
||||
const FileUploader = forwardRef<FileUploaderRef, FileUploaderProps>((props, ref) => {
|
||||
const {
|
||||
minFiles = 0,
|
||||
maxFiles = 1,
|
||||
maxFileSize = 10485760, // default to 10MB
|
||||
fileTypes,
|
||||
disabled = false,
|
||||
onUpload,
|
||||
className,
|
||||
} = props
|
||||
|
||||
const [uppy] = useState(() => {
|
||||
const uppy = new Uppy({
|
||||
debug: true,
|
||||
|
|
@ -43,6 +49,12 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
return uppy
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clear: () => {
|
||||
uppy.clear()
|
||||
},
|
||||
}))
|
||||
|
||||
useUppyEvent(uppy, 'state-update', (_, newState) => {
|
||||
const stateFiles = Object.values(newState.files)
|
||||
|
||||
|
|
@ -79,6 +91,8 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
className={classNames(className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
FileUploader.displayName = 'FileUploader'
|
||||
|
||||
export default FileUploader
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { useState } from 'react'
|
||||
import Footer from '~/components/Footer'
|
||||
import ChatButton from '~/components/chat/ChatButton'
|
||||
import ChatModal from '~/components/chat/ChatModal'
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const [isChatOpen, setIsChatOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div
|
||||
|
|
@ -13,6 +18,9 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|||
<hr className="text-desert-green font-semibold h-[1.5px] bg-desert-green border-none" />
|
||||
<div className="flex-1 w-full bg-desert">{children}</div>
|
||||
<Footer />
|
||||
|
||||
<ChatButton onClick={() => setIsChatOpen(true)} />
|
||||
<ChatModal open={isChatOpen} onClose={() => setIsChatOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import {
|
|||
DownloadJobWithProgress,
|
||||
} from '../../types/downloads'
|
||||
import { catchInternal } from './util'
|
||||
import { NomadOllamaModel } from '../../types/ollama'
|
||||
import { NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
|
||||
import { ChatResponse, ModelResponse } from 'ollama'
|
||||
|
||||
class API {
|
||||
private client: AxiosInstance
|
||||
|
|
@ -35,7 +36,7 @@ class API {
|
|||
|
||||
async deleteModel(model: string): Promise<{ success: boolean; message: string }> {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post('/openwebui/delete-model', { model })
|
||||
const response = await this.client.delete('/ollama/models', { data: { model } })
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
|
@ -60,7 +61,7 @@ class API {
|
|||
|
||||
async downloadModel(model: string): Promise<{ success: boolean; message: string }> {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post('/openwebui/download-model', { model })
|
||||
const response = await this.client.post('/ollama/models', { model })
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
|
@ -138,15 +139,120 @@ class API {
|
|||
})()
|
||||
}
|
||||
|
||||
async getInstalledModels() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<ModelResponse[]>('/ollama/installed-models')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getRecommendedModels(): Promise<NomadOllamaModel[] | undefined> {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<NomadOllamaModel[]>('/openwebui/models', {
|
||||
const response = await this.client.get<NomadOllamaModel[]>('/ollama/models', {
|
||||
params: { sort: 'pulls', recommendedOnly: true },
|
||||
})
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async sendChatMessage(chatRequest: OllamaChatRequest) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<ChatResponse>('/ollama/chat', chatRequest)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getChatSessions() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<
|
||||
Array<{
|
||||
id: string
|
||||
title: string
|
||||
model: string | null
|
||||
timestamp: string
|
||||
lastMessage: string | null
|
||||
}>
|
||||
>('/chat/sessions')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getChatSession(sessionId: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{
|
||||
id: string
|
||||
title: string
|
||||
model: string | null
|
||||
timestamp: string
|
||||
messages: Array<{
|
||||
id: string
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
}>
|
||||
}>(`/chat/sessions/${sessionId}`)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async createChatSession(title: string, model?: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{
|
||||
id: string
|
||||
title: string
|
||||
model: string | null
|
||||
timestamp: string
|
||||
}>('/chat/sessions', { title, model })
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async updateChatSession(sessionId: string, data: { title?: string; model?: string }) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.put<{
|
||||
id: string
|
||||
title: string
|
||||
model: string | null
|
||||
timestamp: string
|
||||
}>(`/chat/sessions/${sessionId}`, data)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async deleteChatSession(sessionId: string) {
|
||||
return catchInternal(async () => {
|
||||
await this.client.delete(`/chat/sessions/${sessionId}`)
|
||||
})()
|
||||
}
|
||||
|
||||
async deleteAllChatSessions() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.delete<{ success: boolean; message: string }>(
|
||||
'/chat/sessions/all'
|
||||
)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async addChatMessage(sessionId: string, role: 'system' | 'user' | 'assistant', content: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{
|
||||
id: string
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
}>(`/chat/sessions/${sessionId}/messages`, { role, content })
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getStoredRAGFiles() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{ files: string[] }>('/rag/files')
|
||||
return response.data.files
|
||||
})()
|
||||
}
|
||||
|
||||
async getSystemInfo() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<SystemInformationResponse>('/system/info')
|
||||
|
|
@ -295,6 +401,23 @@ class API {
|
|||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async uploadDocument(file: File) {
|
||||
return catchInternal(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response = await this.client.post<{ message: string; file_path: string }>(
|
||||
'/rag/upload',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
export default new API()
|
||||
|
|
|
|||
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 { useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import FileUploader from '~/components/file-uploader'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||
import StyledTable from '~/components/StyledTable'
|
||||
import { useNotifications } from '~/context/NotificationContext'
|
||||
import AppLayout from '~/layouts/AppLayout'
|
||||
import api from '~/lib/api'
|
||||
|
||||
export default function KnowledgeBase() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { addNotification } = useNotifications()
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
|
||||
|
||||
const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({
|
||||
queryKey: ['storedFiles'],
|
||||
queryFn: () => api.getStoredRAGFiles(),
|
||||
select: (data) => data || [],
|
||||
})
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => api.uploadDocument(file),
|
||||
onSuccess: (data) => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: data?.message || 'Document uploaded and queued for processing',
|
||||
})
|
||||
setFiles([])
|
||||
if (fileUploaderRef.current) {
|
||||
fileUploaderRef.current.clear()
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error?.message || 'Failed to upload document',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpload = () => {
|
||||
if (files.length > 0) {
|
||||
uploadMutation.mutate(files[0])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
|
|
@ -15,12 +53,11 @@ export default function KnowledgeBase() {
|
|||
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
|
||||
<div className="p-6">
|
||||
<FileUploader
|
||||
ref={fileUploaderRef}
|
||||
minFiles={1}
|
||||
maxFiles={1}
|
||||
onUpload={(files) => {
|
||||
setLoading(true)
|
||||
setFiles(Array.from(files))
|
||||
setLoading(false)
|
||||
onUpload={(uploadedFiles) => {
|
||||
setFiles(Array.from(uploadedFiles))
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-center gap-4 my-6">
|
||||
|
|
@ -28,9 +65,9 @@ export default function KnowledgeBase() {
|
|||
variant="primary"
|
||||
size="lg"
|
||||
icon="ArrowUpCircleIcon"
|
||||
onClick={() => {}}
|
||||
disabled={files.length === 0 || loading}
|
||||
loading={loading}
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || uploadMutation.isPending}
|
||||
loading={uploadMutation.isPending}
|
||||
>
|
||||
Upload
|
||||
</StyledButton>
|
||||
|
|
@ -91,6 +128,25 @@ export default function KnowledgeBase() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-12">
|
||||
<StyledSectionHeader title="Stored Knowledge Base Files" />
|
||||
<StyledTable<{ source: string }>
|
||||
className="font-semibold"
|
||||
rowLines={true}
|
||||
columns={[
|
||||
{
|
||||
accessor: 'source',
|
||||
title: 'File Name',
|
||||
render(record) {
|
||||
return <span className="text-gray-700">{record.source}</span>
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={storedFiles.map((source) => ({ source }))}
|
||||
loading={isLoadingFiles}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</AppLayout>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -50,11 +50,6 @@ export default function LegalPage() {
|
|||
<br />
|
||||
<a href="https://learningequality.org/kolibri" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://learningequality.org/kolibri</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Open WebUI</strong> - Web interface for local AI models (MIT License)
|
||||
<br />
|
||||
<a href="https://openwebui.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://openwebui.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ollama</strong> - Local large language model runtime (MIT License)
|
||||
<br />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Head, router } from '@inertiajs/react'
|
||||
import StyledTable from '~/components/StyledTable'
|
||||
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||
import { NomadOllamaModel, OllamaModelListing } from '../../../types/ollama'
|
||||
import { NomadOllamaModel } from '../../../types/ollama'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
||||
import Alert from '~/components/Alert'
|
||||
|
|
@ -9,9 +9,10 @@ import { useNotifications } from '~/context/NotificationContext'
|
|||
import api from '~/lib/api'
|
||||
import { useModals } from '~/context/ModalContext'
|
||||
import StyledModal from '~/components/StyledModal'
|
||||
import { ModelResponse } from 'ollama'
|
||||
|
||||
export default function ModelsPage(props: {
|
||||
models: { availableModels: NomadOllamaModel[]; installedModels: OllamaModelListing[] }
|
||||
models: { availableModels: NomadOllamaModel[]; installedModels: ModelResponse[] }
|
||||
}) {
|
||||
const { isInstalled } = useServiceInstalledStatus('nomad_open_webui')
|
||||
const { addNotification } = useNotifications()
|
||||
|
|
|
|||
1460
admin/package-lock.json
generated
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",
|
||||
"pdf2pic": "^3.2.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"playwright": "^1.58.0",
|
||||
"pmtiles": "^4.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.0",
|
||||
"react-adonis-transmit": "^1.0.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"systeminformation": "^5.27.14",
|
||||
"tailwindcss": "^4.1.10",
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@
|
|||
|
|
||||
*/
|
||||
import BenchmarkController from '#controllers/benchmark_controller'
|
||||
import ChatsController from '#controllers/chats_controller'
|
||||
import DocsController from '#controllers/docs_controller'
|
||||
import DownloadsController from '#controllers/downloads_controller'
|
||||
import EasySetupController from '#controllers/easy_setup_controller'
|
||||
import HomeController from '#controllers/home_controller'
|
||||
import MapsController from '#controllers/maps_controller'
|
||||
import OpenWebUIController from '#controllers/openwebui_controller'
|
||||
import OllamaController from '#controllers/ollama_controller'
|
||||
import RagController from '#controllers/rag_controller'
|
||||
import SettingsController from '#controllers/settings_controller'
|
||||
import SystemController from '#controllers/system_controller'
|
||||
import ZimController from '#controllers/zim_controller'
|
||||
|
|
@ -24,6 +26,7 @@ transmit.registerRoutes()
|
|||
router.get('/', [HomeController, 'index'])
|
||||
router.get('/home', [HomeController, 'home'])
|
||||
router.on('/about').renderInertia('about')
|
||||
router.on('/chat').renderInertia('chat')
|
||||
router.on('/knowledge-base').renderInertia('knowledge-base')
|
||||
router.get('/maps', [MapsController, 'index'])
|
||||
|
||||
|
|
@ -90,12 +93,32 @@ router.get('/api/health', () => {
|
|||
|
||||
router
|
||||
.group(() => {
|
||||
router.get('/models', [OpenWebUIController, 'models'])
|
||||
router.get('/installed-models', [OpenWebUIController, 'installedModels'])
|
||||
router.post('/download-model', [OpenWebUIController, 'dispatchModelDownload'])
|
||||
router.post('/delete-model', [OpenWebUIController, 'deleteModel'])
|
||||
router.post('/chat', [OllamaController, 'chat'])
|
||||
router.get('/models', [OllamaController, 'availableModels'])
|
||||
router.post('/models', [OllamaController, 'dispatchModelDownload'])
|
||||
router.delete('/models', [OllamaController, 'deleteModel'])
|
||||
router.get('/installed-models', [OllamaController, 'installedModels'])
|
||||
})
|
||||
.prefix('/api/openwebui')
|
||||
.prefix('/api/ollama')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.get('/', [ChatsController, 'index'])
|
||||
router.post('/', [ChatsController, 'store'])
|
||||
router.delete('/all', [ChatsController, 'destroyAll'])
|
||||
router.get('/:id', [ChatsController, 'show'])
|
||||
router.put('/:id', [ChatsController, 'update'])
|
||||
router.delete('/:id', [ChatsController, 'destroy'])
|
||||
router.post('/:id/messages', [ChatsController, 'addMessage'])
|
||||
})
|
||||
.prefix('/api/chat/sessions')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.post('/upload', [RagController, 'upload'])
|
||||
router.get('/files', [RagController, 'getStoredFiles'])
|
||||
})
|
||||
.prefix('/api/rag')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
|
|
|
|||
14
admin/types/chat.ts
Normal file
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[]
|
||||
}
|
||||
|
||||
export type OllamaModelListing = {
|
||||
name: string
|
||||
id: string
|
||||
size: string
|
||||
modified: string
|
||||
export type OllamaChatMessage = {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export type OllamaChatRequest = {
|
||||
model: string
|
||||
messages: OllamaChatMessage[]
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
export type OpenWebUIKnowledgeFileMetadata = {
|
||||
source: string
|
||||
name: string
|
||||
created_by: string
|
||||
file_id: string
|
||||
start_index: number
|
||||
hash: string
|
||||
}
|
||||
export type OllamaChatResponse = {
|
||||
model: string
|
||||
created_at: string
|
||||
message: {
|
||||
role: string
|
||||
content: string
|
||||
}
|
||||
done: boolean
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user