mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-01 05:36:49 -04:00
* feat: Rename 'Whisper' model type to 'STT' in UI - Updated models.html: Changed 'Whisper' filter button to 'STT' - Updated talk.html: Changed 'Whisper Model' to 'STT Model' - Updated backends.html: Changed 'Whisper' to 'STT' - Updated talk.js: Renamed getWhisperModel() to getSTTModel(), sendAudioToWhisper() to sendAudioToSTT(), and whisperModelSelect to sttModelSelect This change makes the UI more consistent with the model category naming, where all speech-to-text models (including Whisper, Parakeet, Moonshine, WhisperX, etc.) are grouped under the 'STT' (Speech-to-Text) category. Fixes #8776 Signed-off-by: team-coding-agent-1 <team-coding-agent-1@localai.dev> * Rename whisperModelSelect to sttModelSelect in talk.html As requested by maintainer mudler in PR review, replacing all whisperModelSelect occurrences with sttModelSelect since the model type was renamed from Whisper to STT. Signed-off-by: LocalAI [bot] <localai-bot@users.noreply.github.com> --------- Signed-off-by: team-coding-agent-1 <team-coding-agent-1@localai.dev> Signed-off-by: LocalAI [bot] <localai-bot@users.noreply.github.com> Co-authored-by: team-coding-agent-1 <team-coding-agent-1@localai.dev> Co-authored-by: LocalAI [bot] <localai-bot@users.noreply.github.com>
900 lines
53 KiB
HTML
900 lines
53 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
{{template "views/partials/head" .}}
|
|
|
|
<body class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">
|
|
<div class="app-layout">
|
|
{{template "views/partials/navbar" .}}
|
|
|
|
<main class="main-content">
|
|
<div class="main-content-inner" x-data="modelsGallery()">
|
|
|
|
<!-- Notifications -->
|
|
<div class="fixed top-20 right-4 z-50 space-y-2" style="max-width: 400px;">
|
|
<template x-for="notification in notifications" :key="notification.id">
|
|
<div x-show="true"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
:class="notification.type === 'error' ? 'bg-[var(--color-error)]' : 'bg-[var(--color-success)]'"
|
|
class="rounded-lg p-4 text-white flex items-start space-x-3">
|
|
<div class="flex-shrink-0">
|
|
<i :class="notification.type === 'error' ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" class="text-xl"></i>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium break-words" x-text="notification.message"></p>
|
|
</div>
|
|
<button @click="dismissNotification(notification.id)" class="flex-shrink-0 text-white hover:opacity-80 transition-opacity">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="container mx-auto px-4 py-8 flex-grow">
|
|
|
|
<!-- Hero Header -->
|
|
<div class="hero-section">
|
|
<div class="hero-content">
|
|
<h1 class="hero-title">
|
|
Model Gallery
|
|
</h1>
|
|
<p class="hero-subtitle">
|
|
Discover and install AI models from our curated collection
|
|
</p>
|
|
<div class="flex flex-wrap justify-center items-center gap-6 text-sm md:text-base">
|
|
<div class="flex items-center bg-[var(--color-bg-primary)] rounded-lg px-4 py-2">
|
|
<div class="w-2 h-2 bg-[var(--color-primary)] rounded-full mr-2"></div>
|
|
<span class="font-semibold text-indigo-300" x-text="availableModels"></span>
|
|
<span class="text-[var(--color-text-secondary)] ml-1">models available</span>
|
|
</div>
|
|
<a href="/manage" class="flex items-center bg-[var(--color-bg-primary)] hover:bg-[var(--color-bg-secondary)] rounded-lg px-4 py-2 transition-colors border border-[var(--color-primary-border)]/30 hover:border-[var(--color-primary-border)]/50">
|
|
<div class="w-2 h-2 bg-[var(--color-success)] rounded-full mr-2"></div>
|
|
<span class="font-semibold text-[var(--color-success)]" x-text="installedModels"></span>
|
|
<span class="text-[var(--color-text-secondary)] ml-1">installed</span>
|
|
</a>
|
|
<div class="flex items-center bg-[var(--color-bg-primary)] rounded-lg px-4 py-2">
|
|
<div class="w-2 h-2 bg-[var(--color-accent)] rounded-full mr-2"></div>
|
|
<span class="font-semibold text-purple-300" x-text="repositories.length"></span>
|
|
<span class="text-[var(--color-text-secondary)] ml-1">repositories</span>
|
|
</div>
|
|
<a href="/import-model" class="inline-flex items-center gap-1.5 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] bg-transparent hover:bg-[var(--color-primary)]/10 border border-[var(--color-border-subtle)] hover:border-[var(--color-primary)]/30 rounded-md py-1.5 px-2.5 transition-colors">
|
|
<i class="fas fa-upload"></i>
|
|
<span>Import Model</span>
|
|
</a>
|
|
<a href="https://localai.io/models/" target="_blank" class="inline-flex items-center gap-1.5 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] bg-transparent hover:bg-[var(--color-primary)]/10 border border-[var(--color-border-subtle)] hover:border-[var(--color-primary)]/30 rounded-md py-1.5 px-2.5 transition-colors">
|
|
<i class="fas fa-info-circle"></i>
|
|
<span>Documentation</span>
|
|
<i class="fas fa-external-link-alt text-[10px]"></i>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{template "views/partials/inprogress" .}}
|
|
|
|
<!-- Search and Filter Section -->
|
|
<div class="card p-8 mb-8">
|
|
<div>
|
|
<!-- Search Input -->
|
|
<div class="mb-8">
|
|
<h3 class="text-xl font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
|
|
<i class="fas fa-search mr-3 text-[var(--color-primary)]"></i>
|
|
Find Your Perfect Model
|
|
</h3>
|
|
<div class="relative">
|
|
<div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none z-10">
|
|
<i class="fas fa-search text-[var(--color-text-secondary)]"></i>
|
|
</div>
|
|
<input
|
|
x-model="searchTerm"
|
|
@input.debounce.500ms="fetchModels()"
|
|
class="input w-full pr-16 py-4"
|
|
style="padding-left: 3.5rem !important;"
|
|
type="search"
|
|
placeholder="Search models by name, tag, or description...">
|
|
<span class="absolute right-4 top-4" x-show="loading">
|
|
<svg class="animate-spin h-6 w-6 text-[var(--color-primary)]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter by Type -->
|
|
<div class="mb-8">
|
|
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
|
|
<i class="fas fa-filter mr-3 text-[var(--color-accent)]"></i>
|
|
Filter by Model Type
|
|
</h3>
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8 gap-3">
|
|
<button @click="filterByTerm('tts')"
|
|
class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-[var(--color-bg-secondary)] hover:bg-[var(--color-primary-light)] text-[var(--color-text-primary)] border border-[var(--color-border-subtle)] hover:border-[var(--color-primary-border)] transition-colors">
|
|
<i class="fas fa-microphone mr-2"></i>
|
|
<span>TTS</span>
|
|
</button>
|
|
<button @click="filterByTerm('stablediffusion')"
|
|
class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-[var(--color-bg-secondary)] hover:bg-[var(--color-accent-light)] text-[var(--color-text-primary)] border border-[var(--color-border-subtle)] hover:border-[var(--color-accent-border)] transition-colors">
|
|
<i class="fas fa-image mr-2"></i>
|
|
<span>Image</span>
|
|
</button>
|
|
<button @click="filterByTerm('llm')"
|
|
class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-[var(--color-bg-secondary)] hover:bg-[var(--color-primary-light)] text-[var(--color-text-primary)] border border-[var(--color-border-subtle)] hover:border-[var(--color-primary-border)] transition-colors">
|
|
<i class="fas fa-comment-alt mr-2"></i>
|
|
<span>LLM</span>
|
|
</button>
|
|
<button @click="filterByTerm('multimodal')"
|
|
class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-[var(--color-bg-secondary)] hover:bg-[var(--color-secondary-light)] text-[var(--color-text-primary)] border border-[var(--color-border-subtle)] hover:border-[var(--color-secondary-light)] transition-colors">
|
|
<i class="fas fa-object-group mr-2"></i>
|
|
<span>Multimodal</span>
|
|
</button>
|
|
<button @click="filterByTerm('embedding')"
|
|
class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-[var(--color-bg-secondary)] hover:bg-[var(--color-primary-light)] text-[var(--color-text-primary)] border border-[var(--color-border-subtle)] hover:border-[var(--color-primary-border)] transition-colors">
|
|
<i class="fas fa-vector-square mr-2"></i>
|
|
<span>Embedding</span>
|
|
</button>
|
|
<button @click="filterByTerm('rerank')"
|
|
class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-[var(--color-bg-secondary)] hover:bg-[var(--color-warning-light)] text-[var(--color-text-primary)] border border-[var(--color-border-subtle)] hover:border-[var(--color-warning-light)] transition-colors">
|
|
<i class="fas fa-sort-amount-up mr-2"></i>
|
|
<span>Rerank</span>
|
|
</button>
|
|
<button @click="filterByTerm('stt')"
|
|
class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-[var(--color-bg-secondary)] hover:bg-[var(--color-secondary-light)] text-[var(--color-text-primary)] border border-[var(--color-border-subtle)] hover:border-[var(--color-secondary-light)] transition-colors">
|
|
<i class="fas fa-headphones mr-2"></i>
|
|
<span>STT</span>
|
|
</button>
|
|
<button @click="filterByTerm('object-detection')"
|
|
class="flex items-center justify-center rounded-lg px-4 py-3 text-sm font-semibold bg-red-600/20 hover:bg-red-600/30 text-red-300 border border-red-500/30 transition-colors">
|
|
<i class="fas fa-eye mr-2"></i>
|
|
<span>Vision</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter by Tags -->
|
|
<div x-show="allTags.length > 0">
|
|
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
|
|
<i class="fas fa-tags mr-3 text-[var(--color-accent)]"></i>
|
|
Browse by Tags
|
|
</h3>
|
|
<div class="max-h-32 overflow-y-auto pr-2">
|
|
<div class="flex flex-wrap gap-2">
|
|
<template x-for="tag in allTags" :key="tag">
|
|
<button @click="filterByTerm(tag)"
|
|
class="inline-flex items-center text-xs px-3 py-2 rounded bg-[var(--color-bg-primary)] hover:bg-[var(--color-bg-primary)]/80 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] border border-[var(--color-bg-secondary)] transition-colors">
|
|
<i class="fas fa-tag text-xs mr-2"></i>
|
|
<span x-text="tag"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Results Section -->
|
|
<div id="search-results" class="transition-all duration-300 relative">
|
|
<div x-show="loading && models.length === 0" class="text-center py-12">
|
|
<svg class="animate-spin h-12 w-12 text-[var(--color-primary)] mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
<p class="text-[var(--color-text-secondary)]">Loading models...</p>
|
|
</div>
|
|
|
|
<div x-show="!loading && models.length === 0" class="text-center py-12">
|
|
<i class="fas fa-search text-[var(--color-text-muted)] text-4xl mb-4"></i>
|
|
<p class="text-[var(--color-text-secondary)]">No models found matching your criteria</p>
|
|
</div>
|
|
|
|
<!-- Loading overlay when switching pages (we have models but loading) -->
|
|
<div x-show="loading && models.length > 0"
|
|
x-transition:enter="transition ease-out duration-150"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
class="absolute inset-0 z-10 flex items-center justify-center rounded-2xl bg-[var(--color-bg-secondary)]/80 backdrop-blur-sm">
|
|
<div class="flex flex-col items-center gap-3">
|
|
<svg class="animate-spin h-12 w-12 text-[var(--color-primary)]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
<p class="text-sm text-[var(--color-text-secondary)]">Loading page...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table View -->
|
|
<div x-show="models.length > 0" class="bg-[var(--color-bg-secondary)] rounded-2xl border border-[var(--color-border-subtle)] overflow-hidden shadow-xl backdrop-blur-sm">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="bg-[var(--color-primary-light)] border-b border-[var(--color-border-subtle)]">
|
|
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Icon</th>
|
|
<th @click="setSort('name')"
|
|
:class="sortBy === 'name' ? 'bg-[var(--color-primary-light)]' : ''"
|
|
class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider cursor-pointer hover:bg-[var(--color-bg-primary)] transition-colors">
|
|
<div class="flex items-center gap-2">
|
|
<span>Model Name</span>
|
|
<i :class="sortBy === 'name' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
|
:class="sortBy === 'name' ? 'text-[var(--color-primary)]' : 'text-[var(--color-text-secondary)]'"
|
|
class="text-xs"></i>
|
|
</div>
|
|
</th>
|
|
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Description</th>
|
|
<th class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Size / VRAM</th>
|
|
<th @click="setSort('status')"
|
|
:class="sortBy === 'status' ? 'bg-[var(--color-primary-light)]' : ''"
|
|
class="px-6 py-4 text-left text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider cursor-pointer hover:bg-[var(--color-bg-primary)] transition-colors">
|
|
<div class="flex items-center gap-2">
|
|
<span>Status</span>
|
|
<i :class="sortBy === 'status' ? (sortOrder === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down') : 'fas fa-sort'"
|
|
:class="sortBy === 'status' ? 'text-[var(--color-primary)]' : 'text-[var(--color-text-secondary)]'"
|
|
class="text-xs"></i>
|
|
</div>
|
|
</th>
|
|
<th class="px-6 py-4 text-right text-xs font-semibold text-[var(--color-text-primary)] uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-[var(--color-border-subtle)]">
|
|
<template x-for="model in models" :key="model.id">
|
|
<tr class="hover:bg-[var(--color-bg-primary)] transition-colors duration-200">
|
|
<!-- Icon -->
|
|
<td class="px-6 py-4">
|
|
<div class="w-12 h-12 rounded-lg border border-[var(--color-border-subtle)] flex items-center justify-center bg-[var(--color-bg-primary)]">
|
|
<img x-show="model.icon"
|
|
:src="model.icon"
|
|
class="w-full h-full object-cover rounded-lg"
|
|
loading="lazy"
|
|
:alt="model.name">
|
|
<i x-show="!model.icon" class="fas fa-brain text-xl text-[var(--color-accent)]"></i>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Model Name -->
|
|
<td class="px-6 py-4">
|
|
<div class="flex flex-col">
|
|
<span class="text-sm font-semibold text-[var(--color-text-primary)]" x-text="model.name"></span>
|
|
<div x-show="model.trustRemoteCode" class="mt-1">
|
|
<span class="inline-flex items-center text-xs px-2 py-1 rounded bg-red-500/20 text-red-300 border border-red-500/30">
|
|
<i class="fa-solid fa-circle-exclamation mr-1"></i>
|
|
Trust Remote Code
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Description -->
|
|
<td class="px-6 py-4">
|
|
<div class="text-sm text-[var(--color-text-secondary)] max-w-xs truncate" x-text="model.description" :title="model.description"></div>
|
|
</td>
|
|
|
|
<!-- Size / VRAM -->
|
|
<td class="px-6 py-4">
|
|
<div class="flex flex-col gap-0.5">
|
|
<template x-if="(model.estimated_size_display && model.estimated_size_display !== '0 B') || (model.estimated_vram_display && model.estimated_vram_display !== '0 B')">
|
|
<div class="text-xs text-[var(--color-text-secondary)]">
|
|
<span x-show="model.estimated_size_display && model.estimated_size_display !== '0 B'" x-text="'Size: ' + model.estimated_size_display"></span>
|
|
<span x-show="(model.estimated_size_display && model.estimated_size_display !== '0 B') && (model.estimated_vram_display && model.estimated_vram_display !== '0 B')"> · </span>
|
|
<span x-show="model.estimated_vram_display && model.estimated_vram_display !== '0 B'" x-text="'VRAM: ' + model.estimated_vram_display"></span>
|
|
</div>
|
|
</template>
|
|
<template x-if="model.estimated_vram_bytes && totalMemory > 0">
|
|
<span :title="(model.estimated_vram_bytes <= totalMemory * 0.95 ? 'Fits your GPU' : 'May not fit your GPU')"
|
|
class="inline-flex items-center text-xs">
|
|
<i class="fas fa-microchip mr-1"
|
|
:class="model.estimated_vram_bytes <= totalMemory * 0.95 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'"></i>
|
|
<span x-text="model.estimated_vram_bytes <= totalMemory * 0.95 ? 'Fits' : 'May not fit'"></span>
|
|
</span>
|
|
</template>
|
|
<span x-show="(!model.estimated_size_display || model.estimated_size_display === '0 B') && (!model.estimated_vram_display || model.estimated_vram_display === '0 B')" class="text-xs text-[var(--color-text-muted)]">-</span>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Status -->
|
|
<td class="px-6 py-4">
|
|
<!-- Processing State -->
|
|
<div x-show="model.processing" class="min-w-[200px]">
|
|
<div class="text-xs font-medium text-[var(--color-text-primary)] mb-1">
|
|
<span x-text="model.isDeletion ? 'Deleting...' : 'Installing...'"></span>
|
|
</div>
|
|
<div x-show="(jobProgress[model.jobID] || 0) === 0" class="text-xs text-[var(--color-primary)]">
|
|
<i class="fas fa-clock mr-1"></i>Queued
|
|
</div>
|
|
<div class="progress-table mt-1">
|
|
<div class="progress-bar-table" :style="'width:' + (jobProgress[model.jobID] || 0) + '%'"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Installed State -->
|
|
<div x-show="!model.processing && model.installed">
|
|
<span class="inline-flex items-center text-xs px-2 py-1 rounded bg-[var(--color-success-light)] text-[var(--color-success)] border border-[var(--color-success)]/30">
|
|
<i class="fas fa-check-circle mr-1"></i>
|
|
Installed
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Not Installed State -->
|
|
<div x-show="!model.processing && !model.installed">
|
|
<span class="inline-flex items-center text-xs px-2 py-1 rounded bg-[var(--color-bg-primary)] text-[var(--color-text-secondary)] border border-[var(--color-border-subtle)]">
|
|
<i class="fas fa-circle mr-1"></i>
|
|
Not Installed
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Actions -->
|
|
<td class="px-6 py-4">
|
|
<div class="flex items-center justify-end gap-2">
|
|
<!-- Info Button -->
|
|
<button @click="openModal(model)"
|
|
class="inline-flex items-center px-3 py-1.5 rounded-lg bg-[var(--color-bg-primary)] hover:bg-[var(--color-primary-light)] text-xs font-medium text-[var(--color-text-primary)] transition duration-200 border border-[var(--color-border-subtle)]"
|
|
title="View details">
|
|
<i class="fas fa-info-circle"></i>
|
|
</button>
|
|
|
|
<!-- Installed State Actions -->
|
|
<template x-if="!model.processing && model.installed">
|
|
<div class="flex gap-2">
|
|
<button @click="reinstallModel(model.id)"
|
|
class="inline-flex items-center px-3 py-1.5 rounded-lg bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-xs font-medium text-white transition duration-200"
|
|
title="Reinstall">
|
|
<i class="fa-solid fa-arrow-rotate-right"></i>
|
|
</button>
|
|
<button @click="deleteModel(model.id)"
|
|
class="inline-flex items-center px-3 py-1.5 rounded-lg bg-[var(--color-error)] hover:bg-[var(--color-error)]/80 text-xs font-medium text-white transition duration-200"
|
|
title="Delete">
|
|
<i class="fa-solid fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Not Installed State Actions -->
|
|
<template x-if="!model.processing && !model.installed">
|
|
<div class="flex gap-2">
|
|
<button @click="getConfig(model.id)"
|
|
class="inline-flex items-center px-3 py-1.5 rounded-lg bg-[var(--color-accent-light)] hover:bg-[var(--color-accent)]/30 text-xs font-medium text-[var(--color-text-primary)] transition duration-200 border border-[var(--color-accent)]/30"
|
|
title="Get config">
|
|
<i class="fa-solid fa-file-code"></i>
|
|
</button>
|
|
<button @click="installModel(model.id)"
|
|
class="inline-flex items-center px-3 py-1.5 rounded-lg bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-xs font-medium text-white transition duration-200"
|
|
title="Install">
|
|
<i class="fa-solid fa-download"></i>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal -->
|
|
<div x-show="selectedModel"
|
|
x-transition
|
|
@click.away="closeModal()"
|
|
class="fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-full max-h-full bg-black/50"
|
|
style="display: none;">
|
|
<div class="relative p-4 w-full max-w-2xl h-[90vh] mx-auto mt-[5vh]">
|
|
<div class="relative bg-[var(--color-bg-secondary)] rounded-lg shadow h-full flex flex-col border border-[var(--color-border-subtle)]">
|
|
<!-- Modal Header -->
|
|
<div class="flex items-center justify-between p-4 md:p-5 border-b border-[var(--color-border-subtle)] rounded-t">
|
|
<h3 class="text-xl font-semibold text-[var(--color-text-primary)]" x-text="selectedModel?.name"></h3>
|
|
<button @click="closeModal()"
|
|
class="text-[var(--color-text-secondary)] bg-transparent hover:bg-[var(--color-bg-primary)] hover:text-[var(--color-text-primary)] rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center transition-colors">
|
|
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
|
</svg>
|
|
<span class="sr-only">Close modal</span>
|
|
</button>
|
|
</div>
|
|
<!-- Modal Body -->
|
|
<div class="p-4 md:p-5 space-y-4 overflow-y-auto flex-1 min-h-0">
|
|
<div class="flex justify-center items-center">
|
|
<div class="w-48 h-48 rounded-lg border border-[var(--color-border-subtle)] flex items-center justify-center bg-[var(--color-bg-primary)] mt-3">
|
|
<img x-show="selectedModel?.icon"
|
|
:src="selectedModel?.icon"
|
|
class="rounded-lg max-h-48 max-w-96 object-cover"
|
|
loading="lazy">
|
|
<i x-show="!selectedModel?.icon" class="fas fa-brain text-6xl text-[var(--color-text-muted)]"></i>
|
|
</div>
|
|
</div>
|
|
<div class="text-base leading-relaxed text-[var(--color-text-secondary)] break-words max-w-full markdown-content" x-html="renderMarkdown(selectedModel?.description)"></div>
|
|
<template x-if="(selectedModel?.estimated_size_display && selectedModel.estimated_size_display !== '0 B') || (selectedModel?.estimated_vram_display && selectedModel.estimated_vram_display !== '0 B')">
|
|
<div class="space-y-1">
|
|
<p x-show="selectedModel?.estimated_size_display && selectedModel.estimated_size_display !== '0 B'" class="text-sm text-[var(--color-text-secondary)]">
|
|
<i class="fas fa-download mr-2 text-[var(--color-primary)]"></i>
|
|
Estimated download size: <span x-text="selectedModel?.estimated_size_display" class="font-medium text-[var(--color-text-primary)]"></span>
|
|
</p>
|
|
<p x-show="selectedModel?.estimated_vram_display && selectedModel.estimated_vram_display !== '0 B'" class="text-sm text-[var(--color-text-secondary)]">
|
|
<i class="fas fa-memory mr-2 text-[var(--color-primary)]"></i>
|
|
Estimated VRAM: <span x-text="selectedModel?.estimated_vram_display" class="font-medium text-[var(--color-text-primary)]"></span>
|
|
</p>
|
|
<p x-show="selectedModel?.estimated_vram_bytes && totalMemory > 0" class="text-sm">
|
|
<i class="fas fa-microchip mr-2"
|
|
:class="selectedModel?.estimated_vram_bytes <= totalMemory * 0.95 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'"></i>
|
|
<span x-text="selectedModel?.estimated_vram_bytes <= totalMemory * 0.95 ? 'Fits your GPU' : 'May not fit your GPU'"
|
|
:class="selectedModel?.estimated_vram_bytes <= totalMemory * 0.95 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'"></span>
|
|
</p>
|
|
</div>
|
|
</template>
|
|
<template x-if="selectedModel?.gallery || selectedModel?.license">
|
|
<div class="space-y-1">
|
|
<p x-show="selectedModel?.gallery" class="text-sm text-[var(--color-text-secondary)]">
|
|
<i class="fa-brands fa-git-alt mr-2 text-[var(--color-primary)]"></i>
|
|
Repository: <span x-text="selectedModel?.gallery" class="font-medium text-[var(--color-text-primary)]"></span>
|
|
</p>
|
|
<p x-show="selectedModel?.license" class="text-sm text-[var(--color-text-secondary)]">
|
|
<i class="fas fa-book mr-2 text-[var(--color-primary)]"></i>
|
|
License: <span x-text="selectedModel?.license" class="font-medium text-[var(--color-text-primary)]"></span>
|
|
</p>
|
|
</div>
|
|
</template>
|
|
<hr>
|
|
<template x-if="selectedModel?.urls && selectedModel.urls.length > 0">
|
|
<div>
|
|
<p class="text-sm font-semibold text-[var(--color-text-primary)] mb-2">Links</p>
|
|
<ul>
|
|
<template x-for="url in selectedModel.urls" :key="url">
|
|
<li>
|
|
<a :href="url" target="_blank" class="text-base leading-relaxed text-[var(--color-text-secondary)] hover:text-[var(--color-primary)]">
|
|
<i class="fas fa-link pr-2"></i>
|
|
<span x-text="url"></span>
|
|
</a>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
<template x-if="selectedModel?.additionalFiles && selectedModel.additionalFiles.length > 0">
|
|
<div>
|
|
<p class="text-sm font-semibold text-[var(--color-text-primary)] mb-2">Files</p>
|
|
<ul>
|
|
<template x-for="file in selectedModel.additionalFiles" :key="file">
|
|
<li class="mb-0">
|
|
<p class="text-base leading-tight text-[var(--color-text-secondary)]">
|
|
<i class="fas fa-file pr-2"></i>
|
|
<span x-text="file.filename"></span>
|
|
</p>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
<template x-if="selectedModel?.tags && selectedModel.tags.length > 0">
|
|
<div>
|
|
<p class="text-sm mb-3 font-semibold text-[var(--color-text-primary)]">Tags</p>
|
|
<div class="flex flex-row flex-wrap content-center">
|
|
<template x-for="tag in selectedModel.tags" :key="tag">
|
|
<a :href="'browse?term=' + tag"
|
|
class="inline-flex items-center text-xs px-3 py-1 rounded-full bg-[var(--color-bg-primary)] text-[var(--color-text-secondary)] border border-[var(--color-border-subtle)] hover:bg-[var(--color-primary-light)] hover:text-[var(--color-text-primary)] transition duration-200 ease-in-out mr-2 mb-2">
|
|
<i class="fas fa-tag pr-2"></i>
|
|
<span x-text="tag"></span>
|
|
</a>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<!-- Modal Footer -->
|
|
<div class="flex items-center p-4 md:p-5 border-t border-[var(--color-border-subtle)] rounded-b">
|
|
<button type="button" @click="closeModal()"
|
|
class="inline-flex items-center gap-1.5 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] bg-transparent hover:bg-[var(--color-primary)]/10 border border-[var(--color-border-subtle)] hover:border-[var(--color-primary)]/30 rounded-md py-1.5 px-2.5 transition-colors">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div x-show="totalPages > 1" class="flex justify-center mt-12">
|
|
<div class="flex items-center gap-4 bg-[var(--color-bg-secondary)] rounded-2xl p-4 backdrop-blur-sm border border-[var(--color-border-subtle)]">
|
|
<button @click="goToPage(currentPage - 1)"
|
|
:disabled="currentPage <= 1"
|
|
:class="currentPage <= 1 ? 'opacity-50 cursor-not-allowed' : ''"
|
|
class="flex items-center justify-center h-12 w-12 bg-[var(--color-bg-primary)] hover:bg-[var(--color-primary)] text-[var(--color-text-secondary)] hover:text-white rounded-lg transition-colors">
|
|
<i class="fas fa-chevron-left"></i>
|
|
</button>
|
|
<div class="text-[var(--color-text-primary)] text-sm font-medium px-4">
|
|
<span class="text-[var(--color-text-secondary)]">Page</span>
|
|
<span class="text-[var(--color-text-primary)] font-bold text-lg mx-2" x-text="currentPage"></span>
|
|
<span class="text-[var(--color-text-secondary)]">of</span>
|
|
<span class="text-[var(--color-text-primary)] font-bold text-lg mx-2" x-text="totalPages"></span>
|
|
</div>
|
|
<button @click="goToPage(currentPage + 1)"
|
|
:disabled="currentPage >= totalPages"
|
|
:class="currentPage >= totalPages ? 'opacity-50 cursor-not-allowed' : ''"
|
|
class="group flex items-center justify-center h-12 w-12 bg-[var(--color-bg-primary)] hover:bg-[var(--color-primary)] text-[var(--color-text-secondary)] hover:text-white rounded-xl shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110">
|
|
<i class="fas fa-chevron-right group-hover:animate-pulse"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* Enhanced scrollbar styling */
|
|
.scrollbar-thin::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.scrollbar-thin::-webkit-scrollbar-track {
|
|
background: rgba(31, 41, 55, 0.5);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
|
background: rgba(107, 114, 128, 0.5);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(107, 114, 128, 0.8);
|
|
}
|
|
|
|
/* Progress bar styling */
|
|
.progress {
|
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%);
|
|
border-radius: 0.5rem;
|
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
height: 24px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-bar {
|
|
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
|
|
height: 100%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
/* Table progress bar styling */
|
|
.progress-table {
|
|
background: var(--color-primary-light);
|
|
border-radius: 0.25rem;
|
|
border: 1px solid var(--color-primary-border);
|
|
height: 6px;
|
|
overflow: hidden;
|
|
width: 100%;
|
|
}
|
|
|
|
.progress-bar-table {
|
|
background: var(--gradient-primary);
|
|
height: 100%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
/* Table styling */
|
|
table {
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
background: var(--color-bg-secondary);
|
|
}
|
|
|
|
tbody tr:last-child td:first-child {
|
|
border-bottom-left-radius: 1rem;
|
|
}
|
|
|
|
tbody tr:last-child td:last-child {
|
|
border-bottom-right-radius: 1rem;
|
|
}
|
|
|
|
/* Markdown content overflow handling */
|
|
.markdown-content {
|
|
word-wrap: break-word;
|
|
overflow-wrap: anywhere;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.markdown-content pre {
|
|
overflow-x: auto;
|
|
max-width: 100%;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.markdown-content code {
|
|
word-wrap: break-word;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.markdown-content pre code {
|
|
white-space: pre;
|
|
overflow-x: auto;
|
|
display: block;
|
|
}
|
|
|
|
.markdown-content table {
|
|
max-width: 100%;
|
|
overflow-x: auto;
|
|
display: block;
|
|
}
|
|
|
|
.markdown-content img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
function modelsGallery() {
|
|
return {
|
|
models: [],
|
|
allTags: [],
|
|
repositories: [],
|
|
searchTerm: '',
|
|
loading: false,
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
availableModels: 0,
|
|
installedModels: 0,
|
|
ramTotal: 0,
|
|
ramUsed: 0,
|
|
ramUsagePercent: 0,
|
|
totalMemory: 0,
|
|
selectedModel: null,
|
|
jobProgress: {},
|
|
notifications: [],
|
|
sortBy: '',
|
|
sortOrder: 'asc',
|
|
|
|
init() {
|
|
this.fetchModels();
|
|
this.fetchResources();
|
|
// Poll for job progress every 600ms
|
|
setInterval(() => this.pollJobs(), 600);
|
|
},
|
|
|
|
async fetchResources() {
|
|
try {
|
|
const response = await fetch('/api/resources');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.totalMemory = data.aggregate?.total_memory || 0;
|
|
}
|
|
} catch (e) {}
|
|
},
|
|
|
|
addNotification(message, type = 'error') {
|
|
const id = Date.now();
|
|
this.notifications.push({ id, message, type });
|
|
// Auto-dismiss after 10 seconds
|
|
setTimeout(() => this.dismissNotification(id), 10000);
|
|
},
|
|
|
|
dismissNotification(id) {
|
|
this.notifications = this.notifications.filter(n => n.id !== id);
|
|
},
|
|
|
|
async fetchModels() {
|
|
this.loading = true;
|
|
try {
|
|
const params = new URLSearchParams({
|
|
page: this.currentPage,
|
|
items: 21,
|
|
term: this.searchTerm
|
|
});
|
|
if (this.sortBy) {
|
|
params.append('sort', this.sortBy);
|
|
params.append('order', this.sortOrder);
|
|
}
|
|
const response = await fetch(`/api/models?${params}`);
|
|
const data = await response.json();
|
|
|
|
this.models = data.models || [];
|
|
this.allTags = data.allTags || [];
|
|
this.repositories = data.repositories || [];
|
|
this.currentPage = data.currentPage || 1;
|
|
this.totalPages = data.totalPages || 1;
|
|
this.availableModels = data.availableModels || 0;
|
|
this.installedModels = data.installedModels || 0;
|
|
this.ramTotal = data.ramTotal || 0;
|
|
this.ramUsed = data.ramUsed || 0;
|
|
this.ramUsagePercent = data.ramUsagePercent || 0;
|
|
} catch (error) {
|
|
console.error('Error fetching models:', error);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
filterByTerm(term) {
|
|
this.searchTerm = term;
|
|
this.currentPage = 1;
|
|
this.fetchModels();
|
|
},
|
|
|
|
setSort(column) {
|
|
if (this.sortBy === column) {
|
|
// Toggle sort order if clicking the same column
|
|
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
// Set new column and default to ascending
|
|
this.sortBy = column;
|
|
this.sortOrder = 'asc';
|
|
}
|
|
this.currentPage = 1;
|
|
this.fetchModels();
|
|
},
|
|
|
|
goToPage(page) {
|
|
if (page >= 1 && page <= this.totalPages) {
|
|
this.currentPage = page;
|
|
this.fetchModels();
|
|
}
|
|
},
|
|
|
|
async installModel(modelId) {
|
|
try {
|
|
const response = await fetch(`/api/models/install/${encodeURIComponent(modelId)}`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await response.json();
|
|
if (data.jobID) {
|
|
// Update model state
|
|
const model = this.models.find(m => m.id === modelId);
|
|
if (model) {
|
|
model.processing = true;
|
|
model.jobID = data.jobID;
|
|
model.isDeletion = false;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error installing model:', error);
|
|
alert('Failed to start installation');
|
|
}
|
|
},
|
|
|
|
async deleteModel(modelId) {
|
|
if (!confirm('Are you sure you wish to delete the model?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/models/delete/${encodeURIComponent(modelId)}`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await response.json();
|
|
if (data.jobID) {
|
|
const model = this.models.find(m => m.id === modelId);
|
|
if (model) {
|
|
model.processing = true;
|
|
model.jobID = data.jobID;
|
|
model.isDeletion = true;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting model:', error);
|
|
alert('Failed to start deletion');
|
|
}
|
|
},
|
|
|
|
async reinstallModel(modelId) {
|
|
this.installModel(modelId);
|
|
},
|
|
|
|
async getConfig(modelId) {
|
|
try {
|
|
const response = await fetch(`/api/models/config/${encodeURIComponent(modelId)}`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await response.json();
|
|
alert(data.message || 'Configuration saved');
|
|
} catch (error) {
|
|
console.error('Error getting config:', error);
|
|
alert('Failed to get configuration');
|
|
}
|
|
},
|
|
|
|
async pollJobs() {
|
|
const processingModels = this.models.filter(m => m.processing && m.jobID);
|
|
|
|
for (const model of processingModels) {
|
|
try {
|
|
const response = await fetch(`/api/models/job/${model.jobID}`);
|
|
const jobData = await response.json();
|
|
|
|
// Handle queued status
|
|
if (jobData.queued) {
|
|
this.jobProgress[model.jobID] = 0;
|
|
// Keep processing state but don't show error
|
|
continue;
|
|
}
|
|
|
|
this.jobProgress[model.jobID] = jobData.progress || 0;
|
|
|
|
if (jobData.completed) {
|
|
model.processing = false;
|
|
model.installed = !jobData.deletion;
|
|
delete this.jobProgress[model.jobID];
|
|
// Show success notification
|
|
const action = jobData.deletion ? 'deleted' : 'installed';
|
|
this.addNotification(`Model "${model.name}" ${action} successfully!`, 'success');
|
|
// Refresh the models list to get updated state
|
|
this.fetchModels();
|
|
}
|
|
|
|
if (jobData.error || (jobData.message && jobData.message.startsWith('error:'))) {
|
|
model.processing = false;
|
|
delete this.jobProgress[model.jobID];
|
|
const action = model.isDeletion ? 'deleting' : 'installing';
|
|
// Extract error message - handle both string and object errors
|
|
let errorMessage = 'Unknown error';
|
|
if (typeof jobData.error === 'string') {
|
|
errorMessage = jobData.error;
|
|
} else if (jobData.error && typeof jobData.error === 'object') {
|
|
// Check if error object has any properties
|
|
const errorKeys = Object.keys(jobData.error);
|
|
if (errorKeys.length > 0) {
|
|
// Try common error object properties
|
|
errorMessage = jobData.error.message || jobData.error.error || jobData.error.Error || JSON.stringify(jobData.error);
|
|
} else {
|
|
// Empty object {}, fall back to message field
|
|
errorMessage = jobData.message || 'Unknown error';
|
|
}
|
|
} else if (jobData.message) {
|
|
// Use message field if error is not present or is empty
|
|
errorMessage = jobData.message;
|
|
}
|
|
// Remove "error: " prefix if present
|
|
if (errorMessage.startsWith('error: ')) {
|
|
errorMessage = errorMessage.substring(7);
|
|
}
|
|
this.addNotification(`Error ${action} model "${model.name}": ${errorMessage}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error polling job:', error);
|
|
// Don't show notification for every polling error, only if model is stuck
|
|
}
|
|
}
|
|
},
|
|
|
|
renderMarkdown(text) {
|
|
if (!text) return '';
|
|
try {
|
|
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
|
return text; // Return plain text if libraries not loaded
|
|
}
|
|
const html = marked.parse(text);
|
|
return DOMPurify.sanitize(html);
|
|
} catch (error) {
|
|
console.error('Error rendering markdown:', error);
|
|
return text;
|
|
}
|
|
},
|
|
|
|
openModal(model) {
|
|
this.selectedModel = model;
|
|
},
|
|
|
|
formatBytes(bytes) {
|
|
if (bytes === 0) return "0 B";
|
|
const k = 1024;
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
},
|
|
|
|
closeModal() {
|
|
this.selectedModel = null;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{{template "views/partials/footer" .}}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
</body>
|
|
</html>
|