Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions prototype/ui-redesign/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ export interface ModelsData {
data: ModelInfo[];
}

/** One physical file backing a model (from GET /api/v1/models/{id}/files). */
export interface ModelFileInfo {
name: string;
role: string;
size_bytes: number;
exists: boolean;
}

/** Response shape of GET /api/v1/models/{id}/files. */
export interface ModelFilesResponse {
model_id: string;
files: ModelFileInfo[];
}

/** Real disk usage for the model-storage drive (bytes). */
export interface StorageInfo {
usedBytes: number;
Expand Down Expand Up @@ -945,6 +959,23 @@ class LemonadeAPI {
return this._json<ModelInfo>(`/api/v1/models/${encodeURIComponent(id)}`);
}

/**
* List the physical files backing a model (main weights, mmproj, tokenizer, …).
* Returns null when the endpoint is unreachable or the model is unknown, so the
* UI can fall back to an empty/error state instead of throwing.
*/
async getModelFiles(id: string): Promise<ModelFilesResponse | null> {
try {
const data = await this._json<ModelFilesResponse>(
`/api/v1/models/${encodeURIComponent(id)}/files`,
);
if (!data || !Array.isArray(data.files)) return null;
return data;
} catch {
return null;
}
}

async loadModel(modelName: string, recipeOptions?: Record<string, unknown>, modelInfo?: ModelInfo | null): Promise<unknown> {
const target = modelName.trim().toLowerCase();
const cachedModelInfo = modelInfo || this.allModels.find(model => modelInfoKey(model).toLowerCase() === target) || null;
Expand Down
132 changes: 122 additions & 10 deletions prototype/ui-redesign/src/components/ModelDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import type { ModelInfo, LoadedModel } from '../api';
import type { ModelInfo, LoadedModel, ModelFileInfo } from '../api';
import api from '../api';
import { capabilityFromModelInfo, capabilityLabel } from '../modelCapabilities';
import {
DEFAULT_PRESET, PRESET_STORE_EVENT, Preset, PresetChangeKind,
Expand Down Expand Up @@ -447,15 +448,124 @@ const ModelPresetsTab: React.FC<{
);
};

/* ── Files tab stub ──────────────────────────────────────────── */
/* ── Files tab ───────────────────────────────────────────────── */

/** Human-readable byte size (B / KB / MB / GB) using binary units. */
function fmtBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const decimals = unit === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(decimals)} ${units[unit]}`;
}

/** Title-case a role slug for display (e.g. "mmproj" → "Mmproj", "main" → "Main"). */
function roleLabel(role: string): string {
const r = String(role || '').trim();
if (!r) return 'File';
return r.charAt(0).toUpperCase() + r.slice(1);
}

const ModelFilesTab: React.FC<{ model: ModelInfo | null | undefined; isActive: boolean }> = ({ model, isActive }) => {
const modelId = model ? String(model.id || '') : '';
const [files, setFiles] = useState<ModelFileInfo[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);

useEffect(() => {
if (!isActive) return;
if (!modelId) { setFiles([]); return; }

let cancelled = false;
setLoading(true);
setError(false);
api.getModelFiles(modelId)
.then(resp => {
if (cancelled) return;
if (!resp) { setError(true); setFiles(null); return; }
setFiles(resp.files);
})
.catch(() => { if (!cancelled) { setError(true); setFiles(null); } })
.finally(() => { if (!cancelled) setLoading(false); });

return () => { cancelled = true; };
}, [modelId, isActive]);

if (loading) {
return (
<div className="detail-tab-content detail-files detail-files--loading" aria-live="polite" aria-busy="true">
<span>Loading files…</span>
</div>
);
}

const ModelFilesTab: React.FC = () => (
<div className="detail-tab-content detail-files detail-files--stub">
<Icon name="hard-drive" size={28} aria-hidden="true" />
<p>Files tab — coming in a future update.</p>
<small>Requires a <code>GET /api/v1/models/&#123;id&#125;/files</code> endpoint in lemond.</small>
</div>
);
if (error) {
return (
<div className="detail-tab-content detail-files detail-files--empty">
<Icon name="hard-drive" size={32} aria-hidden="true" />
<p>Unable to load files for this model.</p>
</div>
);
}

if (!files || files.length === 0) {
return (
<div className="detail-tab-content detail-files detail-files--empty">
<Icon name="hard-drive" size={32} aria-hidden="true" />
<p>No files found for this model.</p>
<small>Files appear here once the model has been downloaded.</small>
</div>
);
}

return (
<div className="detail-tab-content detail-files">
<table className="detail-files__table">
<caption className="sr-only">Files backing {mdName(model) || modelId}</caption>
<thead>
<tr>
<th scope="col">File</th>
<th scope="col">Role</th>
<th scope="col" className="detail-files__col-size">Size</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{files.map((file, idx) => (
<tr key={`${file.name}-${idx}`}>
<td className="detail-files__name">
<Icon name="file" size={14} aria-hidden="true" />
<span title={file.name}>{file.name}</span>
</td>
<td>
<span className="detail-files__role-badge">{roleLabel(file.role)}</span>
</td>
<td className="detail-files__col-size">{fmtBytes(file.size_bytes)}</td>
<td>
{file.exists ? (
<span className="detail-files__status detail-files__status--present">
<Icon name="check" size={14} aria-hidden="true" />
<span>Downloaded</span>
</span>
) : (
<span className="detail-files__status detail-files__status--missing">
<Icon name="download" size={14} aria-hidden="true" />
<span>Not downloaded</span>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

/* ── ModelDetailPanel ────────────────────────────────────────── */

Expand Down Expand Up @@ -926,7 +1036,9 @@ export const ModelDetailPanel: React.FC<ModelDetailPanelProps> = ({
{tab.id === 'presets' && (
<ModelPresetsTab model={model} isActive={activeTab === 'presets'} />
)}
{tab.id === 'files' && <ModelFilesTab />}
{tab.id === 'files' && (
<ModelFilesTab model={model} isActive={activeTab === 'files'} />
)}
</div>
))}
</div>
Expand Down
84 changes: 79 additions & 5 deletions prototype/ui-redesign/src/styles/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -9001,22 +9001,96 @@ input, textarea {
margin: 0;
}

/* ── Files tab stub ───────────────────────────────────────────── */
/* ── Files tab ────────────────────────────────────────────────── */

.detail-files--stub {
.detail-files {
flex: 1;
overflow-y: auto;
padding: var(--space-5);
font-size: var(--text-sm);
color: var(--text-primary);
}

.detail-files--loading,
.detail-files--empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
color: var(--text-tertiary);
font-size: var(--text-sm);
text-align: center;
padding: var(--space-6);
min-height: 160px;
}

.detail-files--empty small { font-size: var(--text-xs); }

.detail-files__table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}

.detail-files__table th,
.detail-files__table td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
height: 40px;
box-sizing: border-box;
}

.detail-files__table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.03em;
border-bottom: 1px solid var(--border-strong);
height: 32px;
}

.detail-files__table tbody tr:last-child td { border-bottom: none; }

.detail-files__col-size { text-align: left; white-space: nowrap; }

.detail-files__name {
display: flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-mono, monospace);
font-size: 0.88em;
color: var(--text-primary);
word-break: break-all;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
}

.detail-files__name svg { flex-shrink: 0; color: var(--text-tertiary); }

.detail-files__role-badge {
display: inline-block;
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
background: var(--surface-2);
border: 1px solid var(--border-subtle);
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
}

.detail-files__status {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
white-space: nowrap;
}

.detail-files--stub small { font-size: var(--text-xs); }
.detail-files__status--present { color: var(--success, var(--accent-fg)); }
.detail-files__status--missing { color: var(--text-tertiary); }

/* ── btn size helpers ─────────────────────────────────────────── */

Expand Down
Loading
Loading