feat: [wip] Open WebUI manipulation

This commit is contained in:
Jake Turner 2026-01-16 05:25:11 +00:00 committed by Jake Turner
parent b6e6e10328
commit b3ef977484
8 changed files with 289 additions and 4 deletions

View File

@ -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

View File

@ -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', {

View 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}`
}
}

View File

@ -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'),

View 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>
)
}

View File

@ -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",

View File

@ -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",

View File

@ -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'])