mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-31 14:09:26 +02:00
Compare commits
4 Commits
main
...
v1.29.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a32a548c | ||
|
|
5a35856747 | ||
|
|
6783cda222 | ||
|
|
175d63da8b |
|
|
@ -22,6 +22,7 @@ export default class OllamaController {
|
||||||
recommendedOnly: reqData.recommendedOnly,
|
recommendedOnly: reqData.recommendedOnly,
|
||||||
query: reqData.query || null,
|
query: reqData.query || null,
|
||||||
limit: reqData.limit || 15,
|
limit: reqData.limit || 15,
|
||||||
|
force: reqData.force,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ export class OllamaService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableModels(
|
async getAvailableModels(
|
||||||
{ sort, recommendedOnly, query, limit }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean, query: string | null, limit?: number } = {
|
{ sort, recommendedOnly, query, limit, force }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean, query: string | null, limit?: number, force?: boolean } = {
|
||||||
sort: 'pulls',
|
sort: 'pulls',
|
||||||
recommendedOnly: false,
|
recommendedOnly: false,
|
||||||
query: null,
|
query: null,
|
||||||
|
|
@ -191,7 +191,7 @@ export class OllamaService {
|
||||||
}
|
}
|
||||||
): Promise<{ models: NomadOllamaModel[], hasMore: boolean } | null> {
|
): Promise<{ models: NomadOllamaModel[], hasMore: boolean } | null> {
|
||||||
try {
|
try {
|
||||||
const models = await this.retrieveAndRefreshModels(sort)
|
const models = await this.retrieveAndRefreshModels(sort, force)
|
||||||
if (!models) {
|
if (!models) {
|
||||||
// If we fail to get models from the API, return the fallback recommended models
|
// If we fail to get models from the API, return the fallback recommended models
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -244,13 +244,18 @@ export class OllamaService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async retrieveAndRefreshModels(
|
private async retrieveAndRefreshModels(
|
||||||
sort?: 'pulls' | 'name'
|
sort?: 'pulls' | 'name',
|
||||||
|
force?: boolean
|
||||||
): Promise<NomadOllamaModel[] | null> {
|
): Promise<NomadOllamaModel[] | null> {
|
||||||
try {
|
try {
|
||||||
const cachedModels = await this.readModelsFromCache()
|
if (!force) {
|
||||||
if (cachedModels) {
|
const cachedModels = await this.readModelsFromCache()
|
||||||
logger.info('[OllamaService] Using cached available models data')
|
if (cachedModels) {
|
||||||
return this.sortModels(cachedModels, sort)
|
logger.info('[OllamaService] Using cached available models data')
|
||||||
|
return this.sortModels(cachedModels, sort)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('[OllamaService] Force refresh requested, bypassing cache')
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('[OllamaService] Fetching fresh available models from API')
|
logger.info('[OllamaService] Fetching fresh available models from API')
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { DockerService } from '#services/docker_service'
|
||||||
import { ServiceSlim } from '../../types/services.js'
|
import { ServiceSlim } from '../../types/services.js'
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import si from 'systeminformation'
|
import si from 'systeminformation'
|
||||||
import { NomadDiskInfo, NomadDiskInfoRaw, SystemInformationResponse } from '../../types/system.js'
|
import { GpuHealthStatus, NomadDiskInfo, NomadDiskInfoRaw, SystemInformationResponse } from '../../types/system.js'
|
||||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import path, { join } from 'path'
|
import path, { join } from 'path'
|
||||||
|
|
@ -235,6 +235,13 @@ export class SystemService {
|
||||||
logger.error('Error reading disk info file:', error)
|
logger.error('Error reading disk info file:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GPU health tracking — detect when host has NVIDIA GPU but Ollama can't access it
|
||||||
|
let gpuHealth: GpuHealthStatus = {
|
||||||
|
status: 'no_gpu',
|
||||||
|
hasNvidiaRuntime: false,
|
||||||
|
ollamaGpuAccessible: false,
|
||||||
|
}
|
||||||
|
|
||||||
// Query Docker API for host-level info (hostname, OS, GPU runtime)
|
// Query Docker API for host-level info (hostname, OS, GPU runtime)
|
||||||
// si.osInfo() returns the container's info inside Docker, not the host's
|
// si.osInfo() returns the container's info inside Docker, not the host's
|
||||||
try {
|
try {
|
||||||
|
|
@ -255,6 +262,7 @@ export class SystemService {
|
||||||
if (!graphics.controllers || graphics.controllers.length === 0) {
|
if (!graphics.controllers || graphics.controllers.length === 0) {
|
||||||
const runtimes = dockerInfo.Runtimes || {}
|
const runtimes = dockerInfo.Runtimes || {}
|
||||||
if ('nvidia' in runtimes) {
|
if ('nvidia' in runtimes) {
|
||||||
|
gpuHealth.hasNvidiaRuntime = true
|
||||||
const nvidiaInfo = await this.getNvidiaSmiInfo()
|
const nvidiaInfo = await this.getNvidiaSmiInfo()
|
||||||
if (Array.isArray(nvidiaInfo)) {
|
if (Array.isArray(nvidiaInfo)) {
|
||||||
graphics.controllers = nvidiaInfo.map((gpu) => ({
|
graphics.controllers = nvidiaInfo.map((gpu) => ({
|
||||||
|
|
@ -264,10 +272,19 @@ export class SystemService {
|
||||||
vram: gpu.vram,
|
vram: gpu.vram,
|
||||||
vramDynamic: false, // assume false here, we don't actually use this field for our purposes.
|
vramDynamic: false, // assume false here, we don't actually use this field for our purposes.
|
||||||
}))
|
}))
|
||||||
|
gpuHealth.status = 'ok'
|
||||||
|
gpuHealth.ollamaGpuAccessible = true
|
||||||
|
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
|
||||||
|
gpuHealth.status = 'ollama_not_installed'
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`NVIDIA runtime detected but failed to get GPU info: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)
|
gpuHealth.status = 'passthrough_failed'
|
||||||
|
logger.warn(`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// si.graphics() returned controllers (host install, not Docker) — GPU is working
|
||||||
|
gpuHealth.status = 'ok'
|
||||||
|
gpuHealth.ollamaGpuAccessible = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Docker info query failed, skip host-level enrichment
|
// Docker info query failed, skip host-level enrichment
|
||||||
|
|
@ -282,6 +299,7 @@ export class SystemService {
|
||||||
fsSize,
|
fsSize,
|
||||||
uptime,
|
uptime,
|
||||||
graphics,
|
graphics,
|
||||||
|
gpuHealth,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting system info:', error)
|
logger.error('Error getting system info:', error)
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,6 @@ export const getAvailableModelsSchema = vine.compile(
|
||||||
recommendedOnly: vine.boolean().optional(),
|
recommendedOnly: vine.boolean().optional(),
|
||||||
query: vine.string().trim().optional(),
|
query: vine.string().trim().optional(),
|
||||||
limit: vine.number().positive().optional(),
|
limit: vine.number().positive().optional(),
|
||||||
|
force: vine.boolean().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
size = 'md',
|
size = 'md',
|
||||||
loading = false,
|
loading = false,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isDisabled = useMemo(() => {
|
const isDisabled = useMemo(() => {
|
||||||
|
|
@ -152,7 +153,8 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
getSizeClasses(),
|
getSizeClasses(),
|
||||||
getVariantClasses(),
|
getVariantClasses(),
|
||||||
isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer',
|
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'
|
'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',
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ class API {
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableModels(params: { query?: string; recommendedOnly?: boolean; limit?: number }) {
|
async getAvailableModels(params: { query?: string; recommendedOnly?: boolean; limit?: number; force?: boolean }) {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<{
|
const response = await this.client.get<{
|
||||||
models: NomadOllamaModel[]
|
models: NomadOllamaModel[]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Head, router, usePage } from '@inertiajs/react'
|
import { Head, router, usePage } from '@inertiajs/react'
|
||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import StyledTable from '~/components/StyledTable'
|
import StyledTable from '~/components/StyledTable'
|
||||||
import SettingsLayout from '~/layouts/SettingsLayout'
|
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||||
import { NomadOllamaModel } from '../../../types/ollama'
|
import { NomadOllamaModel } from '../../../types/ollama'
|
||||||
|
|
@ -16,9 +16,10 @@ import Switch from '~/components/inputs/Switch'
|
||||||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
import Input from '~/components/inputs/Input'
|
import Input from '~/components/inputs/Input'
|
||||||
import { IconSearch } from '@tabler/icons-react'
|
import { IconSearch, IconRefresh } from '@tabler/icons-react'
|
||||||
import useDebounce from '~/hooks/useDebounce'
|
import useDebounce from '~/hooks/useDebounce'
|
||||||
import ActiveModelDownloads from '~/components/ActiveModelDownloads'
|
import ActiveModelDownloads from '~/components/ActiveModelDownloads'
|
||||||
|
import { useSystemInfo } from '~/hooks/useSystemInfo'
|
||||||
|
|
||||||
export default function ModelsPage(props: {
|
export default function ModelsPage(props: {
|
||||||
models: {
|
models: {
|
||||||
|
|
@ -32,6 +33,64 @@ export default function ModelsPage(props: {
|
||||||
const { addNotification } = useNotifications()
|
const { addNotification } = useNotifications()
|
||||||
const { openModal, closeAllModals } = useModals()
|
const { openModal, closeAllModals } = useModals()
|
||||||
const { debounce } = useDebounce()
|
const { debounce } = useDebounce()
|
||||||
|
const { data: systemInfo } = useSystemInfo({})
|
||||||
|
|
||||||
|
const [gpuBannerDismissed, setGpuBannerDismissed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('nomad:gpu-banner-dismissed') === 'true'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [reinstalling, setReinstalling] = useState(false)
|
||||||
|
|
||||||
|
const handleDismissGpuBanner = () => {
|
||||||
|
setGpuBannerDismissed(true)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('nomad:gpu-banner-dismissed', 'true')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleForceReinstallOllama = () => {
|
||||||
|
openModal(
|
||||||
|
<StyledModal
|
||||||
|
title="Reinstall AI Assistant?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
closeAllModals()
|
||||||
|
setReinstalling(true)
|
||||||
|
try {
|
||||||
|
const response = await api.forceReinstallService('nomad_ollama')
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || 'Force reinstall failed')
|
||||||
|
}
|
||||||
|
addNotification({
|
||||||
|
message: `${aiAssistantName} is being reinstalled with GPU support. This page will reload shortly.`,
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
try { localStorage.removeItem('nomad:gpu-banner-dismissed') } catch {}
|
||||||
|
setTimeout(() => window.location.reload(), 5000)
|
||||||
|
} catch (error) {
|
||||||
|
addNotification({
|
||||||
|
message: `Failed to reinstall: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
setReinstalling(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={closeAllModals}
|
||||||
|
open={true}
|
||||||
|
confirmText="Reinstall"
|
||||||
|
cancelText="Cancel"
|
||||||
|
>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
This will recreate the {aiAssistantName} container with GPU support enabled.
|
||||||
|
Your downloaded models will be preserved. The service will be briefly
|
||||||
|
unavailable during reinstall.
|
||||||
|
</p>
|
||||||
|
</StyledModal>,
|
||||||
|
'gpu-health-force-reinstall-modal'
|
||||||
|
)
|
||||||
|
}
|
||||||
const [chatSuggestionsEnabled, setChatSuggestionsEnabled] = useState(
|
const [chatSuggestionsEnabled, setChatSuggestionsEnabled] = useState(
|
||||||
props.models.settings.chatSuggestionsEnabled
|
props.models.settings.chatSuggestionsEnabled
|
||||||
)
|
)
|
||||||
|
|
@ -47,13 +106,19 @@ export default function ModelsPage(props: {
|
||||||
setQuery(val)
|
setQuery(val)
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
const { data: availableModelData, isFetching } = useQuery({
|
const forceRefreshRef = useRef(false)
|
||||||
|
const [isForceRefreshing, setIsForceRefreshing] = useState(false)
|
||||||
|
|
||||||
|
const { data: availableModelData, isFetching, refetch } = useQuery({
|
||||||
queryKey: ['ollama', 'availableModels', query, limit],
|
queryKey: ['ollama', 'availableModels', query, limit],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
const force = forceRefreshRef.current
|
||||||
|
forceRefreshRef.current = false
|
||||||
const res = await api.getAvailableModels({
|
const res = await api.getAvailableModels({
|
||||||
query,
|
query,
|
||||||
recommendedOnly: false,
|
recommendedOnly: false,
|
||||||
limit,
|
limit,
|
||||||
|
force: force || undefined,
|
||||||
})
|
})
|
||||||
if (!res) {
|
if (!res) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -66,6 +131,14 @@ export default function ModelsPage(props: {
|
||||||
initialData: { models: props.models.availableModels, hasMore: false },
|
initialData: { models: props.models.availableModels, hasMore: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function handleForceRefresh() {
|
||||||
|
forceRefreshRef.current = true
|
||||||
|
setIsForceRefreshing(true)
|
||||||
|
await refetch()
|
||||||
|
setIsForceRefreshing(false)
|
||||||
|
addNotification({ message: 'Model list refreshed from remote.', type: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
async function handleInstallModel(modelName: string) {
|
async function handleInstallModel(modelName: string) {
|
||||||
try {
|
try {
|
||||||
const res = await api.downloadModel(modelName)
|
const res = await api.downloadModel(modelName)
|
||||||
|
|
@ -164,6 +237,26 @@ export default function ModelsPage(props: {
|
||||||
className="!mt-6"
|
className="!mt-6"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isInstalled && systemInfo?.gpuHealth?.status === 'passthrough_failed' && !gpuBannerDismissed && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
variant="bordered"
|
||||||
|
title="GPU Not Accessible"
|
||||||
|
message={`Your system has an NVIDIA GPU, but ${aiAssistantName} can't access it. AI is running on CPU only, which is significantly slower.`}
|
||||||
|
className="!mt-6"
|
||||||
|
dismissible={true}
|
||||||
|
onDismiss={handleDismissGpuBanner}
|
||||||
|
buttonProps={{
|
||||||
|
children: `Fix: Reinstall ${aiAssistantName}`,
|
||||||
|
icon: 'IconRefresh',
|
||||||
|
variant: 'action',
|
||||||
|
size: 'sm',
|
||||||
|
onClick: handleForceReinstallOllama,
|
||||||
|
loading: reinstalling,
|
||||||
|
disabled: reinstalling,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<StyledSectionHeader title="Settings" className="mt-8 mb-4" />
|
<StyledSectionHeader title="Settings" className="mt-8 mb-4" />
|
||||||
<div className="bg-white rounded-lg border-2 border-gray-200 p-6">
|
<div className="bg-white rounded-lg border-2 border-gray-200 p-6">
|
||||||
|
|
@ -196,7 +289,7 @@ export default function ModelsPage(props: {
|
||||||
<ActiveModelDownloads withHeader />
|
<ActiveModelDownloads withHeader />
|
||||||
|
|
||||||
<StyledSectionHeader title="Models" className="mt-12 mb-4" />
|
<StyledSectionHeader title="Models" className="mt-12 mb-4" />
|
||||||
<div className="flex justify-start mt-4">
|
<div className="flex justify-start items-center gap-3 mt-4">
|
||||||
<Input
|
<Input
|
||||||
name="search"
|
name="search"
|
||||||
label=""
|
label=""
|
||||||
|
|
@ -209,6 +302,15 @@ export default function ModelsPage(props: {
|
||||||
className="w-1/3"
|
className="w-1/3"
|
||||||
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />}
|
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />}
|
||||||
/>
|
/>
|
||||||
|
<StyledButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleForceRefresh}
|
||||||
|
icon="IconRefresh"
|
||||||
|
loading={isForceRefreshing}
|
||||||
|
className='mt-1'
|
||||||
|
>
|
||||||
|
Refresh Models
|
||||||
|
</StyledButton>
|
||||||
</div>
|
</div>
|
||||||
<StyledTable<NomadOllamaModel>
|
<StyledTable<NomadOllamaModel>
|
||||||
className="font-semibold mt-4"
|
className="font-semibold mt-4"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import { Head } from '@inertiajs/react'
|
import { Head } from '@inertiajs/react'
|
||||||
import SettingsLayout from '~/layouts/SettingsLayout'
|
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||||
import { SystemInformationResponse } from '../../../types/system'
|
import { SystemInformationResponse } from '../../../types/system'
|
||||||
|
|
@ -6,7 +7,11 @@ import CircularGauge from '~/components/systeminfo/CircularGauge'
|
||||||
import HorizontalBarChart from '~/components/HorizontalBarChart'
|
import HorizontalBarChart from '~/components/HorizontalBarChart'
|
||||||
import InfoCard from '~/components/systeminfo/InfoCard'
|
import InfoCard from '~/components/systeminfo/InfoCard'
|
||||||
import Alert from '~/components/Alert'
|
import Alert from '~/components/Alert'
|
||||||
|
import StyledModal from '~/components/StyledModal'
|
||||||
import { useSystemInfo } from '~/hooks/useSystemInfo'
|
import { useSystemInfo } from '~/hooks/useSystemInfo'
|
||||||
|
import { useNotifications } from '~/context/NotificationContext'
|
||||||
|
import { useModals } from '~/context/ModalContext'
|
||||||
|
import api from '~/lib/api'
|
||||||
import StatusCard from '~/components/systeminfo/StatusCard'
|
import StatusCard from '~/components/systeminfo/StatusCard'
|
||||||
import { IconCpu, IconDatabase, IconServer, IconDeviceDesktop, IconComponents } from '@tabler/icons-react'
|
import { IconCpu, IconDatabase, IconServer, IconDeviceDesktop, IconComponents } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
|
@ -16,6 +21,65 @@ export default function SettingsPage(props: {
|
||||||
const { data: info } = useSystemInfo({
|
const { data: info } = useSystemInfo({
|
||||||
initialData: props.system.info,
|
initialData: props.system.info,
|
||||||
})
|
})
|
||||||
|
const { addNotification } = useNotifications()
|
||||||
|
const { openModal, closeAllModals } = useModals()
|
||||||
|
|
||||||
|
const [gpuBannerDismissed, setGpuBannerDismissed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('nomad:gpu-banner-dismissed') === 'true'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [reinstalling, setReinstalling] = useState(false)
|
||||||
|
|
||||||
|
const handleDismissGpuBanner = () => {
|
||||||
|
setGpuBannerDismissed(true)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('nomad:gpu-banner-dismissed', 'true')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleForceReinstallOllama = () => {
|
||||||
|
openModal(
|
||||||
|
<StyledModal
|
||||||
|
title="Reinstall AI Assistant?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
closeAllModals()
|
||||||
|
setReinstalling(true)
|
||||||
|
try {
|
||||||
|
const response = await api.forceReinstallService('nomad_ollama')
|
||||||
|
if (!response || !response.success) {
|
||||||
|
throw new Error(response?.message || 'Force reinstall failed')
|
||||||
|
}
|
||||||
|
addNotification({
|
||||||
|
message: 'AI Assistant is being reinstalled with GPU support. This page will reload shortly.',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
try { localStorage.removeItem('nomad:gpu-banner-dismissed') } catch {}
|
||||||
|
setTimeout(() => window.location.reload(), 5000)
|
||||||
|
} catch (error) {
|
||||||
|
addNotification({
|
||||||
|
message: `Failed to reinstall: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
setReinstalling(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={closeAllModals}
|
||||||
|
open={true}
|
||||||
|
confirmText="Reinstall"
|
||||||
|
cancelText="Cancel"
|
||||||
|
>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
This will recreate the AI Assistant container with GPU support enabled.
|
||||||
|
Your downloaded models will be preserved. The service will be briefly
|
||||||
|
unavailable during reinstall.
|
||||||
|
</p>
|
||||||
|
</StyledModal>,
|
||||||
|
'gpu-health-force-reinstall-modal'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Use (total - available) to reflect actual memory pressure.
|
// Use (total - available) to reflect actual memory pressure.
|
||||||
// mem.used includes reclaimable buff/cache on Linux, which inflates the number.
|
// mem.used includes reclaimable buff/cache on Linux, which inflates the number.
|
||||||
|
|
@ -173,6 +237,27 @@ export default function SettingsPage(props: {
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{info?.gpuHealth?.status === 'passthrough_failed' && !gpuBannerDismissed && (
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
variant="bordered"
|
||||||
|
title="GPU Not Accessible to AI Assistant"
|
||||||
|
message="Your system has an NVIDIA GPU, but the AI Assistant can't access it. AI is running on CPU only, which is significantly slower."
|
||||||
|
dismissible={true}
|
||||||
|
onDismiss={handleDismissGpuBanner}
|
||||||
|
buttonProps={{
|
||||||
|
children: 'Fix: Reinstall AI Assistant',
|
||||||
|
icon: 'IconRefresh',
|
||||||
|
variant: 'action',
|
||||||
|
size: 'sm',
|
||||||
|
onClick: handleForceReinstallOllama,
|
||||||
|
loading: reinstalling,
|
||||||
|
disabled: reinstalling,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{info?.graphics?.controllers && info.graphics.controllers.length > 0 && (
|
{info?.graphics?.controllers && info.graphics.controllers.length > 0 && (
|
||||||
<InfoCard
|
<InfoCard
|
||||||
title="Graphics"
|
title="Graphics"
|
||||||
|
|
|
||||||
18
admin/package-lock.json
generated
18
admin/package-lock.json
generated
|
|
@ -66,7 +66,7 @@
|
||||||
"stopword": "^3.1.5",
|
"stopword": "^3.1.5",
|
||||||
"systeminformation": "^5.30.8",
|
"systeminformation": "^5.30.8",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"tar": "^7.5.9",
|
"tar": "^7.5.10",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
"url-join": "^5.0.0",
|
"url-join": "^5.0.0",
|
||||||
"yaml": "^2.8.0"
|
"yaml": "^2.8.0"
|
||||||
|
|
@ -4379,7 +4379,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4396,7 +4395,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4413,7 +4411,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4430,7 +4427,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4447,7 +4443,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4464,7 +4459,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4481,7 +4475,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4498,7 +4491,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4515,7 +4507,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4532,7 +4523,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -15275,9 +15265,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.9",
|
"version": "7.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz",
|
||||||
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
"integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/fs-minipass": "^4.0.0",
|
"@isaacs/fs-minipass": "^4.0.0",
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@
|
||||||
"stopword": "^3.1.5",
|
"stopword": "^3.1.5",
|
||||||
"systeminformation": "^5.30.8",
|
"systeminformation": "^5.30.8",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"tar": "^7.5.9",
|
"tar": "^7.5.10",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
"url-join": "^5.0.0",
|
"url-join": "^5.0.0",
|
||||||
"yaml": "^2.8.0"
|
"yaml": "^2.8.0"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { Systeminformation } from 'systeminformation'
|
import { Systeminformation } from 'systeminformation'
|
||||||
|
|
||||||
|
export type GpuHealthStatus = {
|
||||||
|
status: 'ok' | 'passthrough_failed' | 'no_gpu' | 'ollama_not_installed'
|
||||||
|
hasNvidiaRuntime: boolean
|
||||||
|
ollamaGpuAccessible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type SystemInformationResponse = {
|
export type SystemInformationResponse = {
|
||||||
cpu: Systeminformation.CpuData
|
cpu: Systeminformation.CpuData
|
||||||
mem: Systeminformation.MemData
|
mem: Systeminformation.MemData
|
||||||
|
|
@ -9,6 +15,7 @@ export type SystemInformationResponse = {
|
||||||
fsSize: Systeminformation.FsSizeData[]
|
fsSize: Systeminformation.FsSizeData[]
|
||||||
uptime: Systeminformation.TimeData
|
uptime: Systeminformation.TimeData
|
||||||
graphics: Systeminformation.GraphicsData
|
graphics: Systeminformation.GraphicsData
|
||||||
|
gpuHealth?: GpuHealthStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type inferrence is not working properly with usePage and shared props, so we define this type manually
|
// Type inferrence is not working properly with usePage and shared props, so we define this type manually
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "project-nomad",
|
"name": "project-nomad",
|
||||||
"version": "1.28.0",
|
"version": "1.29.0-rc.1",
|
||||||
"description": "\"",
|
"description": "\"",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user