Skip to content

Commit d462934

Browse files
committed
feat: WIP
1 parent e802356 commit d462934

9 files changed

Lines changed: 973 additions & 6 deletions

File tree

tools/ui/src/lib/components/ui/sidebar/sidebar-trigger.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
data-slot="sidebar-trigger"
2323
variant="ghost"
2424
size="icon-lg"
25-
class="rounded-full backdrop-blur-lg {className} {sidebar.open
25+
class="fixed! rounded-full backdrop-blur-lg {className} {sidebar.open
2626
? 'top-1.5'
2727
: 'top-0'} md:left-[calc(var(--sidebar-width)-3.25rem)] {sidebar.isResizing
2828
? '!duration-0'

tools/ui/src/lib/constants/routes.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,7 @@ export const ROUTES = {
2323
/** MCP servers. */
2424
MCP_SERVERS: '#/mcp-servers',
2525
/** Settings base — for dynamic settings URLs use RouterService. */
26-
SETTINGS: '#/settings'
26+
SETTINGS: '#/settings',
27+
/** Model management — browse HuggingFace GGUF models. */
28+
MANAGE_MODELS: '#/models'
2729
} as const;

tools/ui/src/lib/constants/ui.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Settings, Search, SquarePen } from '@lucide/svelte';
1+
import { Settings, Search, SquarePen, Package } from '@lucide/svelte';
22
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
33
import type { Component } from 'svelte';
44
import { ROUTES } from './routes';
@@ -21,6 +21,7 @@ export interface DesktopIconStripItem {
2121

2222
export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
2323
{ icon: SquarePen, tooltip: 'New chat', route: ROUTES.NEW_CHAT, keys: ['shift', 'cmd', 'o'] },
24+
{ icon: Package, tooltip: 'Models', route: ROUTES.MANAGE_MODELS },
2425
{ icon: Search, tooltip: 'Search', keys: ['cmd', 'k'] },
2526
{
2627
icon: McpLogo,
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import type {
2+
HfModelInfo,
3+
HfModelDetailInfo,
4+
HfModelSearchParams,
5+
HfModelSort
6+
} from '$lib/types/huggingface';
7+
8+
// Constants
9+
10+
export const HF_TASKS: Record<string, string> = {
11+
'text-generation': 'Text Generation',
12+
conversational: 'Conversational',
13+
'text2text-generation': 'Text2Text Generation',
14+
'fill-mask': 'Fill Mask',
15+
'automatic-speech-recognition': 'Speech Recognition',
16+
'text-to-speech': 'Text to Speech',
17+
'sentence-similarity': 'Sentence Similarity'
18+
};
19+
20+
export const HF_LIBRARIES: Record<string, string> = {
21+
transformers: 'Transformers',
22+
gguf: 'GGUF',
23+
safetensors: 'Safetensors',
24+
onnx: 'ONNX',
25+
vllm: 'vLLM',
26+
mlx: 'MLX'
27+
};
28+
29+
/**
30+
* HuggingFaceService - Service for browsing and searching GGUF models on Hugging Face Hub
31+
*/
32+
export class HuggingFaceService {
33+
// Configuration
34+
35+
private static readonly BASE_URL = 'https://huggingface.co/api/models';
36+
private static readonly DEFAULT_LIMIT = 50;
37+
private static readonly MAX_LIMIT = 100;
38+
39+
// Available options for filtering
40+
41+
/** Available pipeline tasks with display labels */
42+
static readonly TASKS: Record<string, string> = HF_TASKS;
43+
44+
/** Available library names with display labels */
45+
static readonly LIBRARIES: Record<string, string> = HF_LIBRARIES;
46+
47+
/** Available sort options */
48+
static readonly SORT_OPTIONS: HfModelSort[] = [
49+
'downloads',
50+
'likes',
51+
'trendingScore',
52+
'createdAt'
53+
];
54+
55+
/** Sort option display labels */
56+
static readonly SORT_LABELS: Record<HfModelSort, string> = {
57+
downloads: 'Most Downloads',
58+
likes: 'Most Likes',
59+
trendingScore: 'Trending',
60+
createdAt: 'Newest'
61+
};
62+
63+
// GGUF Model Searching
64+
65+
/**
66+
* Search GGUF models with various filters and options
67+
*/
68+
static async search(params: HfModelSearchParams = {}): Promise<HfModelInfo[]> {
69+
const { limit = HuggingFaceService.DEFAULT_LIMIT, ...restParams } = params;
70+
71+
const url = this.buildUrl({
72+
...restParams,
73+
filter: 'gguf',
74+
limit: Math.min(limit, HuggingFaceService.MAX_LIMIT)
75+
});
76+
77+
return this.fetchWithRetry(url);
78+
}
79+
80+
/**
81+
* Search models by query string
82+
*/
83+
static async searchByQuery(
84+
query: string,
85+
params: Omit<HfModelSearchParams, 'search'> = {}
86+
): Promise<HfModelInfo[]> {
87+
return this.search({
88+
...params,
89+
search: query
90+
});
91+
}
92+
93+
// GGUF Model Browsing
94+
95+
/**
96+
* Get trending GGUF models
97+
*/
98+
static async getTrending(
99+
limit: number = HuggingFaceService.DEFAULT_LIMIT
100+
): Promise<HfModelInfo[]> {
101+
return this.search({ sort: 'trendingScore', limit });
102+
}
103+
104+
/**
105+
* Get most popular GGUF models by downloads
106+
*/
107+
static async getPopular(
108+
limit: number = HuggingFaceService.DEFAULT_LIMIT
109+
): Promise<HfModelInfo[]> {
110+
return this.search({ sort: 'downloads', limit });
111+
}
112+
113+
/**
114+
* Get most liked GGUF models
115+
*/
116+
static async getMostLiked(
117+
limit: number = HuggingFaceService.DEFAULT_LIMIT
118+
): Promise<HfModelInfo[]> {
119+
return this.search({ sort: 'likes', limit });
120+
}
121+
122+
/**
123+
* Get newly released GGUF models
124+
*/
125+
static async getNew(limit: number = HuggingFaceService.DEFAULT_LIMIT): Promise<HfModelInfo[]> {
126+
return this.search({ sort: 'createdAt', limit });
127+
}
128+
129+
// GGUF Model Filtering
130+
131+
/**
132+
* Get GGUF models by pipeline task
133+
*/
134+
static async getByTask(
135+
pipelineTag: string,
136+
params: Omit<HfModelSearchParams, 'pipeline_tag'> = {}
137+
): Promise<HfModelInfo[]> {
138+
return this.search({
139+
...params,
140+
pipeline_tag: pipelineTag
141+
});
142+
}
143+
144+
// Model Details & Files
145+
146+
/**
147+
* Get detailed information about a specific GGUF model
148+
*/
149+
static async getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
150+
// FIX: Do not encode the modelId, as it contains slashes for author/name
151+
const url = `https://huggingface.co/api/models/${modelId}`;
152+
try {
153+
const response = await fetch(url);
154+
if (response.status === 404) return null;
155+
if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
156+
const data = (await response.json()) as HfModelDetailInfo;
157+
return data;
158+
} catch (error) {
159+
console.error(`Error fetching details for ${modelId}:`, error);
160+
return null;
161+
}
162+
}
163+
164+
/**
165+
* Get repository file tree to list available GGUF variants
166+
*/
167+
static async getTree(modelId: string): Promise<{ path: string; size: number }[]> {
168+
// FIX: Do not encode the modelId
169+
const url = `https://huggingface.co/api/models/${modelId}/tree/main`;
170+
try {
171+
const response = await fetch(url);
172+
if (!response.ok) return [];
173+
const data = await response.json();
174+
return data.filter((f: { path: string; size: number }) => f.path.endsWith('.gguf'));
175+
} catch {
176+
return [];
177+
}
178+
}
179+
180+
// Model Navigation
181+
182+
/**
183+
* Get model URL on Hugging Face Hub
184+
*/
185+
static getModelUrl(modelId: string): string {
186+
return `https://huggingface.co/${modelId}`;
187+
}
188+
189+
// Utility Methods
190+
191+
/**
192+
* Parse model tags to extract useful information
193+
*/
194+
static parseTags(tags: string[]): {
195+
license: string | null;
196+
isGated: boolean;
197+
isGguf: boolean;
198+
isSafetensors: boolean;
199+
tasks: string[];
200+
} {
201+
const license = tags.find((tag) => tag.startsWith('license:'))?.replace('license:', '') || null;
202+
const isGated = tags.includes('gated');
203+
const isGguf = tags.includes('gguf');
204+
const isSafetensors = tags.includes('safetensors');
205+
const tasks = tags.filter((tag) => Object.keys(HuggingFaceService.TASKS).includes(tag));
206+
207+
return { license, isGated, isGguf, isSafetensors, tasks };
208+
}
209+
210+
/**
211+
* Format model downloads count with K/M/B suffix
212+
*/
213+
static formatDownloads(downloads: number): string {
214+
if (downloads >= 1_000_000) {
215+
return `${(downloads / 1_000_000).toFixed(1)}M`;
216+
}
217+
if (downloads >= 1_000) {
218+
return `${(downloads / 1_000).toFixed(1)}K`;
219+
}
220+
return downloads.toString();
221+
}
222+
223+
/**
224+
* Format likes count with K suffix if applicable
225+
*/
226+
static formatLikes(likes: number): string {
227+
if (likes >= 1_000) {
228+
return `${(likes / 1_000).toFixed(1)}K`;
229+
}
230+
return likes.toString();
231+
}
232+
233+
/**
234+
* Format timestamp to relative time
235+
*/
236+
static formatRelativeTime(timestamp: string): string {
237+
const date = new Date(timestamp);
238+
const now = new Date();
239+
const diffMs = now.getTime() - date.getTime();
240+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
241+
242+
if (diffDays === 0) return 'Today';
243+
if (diffDays === 1) return 'Yesterday';
244+
if (diffDays < 7) return `${diffDays} days ago`;
245+
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
246+
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
247+
return `${Math.floor(diffDays / 365)} years ago`;
248+
}
249+
250+
/**
251+
* Format file size in bytes to human-readable string
252+
*/
253+
static formatFileSize(bytes: number): string {
254+
if (bytes >= 1_000_000_000) {
255+
return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
256+
}
257+
if (bytes >= 1_000_000) {
258+
return `${(bytes / 1_000_000).toFixed(1)} MB`;
259+
}
260+
if (bytes >= 1_000) {
261+
return `${(bytes / 1_000).toFixed(1)} KB`;
262+
}
263+
return `${bytes} B`;
264+
}
265+
266+
// Internal Methods
267+
268+
/**
269+
* Build API URL from search parameters
270+
*/
271+
private static buildUrl(params: HfModelSearchParams): string {
272+
const url = new URL(this.BASE_URL);
273+
274+
Object.entries(params).forEach(([key, value]) => {
275+
if (value !== undefined && value !== null && value !== '') {
276+
if (Array.isArray(value)) {
277+
value.forEach((v) => url.searchParams.append(key, v));
278+
} else {
279+
url.searchParams.set(key, String(value));
280+
}
281+
}
282+
});
283+
284+
return url.toString();
285+
}
286+
287+
/**
288+
* Fetch data with retry logic for resilience
289+
*/
290+
private static async fetchWithRetry(url: string, attempt: number = 1): Promise<HfModelInfo[]> {
291+
const RETRY_ATTEMPTS = 3;
292+
const RETRY_DELAY_MS = 1000;
293+
294+
try {
295+
const response = await fetch(url);
296+
297+
if (!response.ok) {
298+
if (response.status === 404) {
299+
return [];
300+
}
301+
302+
if (response.status >= 500 && attempt < RETRY_ATTEMPTS) {
303+
await this.delay(RETRY_DELAY_MS * attempt);
304+
return this.fetchWithRetry(url, attempt + 1);
305+
}
306+
307+
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
308+
}
309+
310+
const data = await response.json();
311+
312+
if (Array.isArray(data)) {
313+
return data as HfModelInfo[];
314+
}
315+
316+
if (data && Array.isArray(data.data)) {
317+
return data.data as HfModelInfo[];
318+
}
319+
320+
throw new Error('Unexpected API response format');
321+
} catch (error) {
322+
if (attempt < RETRY_ATTEMPTS) {
323+
await this.delay(RETRY_DELAY_MS * attempt);
324+
return this.fetchWithRetry(url, attempt + 1);
325+
}
326+
327+
throw error;
328+
}
329+
}
330+
331+
/**
332+
* Delay helper for retry logic
333+
*/
334+
private static delay(ms: number): Promise<void> {
335+
return new Promise((resolve) => setTimeout(resolve, ms));
336+
}
337+
}

0 commit comments

Comments
 (0)