mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-04 07:46:16 +02:00
feat: [wip] Open WebUI manipulation
This commit is contained in:
parent
b6e6e10328
commit
b3ef977484
34
Dockerfile
34
Dockerfile
|
|
@ -1,7 +1,37 @@
|
||||||
FROM node:22.16.0-alpine3.22 AS base
|
FROM node:22-slim AS base
|
||||||
|
|
||||||
# Install bash & curl for entrypoint script compatibility
|
# Install bash & curl for entrypoint script compatibility
|
||||||
RUN apk add --no-cache bash curl
|
# as well as dependencies for Playwright Chromium
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
bash \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
ca-certificates \
|
||||||
|
fonts-liberation \
|
||||||
|
libnss3 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libgbm1 \
|
||||||
|
libasound2 \
|
||||||
|
libxcb-shm0 \
|
||||||
|
libx11-xcb1 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxcursor1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxi6 \
|
||||||
|
libgtk-3-0t64 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libatk1.0-0t64 \
|
||||||
|
libcairo-gobject2 \
|
||||||
|
libcairo2 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libxrender1 \
|
||||||
|
libasound2t64
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# All deps stage
|
# All deps stage
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { MapService } from '#services/map_service';
|
import { MapService } from '#services/map_service';
|
||||||
|
import { OpenWebUIService } from '#services/openwebui_service';
|
||||||
import { SystemService } from '#services/system_service';
|
import { SystemService } from '#services/system_service';
|
||||||
import { inject } from '@adonisjs/core';
|
import { inject } from '@adonisjs/core';
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
|
@ -7,7 +8,8 @@ import type { HttpContext } from '@adonisjs/core/http'
|
||||||
export default class SettingsController {
|
export default class SettingsController {
|
||||||
constructor(
|
constructor(
|
||||||
private systemService: SystemService,
|
private systemService: SystemService,
|
||||||
private mapService: MapService
|
private mapService: MapService,
|
||||||
|
private openWebUIService: OpenWebUIService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async system({ inertia }: HttpContext) {
|
async system({ inertia }: HttpContext) {
|
||||||
|
|
@ -43,6 +45,15 @@ export default class SettingsController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async models({ inertia }: HttpContext) {
|
||||||
|
const installedModels = await this.openWebUIService.getInstalledModels();
|
||||||
|
return inertia.render('settings/models', {
|
||||||
|
models: {
|
||||||
|
installedModels: installedModels || []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async update({ inertia }: HttpContext) {
|
async update({ inertia }: HttpContext) {
|
||||||
const updateInfo = await this.systemService.checkLatestVersion();
|
const updateInfo = await this.systemService.checkLatestVersion();
|
||||||
return inertia.render('settings/update', {
|
return inertia.render('settings/update', {
|
||||||
|
|
|
||||||
123
admin/app/services/openwebui_service.ts
Normal file
123
admin/app/services/openwebui_service.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { inject } from '@adonisjs/core'
|
||||||
|
import { chromium } from 'playwright'
|
||||||
|
import { SystemService } from './system_service.js'
|
||||||
|
import logger from '@adonisjs/core/services/logger'
|
||||||
|
import { DockerService } from './docker_service.js'
|
||||||
|
import { ServiceSlim } from '../../types/services.js'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
@inject()
|
||||||
|
export class OpenWebUIService {
|
||||||
|
constructor(private systemService: SystemService) {}
|
||||||
|
|
||||||
|
async getOpenWebUIToken(): Promise<{
|
||||||
|
token: string
|
||||||
|
location: string
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const { openWebUIService } = await this.getOpenWebUIAndOllamaServices()
|
||||||
|
if (!openWebUIService) {
|
||||||
|
logger.warn('[OpenWebUIService] Open WebUI service is not installed.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = this.extractOpenWebUIUrl(openWebUIService)
|
||||||
|
if (!location) {
|
||||||
|
logger.warn('[OpenWebUIService] Could not determine Open WebUI URL.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true })
|
||||||
|
const context = await browser.newContext()
|
||||||
|
const page = await context.newPage()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(location, { waitUntil: 'networkidle' })
|
||||||
|
|
||||||
|
const cookies = await context.cookies()
|
||||||
|
const tokenCookie = cookies.find((cookie) => cookie.name === 'token')
|
||||||
|
if (tokenCookie) {
|
||||||
|
return { token: tokenCookie.value, location }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[OpenWebUIService] Failed to get Open WebUI token: ${error instanceof Error ? error.message : error}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstalledModels(): Promise<string[] | null> {
|
||||||
|
try {
|
||||||
|
const tokenData = await this.getOpenWebUIToken()
|
||||||
|
if (!tokenData) {
|
||||||
|
logger.warn('[OpenWebUIService] Cannot get installed models without Open WebUI token.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(tokenData.location + '/ollama/api/tags', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokenData.token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data.models && Array.isArray(response.data.models)) {
|
||||||
|
console.log("GOT RESPONSE DATA:", response.data)
|
||||||
|
return response.data.models as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`[OpenWebUIService] Unexpected response when fetching installed models: ${response.status}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[OpenWebUIService] Failed to get installed models: ${error instanceof Error ? error.message : error}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOpenWebUIAndOllamaServices(): Promise<{
|
||||||
|
openWebUIService: ServiceSlim | null
|
||||||
|
ollamaService: ServiceSlim | null
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const services = await this.systemService.getServices({ installedOnly: true })
|
||||||
|
|
||||||
|
const owuiContainer = services.find(
|
||||||
|
(service) => service.service_name === DockerService.OPEN_WEBUI_SERVICE_NAME
|
||||||
|
)
|
||||||
|
const ollamaContainer = services.find(
|
||||||
|
(service) => service.service_name === DockerService.OLLAMA_SERVICE_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
openWebUIService: owuiContainer || null,
|
||||||
|
ollamaService: ollamaContainer || null,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[OpenWebUIService] Failed to get Open WebUI and Ollama services: ${error instanceof Error ? error.message : error}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
openWebUIService: null,
|
||||||
|
ollamaService: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractOpenWebUIUrl(service: ServiceSlim): string | null {
|
||||||
|
const location = service.ui_location || '3000'
|
||||||
|
if (!location || isNaN(Number(location))) {
|
||||||
|
logger.warn(`[OpenWebUIService] Invalid Open WebUI location: ${location}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `http://localhost:${location}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,13 @@ import {
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { IconArrowBigUpLines, IconDashboard, IconGavel, IconMapRoute } from '@tabler/icons-react'
|
import {
|
||||||
|
IconArrowBigUpLines,
|
||||||
|
IconDashboard,
|
||||||
|
IconDatabaseStar,
|
||||||
|
IconGavel,
|
||||||
|
IconMapRoute,
|
||||||
|
} from '@tabler/icons-react'
|
||||||
import StyledSidebar from '~/components/StyledSidebar'
|
import StyledSidebar from '~/components/StyledSidebar'
|
||||||
import { getServiceLink } from '~/lib/navigation'
|
import { getServiceLink } from '~/lib/navigation'
|
||||||
|
|
||||||
|
|
@ -12,6 +18,7 @@ const navigation = [
|
||||||
{ name: 'Apps', href: '/settings/apps', icon: CommandLineIcon, current: false },
|
{ name: 'Apps', href: '/settings/apps', icon: CommandLineIcon, current: false },
|
||||||
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
|
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
|
||||||
{ name: 'Maps Manager', href: '/settings/maps', icon: IconMapRoute, current: false },
|
{ name: 'Maps Manager', href: '/settings/maps', icon: IconMapRoute, current: false },
|
||||||
|
{ name: 'Models Manager', href: '/settings/models', icon: IconDatabaseStar, current: false },
|
||||||
{
|
{
|
||||||
name: 'Service Logs & Metrics',
|
name: 'Service Logs & Metrics',
|
||||||
href: getServiceLink('9999'),
|
href: getServiceLink('9999'),
|
||||||
|
|
|
||||||
66
admin/inertia/pages/settings/models.tsx
Normal file
66
admin/inertia/pages/settings/models.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Head } from '@inertiajs/react'
|
||||||
|
import StyledTable from '~/components/StyledTable'
|
||||||
|
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||||
|
import { ServiceSlim } from '../../../types/services'
|
||||||
|
import { getServiceLink } from '~/lib/navigation'
|
||||||
|
import LoadingSpinner from '~/components/LoadingSpinner'
|
||||||
|
import { IconCheck } from '@tabler/icons-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function ModelsPage(props: { models: { installedModels: string[] } }) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout>
|
||||||
|
<Head title="App Settings" />
|
||||||
|
<div className="xl:pl-72 w-full">
|
||||||
|
<main className="px-12 py-6">
|
||||||
|
<h1 className="text-4xl font-semibold mb-4">Models</h1>
|
||||||
|
<p className="text-gray-500 mb-4">Easily manage the AI models available for Open WebUI</p>
|
||||||
|
{loading && <LoadingSpinner fullscreen />}
|
||||||
|
{!loading && (
|
||||||
|
<StyledTable<ServiceSlim & { actions?: any }>
|
||||||
|
className="font-semibold"
|
||||||
|
rowLines={true}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
accessor: 'friendly_name',
|
||||||
|
title: 'Name',
|
||||||
|
render(record) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p>{record.friendly_name || record.service_name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{record.description}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'ui_location',
|
||||||
|
title: 'Port',
|
||||||
|
render: (record) => (
|
||||||
|
<a
|
||||||
|
href={getServiceLink(record.ui_location || 'unknown')}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-desert-green hover:underline font-semibold"
|
||||||
|
>
|
||||||
|
{record.ui_location}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'installed',
|
||||||
|
title: 'Installed',
|
||||||
|
render: (record) =>
|
||||||
|
record.installed ? <IconCheck className="h-6 w-6 text-desert-green" /> : '',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={[]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
admin/package-lock.json
generated
45
admin/package-lock.json
generated
|
|
@ -43,6 +43,7 @@
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"mysql2": "^3.14.1",
|
"mysql2": "^3.14.1",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
|
"playwright": "^1.57.0",
|
||||||
"pmtiles": "^4.3.0",
|
"pmtiles": "^4.3.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|
@ -9678,6 +9679,50 @@
|
||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||||
|
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.57.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||||
|
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pluralize": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node bin/server.js",
|
"start": "node bin/server.js",
|
||||||
"build": "node ace build",
|
"build": "node ace build",
|
||||||
|
"postinstall": "playwright install chromium --with-deps",
|
||||||
"dev": "node ace serve --hmr",
|
"dev": "node ace serve --hmr",
|
||||||
"test": "node ace test",
|
"test": "node ace test",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|
@ -91,6 +92,7 @@
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"mysql2": "^3.14.1",
|
"mysql2": "^3.14.1",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
|
"playwright": "^1.57.0",
|
||||||
"pmtiles": "^4.3.0",
|
"pmtiles": "^4.3.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ router
|
||||||
router.get('/apps', [SettingsController, 'apps'])
|
router.get('/apps', [SettingsController, 'apps'])
|
||||||
router.get('/legal', [SettingsController, 'legal'])
|
router.get('/legal', [SettingsController, 'legal'])
|
||||||
router.get('/maps', [SettingsController, 'maps'])
|
router.get('/maps', [SettingsController, 'maps'])
|
||||||
|
router.get('/models', [SettingsController, 'models'])
|
||||||
router.get('/update', [SettingsController, 'update'])
|
router.get('/update', [SettingsController, 'update'])
|
||||||
router.get('/zim', [SettingsController, 'zim'])
|
router.get('/zim', [SettingsController, 'zim'])
|
||||||
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user