diff --git a/Dockerfile b/Dockerfile index 3e77d9d..5a7badf 100644 --- a/Dockerfile +++ b/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 -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 FROM base AS deps diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index df3ac76..73cdfe5 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -1,4 +1,5 @@ import { MapService } from '#services/map_service'; +import { OpenWebUIService } from '#services/openwebui_service'; import { SystemService } from '#services/system_service'; import { inject } from '@adonisjs/core'; import type { HttpContext } from '@adonisjs/core/http' @@ -7,7 +8,8 @@ import type { HttpContext } from '@adonisjs/core/http' export default class SettingsController { constructor( private systemService: SystemService, - private mapService: MapService + private mapService: MapService, + private openWebUIService: OpenWebUIService ) { } 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) { const updateInfo = await this.systemService.checkLatestVersion(); return inertia.render('settings/update', { diff --git a/admin/app/services/openwebui_service.ts b/admin/app/services/openwebui_service.ts new file mode 100644 index 0000000..f4a756a --- /dev/null +++ b/admin/app/services/openwebui_service.ts @@ -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 { + 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}` + } +} diff --git a/admin/inertia/layouts/SettingsLayout.tsx b/admin/inertia/layouts/SettingsLayout.tsx index b239685..dd08a4c 100644 --- a/admin/inertia/layouts/SettingsLayout.tsx +++ b/admin/inertia/layouts/SettingsLayout.tsx @@ -4,7 +4,13 @@ import { FolderIcon, MagnifyingGlassIcon, } 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 { getServiceLink } from '~/lib/navigation' @@ -12,6 +18,7 @@ const navigation = [ { name: 'Apps', href: '/settings/apps', icon: CommandLineIcon, current: false }, { name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, 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', href: getServiceLink('9999'), diff --git a/admin/inertia/pages/settings/models.tsx b/admin/inertia/pages/settings/models.tsx new file mode 100644 index 0000000..8f01e89 --- /dev/null +++ b/admin/inertia/pages/settings/models.tsx @@ -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 ( + + +
+
+

Models

+

Easily manage the AI models available for Open WebUI

+ {loading && } + {!loading && ( + + className="font-semibold" + rowLines={true} + columns={[ + { + accessor: 'friendly_name', + title: 'Name', + render(record) { + return ( +
+

{record.friendly_name || record.service_name}

+

{record.description}

+
+ ) + }, + }, + { + accessor: 'ui_location', + title: 'Port', + render: (record) => ( + + {record.ui_location} + + ), + }, + { + accessor: 'installed', + title: 'Installed', + render: (record) => + record.installed ? : '', + }, + ]} + data={[]} + /> + )} +
+
+
+ ) +} diff --git a/admin/package-lock.json b/admin/package-lock.json index 15c17ea..d3d5409 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -43,6 +43,7 @@ "maplibre-gl": "^4.7.1", "mysql2": "^3.14.1", "pino-pretty": "^13.0.0", + "playwright": "^1.57.0", "pmtiles": "^4.3.0", "postcss": "^8.5.6", "react": "^19.1.0", @@ -9678,6 +9679,50 @@ "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": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/admin/package.json b/admin/package.json index 70b693b..e858973 100644 --- a/admin/package.json +++ b/admin/package.json @@ -8,6 +8,7 @@ "scripts": { "start": "node bin/server.js", "build": "node ace build", + "postinstall": "playwright install chromium --with-deps", "dev": "node ace serve --hmr", "test": "node ace test", "lint": "eslint .", @@ -91,6 +92,7 @@ "maplibre-gl": "^4.7.1", "mysql2": "^3.14.1", "pino-pretty": "^13.0.0", + "playwright": "^1.57.0", "pmtiles": "^4.3.0", "postcss": "^8.5.6", "react": "^19.1.0", diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 6152e66..2019654 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -33,6 +33,7 @@ router router.get('/apps', [SettingsController, 'apps']) router.get('/legal', [SettingsController, 'legal']) router.get('/maps', [SettingsController, 'maps']) + router.get('/models', [SettingsController, 'models']) router.get('/update', [SettingsController, 'update']) router.get('/zim', [SettingsController, 'zim']) router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])