mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
Exisiting Ollama API support still functions as before. OpenAI vs Ollama API mostly have the same features, however model file size is not support with OpenAI's API so when a user chooses one of those then the models will just show up as the model name without the size. `npm install openai` triggered some updates in admin/package-lock.json such as adding many instances of "dev: true". This further enhances the user's ability to run the LLM on a different host.
704 lines
21 KiB
TypeScript
704 lines
21 KiB
TypeScript
import axios, { AxiosError, AxiosInstance } from 'axios'
|
|
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
|
|
import { ServiceSlim } from '../../types/services'
|
|
import { FileEntry } from '../../types/files'
|
|
import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
|
|
import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'
|
|
import { EmbedJobWithProgress } from '../../types/rag'
|
|
import type { CategoryWithStatus, CollectionWithStatus, ContentUpdateCheckResult, ResourceUpdateInfo } from '../../types/collections'
|
|
import { catchInternal } from './util'
|
|
import { NomadChatResponse, NomadInstalledModel, NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
|
|
import BenchmarkResult from '#models/benchmark_result'
|
|
import { BenchmarkType, RunBenchmarkResponse, SubmitBenchmarkResponse, UpdateBuilderTagResponse } from '../../types/benchmark'
|
|
|
|
class API {
|
|
private client: AxiosInstance
|
|
|
|
constructor() {
|
|
this.client = axios.create({
|
|
baseURL: '/api',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
})
|
|
}
|
|
|
|
async affectService(service_name: string, action: 'start' | 'stop' | 'restart') {
|
|
try {
|
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
|
'/system/services/affect',
|
|
{ service_name, action }
|
|
)
|
|
return response.data
|
|
} catch (error) {
|
|
if (error instanceof AxiosError && error.response?.data?.message) {
|
|
return { success: false, message: error.response.data.message }
|
|
}
|
|
console.error('Error affecting service:', error)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
async checkLatestVersion(force: boolean = false) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<CheckLatestVersionResult>('/system/latest-version', {
|
|
params: { force },
|
|
})
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async configureRemoteOllama(remoteUrl: string | null): Promise<{ success: boolean; message: string }> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
|
'/ollama/configure-remote',
|
|
{ remoteUrl }
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async deleteModel(model: string): Promise<{ success: boolean; message: string }> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.delete('/ollama/models', { data: { model } })
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async downloadBaseMapAssets() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ success: boolean }>('/maps/download-base-assets')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async downloadMapCollection(slug: string): Promise<{
|
|
message: string
|
|
slug: string
|
|
resources: string[] | null
|
|
}> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post('/maps/download-collection', { slug })
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async downloadModel(model: string): Promise<{ success: boolean; message: string }> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post('/ollama/models', { model })
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async downloadCategoryTier(categorySlug: string, tierSlug: string): Promise<{
|
|
message: string
|
|
categorySlug: string
|
|
tierSlug: string
|
|
resources: string[] | null
|
|
}> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post('/zim/download-category-tier', { categorySlug, tierSlug })
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async downloadRemoteMapRegion(url: string) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ message: string; filename: string; url: string }>(
|
|
'/maps/download-remote',
|
|
{ url }
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async downloadRemoteMapRegionPreflight(url: string) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<
|
|
{ filename: string; size: number } | { message: string }
|
|
>('/maps/download-remote-preflight', { url })
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async downloadRemoteZimFile(
|
|
url: string,
|
|
metadata?: { title: string; summary?: string; author?: string; size_bytes?: number }
|
|
) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ message: string; filename: string; url: string }>(
|
|
'/zim/download-remote',
|
|
{ url, metadata }
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async fetchLatestMapCollections(): Promise<{ success: boolean } | undefined> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ success: boolean }>(
|
|
'/maps/fetch-latest-collections'
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async checkForContentUpdates() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<ContentUpdateCheckResult>('/content-updates/check')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async applyContentUpdate(update: ResourceUpdateInfo) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ success: boolean; jobId?: string; error?: string }>(
|
|
'/content-updates/apply',
|
|
update
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async applyAllContentUpdates(updates: ResourceUpdateInfo[]) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{
|
|
results: Array<{ resource_id: string; success: boolean; jobId?: string; error?: string }>
|
|
}>('/content-updates/apply-all', { updates })
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async refreshManifests(): Promise<{ success: boolean; changed: Record<string, boolean> } | undefined> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ success: boolean; changed: Record<string, boolean> }>(
|
|
'/manifests/refresh'
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async checkServiceUpdates() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
|
'/system/services/check-updates'
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async getAvailableVersions(serviceName: string) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{
|
|
versions: Array<{ tag: string; isLatest: boolean; releaseUrl?: string }>
|
|
}>(`/system/services/${serviceName}/available-versions`)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async updateService(serviceName: string, targetVersion: string) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
|
'/system/services/update',
|
|
{ service_name: serviceName, target_version: targetVersion }
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async forceReinstallService(service_name: string) {
|
|
try {
|
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
|
`/system/services/force-reinstall`,
|
|
{ service_name }
|
|
)
|
|
return response.data
|
|
} catch (error) {
|
|
if (error instanceof AxiosError && error.response?.data?.message) {
|
|
return { success: false, message: error.response.data.message }
|
|
}
|
|
console.error('Error force reinstalling service:', error)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
async getChatSuggestions(signal?: AbortSignal) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{ suggestions: string[] }>(
|
|
'/chat/suggestions',
|
|
{ signal }
|
|
)
|
|
return response.data.suggestions
|
|
})()
|
|
}
|
|
|
|
async getDebugInfo() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{ debugInfo: string }>('/system/debug-info')
|
|
return response.data.debugInfo
|
|
})()
|
|
}
|
|
|
|
async getInternetStatus() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<boolean>('/system/internet-status')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async getInstalledModels() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<NomadInstalledModel[]>('/ollama/installed-models')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async getAvailableModels(params: { query?: string; recommendedOnly?: boolean; limit?: number; force?: boolean }) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{
|
|
models: NomadOllamaModel[]
|
|
hasMore: boolean
|
|
}>('/ollama/models', {
|
|
params: { sort: 'pulls', ...params },
|
|
})
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async sendChatMessage(chatRequest: OllamaChatRequest) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<NomadChatResponse>('/ollama/chat', chatRequest)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async streamChatMessage(
|
|
chatRequest: OllamaChatRequest,
|
|
onChunk: (content: string, thinking: string, done: boolean) => void,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
// Axios doesn't support ReadableStream in browser, so need to use fetch
|
|
const response = await fetch('/api/ollama/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...chatRequest, stream: true }),
|
|
signal,
|
|
})
|
|
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`HTTP error: ${response.status}`)
|
|
}
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() || ''
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data: ')) continue
|
|
let data: any
|
|
try {
|
|
data = JSON.parse(line.slice(6))
|
|
} catch { continue /* skip malformed chunks */ }
|
|
|
|
if (data.error) throw new Error('The model encountered an error. Please try again.')
|
|
|
|
onChunk(
|
|
data.message?.content ?? '',
|
|
data.message?.thinking ?? '',
|
|
data.done ?? false
|
|
)
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock()
|
|
}
|
|
}
|
|
|
|
async getBenchmarkResults() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{ results: BenchmarkResult[], total: number }>('/benchmark/results')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async getLatestBenchmarkResult() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{ result: BenchmarkResult | null }>('/benchmark/results/latest')
|
|
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 getActiveEmbedJobs(): Promise<EmbedJobWithProgress[] | undefined> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<EmbedJobWithProgress[]>('/rag/active-jobs')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async getStoredRAGFiles() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{ files: string[] }>('/rag/files')
|
|
return response.data.files
|
|
})()
|
|
}
|
|
|
|
async deleteRAGFile(source: string) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.delete<{ message: string }>('/rag/files', { data: { source } })
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async getSystemInfo() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<SystemInformationResponse>('/system/info')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async getSystemServices() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<Array<ServiceSlim>>('/system/services')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async getSystemUpdateStatus() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<SystemUpdateStatus>('/system/update/status')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async getSystemUpdateLogs() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{ logs: string }>('/system/update/logs')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async healthCheck() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{ status: string }>('/health', {
|
|
timeout: 5000,
|
|
})
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async installService(service_name: string) {
|
|
try {
|
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
|
'/system/services/install',
|
|
{ service_name }
|
|
)
|
|
return response.data
|
|
} catch (error) {
|
|
if (error instanceof AxiosError && error.response?.data?.message) {
|
|
return { success: false, message: error.response.data.message }
|
|
}
|
|
console.error('Error installing service:', error)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
async listCuratedMapCollections() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<CollectionWithStatus[]>(
|
|
'/maps/curated-collections'
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async listCuratedCategories() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<CategoryWithStatus[]>('/easy-setup/curated-categories')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async listDocs() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async listMapRegionFiles() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{ files: FileEntry[] }>('/maps/regions')
|
|
return response.data.files
|
|
})()
|
|
}
|
|
|
|
async listRemoteZimFiles({
|
|
start = 0,
|
|
count = 12,
|
|
query,
|
|
}: {
|
|
start?: number
|
|
count?: number
|
|
query?: string
|
|
}) {
|
|
return catchInternal(async () => {
|
|
return await this.client.get<ListRemoteZimFilesResponse>('/zim/list-remote', {
|
|
params: {
|
|
start,
|
|
count,
|
|
query,
|
|
},
|
|
})
|
|
})()
|
|
}
|
|
|
|
async deleteZimFile(filename: string) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.delete<{ message: string }>(`/zim/${filename}`)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async listZimFiles() {
|
|
return catchInternal(async () => {
|
|
return await this.client.get<ListZimFilesResponse>('/zim/list')
|
|
})()
|
|
}
|
|
|
|
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[] | undefined> {
|
|
return catchInternal(async () => {
|
|
const endpoint = filetype ? `/downloads/jobs/${filetype}` : '/downloads/jobs'
|
|
const response = await this.client.get<DownloadJobWithProgress[]>(endpoint)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async removeDownloadJob(jobId: string): Promise<void> {
|
|
return catchInternal(async () => {
|
|
await this.client.delete(`/downloads/jobs/${jobId}`)
|
|
})()
|
|
}
|
|
|
|
async runBenchmark(type: BenchmarkType, sync: boolean = false) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<RunBenchmarkResponse>(
|
|
`/benchmark/run${sync ? '?sync=true' : ''}`,
|
|
{ benchmark_type: type },
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async startSystemUpdate() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
|
'/system/update'
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async submitBenchmark(benchmark_id: string, anonymous: boolean) {
|
|
try {
|
|
const response = await this.client.post<SubmitBenchmarkResponse>('/benchmark/submit', { benchmark_id, anonymous })
|
|
return response.data
|
|
} catch (error: any) {
|
|
// For 409 Conflict errors, throw a specific error that the UI can handle
|
|
if (error.response?.status === 409) {
|
|
const err = new Error(error.response?.data?.error || 'This benchmark has already been submitted to the repository')
|
|
; (err as any).status = 409
|
|
throw err
|
|
}
|
|
// For other errors, extract the message and throw
|
|
const errorMessage = error.response?.data?.error || error.message || 'Failed to submit benchmark'
|
|
throw new Error(errorMessage)
|
|
}
|
|
}
|
|
|
|
async subscribeToReleaseNotes(email: string) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
|
'/system/subscribe-release-notes',
|
|
{ email }
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async syncRAGStorage() {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{
|
|
success: boolean
|
|
message: string
|
|
filesScanned?: number
|
|
filesQueued?: number
|
|
}>('/rag/sync')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
// Wikipedia selector methods
|
|
|
|
async getWikipediaState(): Promise<WikipediaState | undefined> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<WikipediaState>('/zim/wikipedia')
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async selectWikipedia(
|
|
optionId: string
|
|
): Promise<{ success: boolean; jobId?: string; message?: string } | undefined> {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<{
|
|
success: boolean
|
|
jobId?: string
|
|
message?: string
|
|
}>('/zim/wikipedia/select', { optionId })
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async updateBuilderTag(benchmark_id: string, builder_tag: string) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.post<UpdateBuilderTagResponse>(
|
|
'/benchmark/builder-tag',
|
|
{ benchmark_id, builder_tag }
|
|
)
|
|
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
|
|
})()
|
|
}
|
|
|
|
async getSetting(key: string) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.get<{ key: string; value: any }>(
|
|
'/system/settings',
|
|
{ params: { key } }
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
|
|
async updateSetting(key: string, value: any) {
|
|
return catchInternal(async () => {
|
|
const response = await this.client.patch<{ success: boolean; message: string }>(
|
|
'/system/settings',
|
|
{ key, value }
|
|
)
|
|
return response.data
|
|
})()
|
|
}
|
|
}
|
|
|
|
export default new API()
|