mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-18 21:58:58 -04:00
feat(ui): Home loaded-models skeleton list, button hierarchy, EmptyState wizard
Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
29
core/http/react-ui/e2e/home-redesign.spec.js
Normal file
29
core/http/react-ui/e2e/home-redesign.spec.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
test.describe('Home editorial redesign', () => {
|
||||
test('renders the serif greeting header', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
const greeting = page.locator('.home-greeting')
|
||||
await expect(greeting).toBeVisible({ timeout: 15_000 })
|
||||
const family = await greeting.evaluate(el => getComputedStyle(el).fontFamily)
|
||||
expect(family.toLowerCase()).toContain('fraunces')
|
||||
})
|
||||
|
||||
test('quick links expose a single primary action', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
await expect(page.locator('.home-greeting, .empty-state-title').first()).toBeVisible({ timeout: 15_000 })
|
||||
const primaries = page.locator('.home-quick-links .btn-primary')
|
||||
// At most one primary CTA in the quick-links row.
|
||||
expect(await primaries.count()).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('loaded-models block uses an editorial section heading', async ({ page }) => {
|
||||
await page.goto('/app')
|
||||
await expect(page.locator('.home-greeting').first()).toBeVisible({ timeout: 15_000 })
|
||||
// The refined loaded-models block introduces a SectionHeading; the legacy
|
||||
// inline ".home-loaded-text" label is gone.
|
||||
const heading = page.locator('.home-loaded .section-heading')
|
||||
await expect(heading).toBeVisible({ timeout: 15_000 })
|
||||
await expect(heading).toHaveText(/active models/i)
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,7 @@
|
||||
"documentation": "Dokumentation"
|
||||
},
|
||||
"loadedModels": {
|
||||
"heading": "Aktive Modelle",
|
||||
"count_one": "{{count}} Modell geladen",
|
||||
"count_other": "{{count}} Modelle geladen",
|
||||
"stop": "Modell stoppen",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"documentation": "Documentation"
|
||||
},
|
||||
"loadedModels": {
|
||||
"heading": "Active models",
|
||||
"count_one": "{{count}} model loaded",
|
||||
"count_other": "{{count}} models loaded",
|
||||
"stop": "Stop model",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"documentation": "Documentación"
|
||||
},
|
||||
"loadedModels": {
|
||||
"heading": "Modelos activos",
|
||||
"count_one": "{{count}} modelo cargado",
|
||||
"count_other": "{{count}} modelos cargados",
|
||||
"stop": "Detener modelo",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"documentation": "Dokumentasi"
|
||||
},
|
||||
"loadedModels": {
|
||||
"heading": "Model aktif",
|
||||
"count_one": "{{count}} model dimuat",
|
||||
"count_other": "{{count}} model dimuat",
|
||||
"stop": "Hentikan model",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"documentation": "Documentazione"
|
||||
},
|
||||
"loadedModels": {
|
||||
"heading": "Modelli attivi",
|
||||
"count_one": "{{count}} modello caricato",
|
||||
"count_other": "{{count}} modelli caricati",
|
||||
"stop": "Ferma modello",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"documentation": "문서"
|
||||
},
|
||||
"loadedModels": {
|
||||
"heading": "활성 모델",
|
||||
"count_one": "모델 {{count}}개 로드됨",
|
||||
"count_other": "모델 {{count}}개 로드됨",
|
||||
"stop": "모델 중지",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"documentation": "文档"
|
||||
},
|
||||
"loadedModels": {
|
||||
"heading": "活动模型",
|
||||
"count_one": "已加载 {{count}} 个模型",
|
||||
"count_other": "已加载 {{count}} 个模型",
|
||||
"stop": "停止模型",
|
||||
|
||||
@@ -5959,33 +5959,21 @@ select.input {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
/* Quiet variant: a deliberately understated link (e.g. Documentation) so the
|
||||
quick-links row keeps a single clear primary action. */
|
||||
.home-link-btn--quiet { opacity: 0.8; }
|
||||
|
||||
/* Home loaded models */
|
||||
.home-loaded-models {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
.home-loaded {
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-subtle);
|
||||
}
|
||||
.home-loaded-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-success);
|
||||
}
|
||||
.home-loaded-text {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.home-loaded-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
@@ -5994,12 +5982,11 @@ select.input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
padding: 3px 6px 3px 10px;
|
||||
background: var(--color-surface-sunken);
|
||||
border: 1px solid var(--color-border-divider);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.home-loaded-item button {
|
||||
background: none;
|
||||
@@ -6007,18 +5994,16 @@ select.input {
|
||||
color: var(--color-error);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
.home-loaded-empty {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.home-stop-all {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--color-error);
|
||||
color: var(--color-error);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Home wizard (no models) */
|
||||
|
||||
@@ -14,6 +14,9 @@ import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi
|
||||
import { API_CONFIG } from '../utils/config'
|
||||
import { greetingKey } from '../utils/greeting'
|
||||
import StatusPill from '../components/StatusPill'
|
||||
import Skeleton from '../components/Skeleton'
|
||||
import SectionHeading from '../components/SectionHeading'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import { staggerStyle } from '../hooks/useStagger'
|
||||
|
||||
export default function Home() {
|
||||
@@ -437,53 +440,64 @@ export default function Home() {
|
||||
<i className="fas fa-user-shield" /> {t('quickLinks.manageByChat')}
|
||||
</button>
|
||||
)}
|
||||
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
|
||||
<i className="fas fa-desktop" /> {t('quickLinks.installedModels')}
|
||||
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
|
||||
<i className="fas fa-download" aria-hidden="true" /> {t('quickLinks.browseGallery')}
|
||||
</button>
|
||||
<button className="home-link-btn" onClick={() => navigate('/app/models')}>
|
||||
<i className="fas fa-download" /> {t('quickLinks.browseGallery')}
|
||||
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
|
||||
<i className="fas fa-desktop" aria-hidden="true" /> {t('quickLinks.installedModels')}
|
||||
</button>
|
||||
<button className="home-link-btn" onClick={() => navigate('/app/import-model')}>
|
||||
<i className="fas fa-upload" /> {t('quickLinks.importModel')}
|
||||
<i className="fas fa-upload" aria-hidden="true" /> {t('quickLinks.importModel')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<a className="home-link-btn" href="https://localai.io" target="_blank" rel="noopener noreferrer">
|
||||
<i className="fas fa-book" /> {t('quickLinks.documentation')}
|
||||
<a className="home-link-btn home-link-btn--quiet" href="https://localai.io" target="_blank" rel="noopener noreferrer">
|
||||
<i className="fas fa-book" aria-hidden="true" /> {t('quickLinks.documentation')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Loaded models status */}
|
||||
{loadedCount > 0 && (
|
||||
<div className="home-loaded-models">
|
||||
<span className="home-loaded-dot" />
|
||||
<span className="home-loaded-text">{t('loadedModels.count', { count: loadedCount })}</span>
|
||||
<div className="home-loaded-list">
|
||||
{[...loadedModels].sort((a, b) => a.id.localeCompare(b.id)).map(m => (
|
||||
<span key={m.id} className="home-loaded-item">
|
||||
{m.id}
|
||||
<button onClick={() => handleStopModel(m.id)} title={t('loadedModels.stop')}>
|
||||
<i className="fas fa-times" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{loadedCount > 1 && (
|
||||
<button className="home-stop-all" onClick={handleStopAll}>
|
||||
{t('loadedModels.stopAll')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<section className="home-loaded">
|
||||
<SectionHeading>{t('loadedModels.heading')}</SectionHeading>
|
||||
{modelsLoading ? (
|
||||
<Skeleton variant="line" count={2} />
|
||||
) : loadedCount > 0 ? (
|
||||
<>
|
||||
<ul className="home-loaded-list reveal-stagger">
|
||||
{[...loadedModels].sort((a, b) => a.id.localeCompare(b.id)).map((m, i) => (
|
||||
<li key={m.id} className="home-loaded-item" style={staggerStyle(i)}>
|
||||
<StatusPill status="healthy" label={m.id} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStopModel(m.id)}
|
||||
title={t('loadedModels.stop')}
|
||||
aria-label={t('loadedModels.stop')}
|
||||
>
|
||||
<i className="fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{loadedCount > 1 && (
|
||||
<button className="btn btn-secondary btn-sm home-stop-all" onClick={handleStopAll}>
|
||||
{t('loadedModels.stopAll')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="home-loaded-empty">{t('statusLine.noModelsLoaded')}</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : isAdmin ? (
|
||||
/* No models installed - compact getting started */
|
||||
<div className="home-wizard">
|
||||
<div className="home-wizard-hero">
|
||||
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="home-logo" />
|
||||
<h1>{t('wizard.getStarted', { name: branding.instanceName })}</h1>
|
||||
<p>{t('wizard.intro')}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
eyebrow={branding.instanceName}
|
||||
icon="fa-rocket"
|
||||
title={t('wizard.getStarted', { name: branding.instanceName })}
|
||||
body={t('wizard.intro')}
|
||||
/>
|
||||
|
||||
<div className="home-wizard-steps card">
|
||||
<div className="home-wizard-step">
|
||||
|
||||
Reference in New Issue
Block a user