new integrated hugginface model search, ensure that prettier --check is run in CI

This commit is contained in:
Egor 2025-12-07 22:47:35 -08:00
parent d302f07e79
commit bde2decd9e
19 changed files with 2345 additions and 213 deletions

View file

@ -36,7 +36,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: yarn run: yarn
- name: Run checks (lint, type check, spell check) - name: Run checks (format, lint, type check)
run: yarn test run: yarn test
- name: Test build - name: Test build

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src 'self' http: https:; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' data:; frame-src 'self' http: https:;" content="default-src 'self'; connect-src 'self' http: https:; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' data:; frame-src 'self' http: https:; img-src 'self' data: https:;"
/> />
<title>Gerbil</title> <title>Gerbil</title>
</head> </head>

View file

@ -16,10 +16,11 @@
"package": "electron-vite build && electron-builder --publish=never", "package": "electron-vite build && electron-builder --publish=never",
"analyze": "cross-env ANALYZE=true electron-vite build && yarn dlx open-cli dist/stats.html", "analyze": "cross-env ANALYZE=true electron-vite build && yarn dlx open-cli dist/stats.html",
"format": "prettier --write . --ignore-path .gitignore | grep -v '(unchanged)' || true", "format": "prettier --write . --ignore-path .gitignore | grep -v '(unchanged)' || true",
"format:check": "prettier --check . --ignore-path .gitignore",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"compile": "tsc --noEmit", "compile": "tsc --noEmit",
"test": "yarn lint && yarn compile", "test": "yarn format:check && yarn lint && yarn compile",
"release": "yarn dlx tsx scripts/release.ts" "release": "yarn dlx tsx scripts/release.ts"
}, },
"keywords": [ "keywords": [
@ -53,6 +54,10 @@
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"systeminformation": "^5.27.11", "systeminformation": "^5.27.11",
"winston": "^3.19.0", "winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0",
@ -85,7 +90,7 @@
"prettier": "^3.7.4", "prettier": "^3.7.4",
"rollup-plugin-visualizer": "^6.0.5", "rollup-plugin-visualizer": "^6.0.5",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6" "vite": "^7.2.7"
}, },
"build": { "build": {
"appId": "com.gerbil.app", "appId": "com.gerbil.app",

View file

@ -21,6 +21,7 @@ export interface ModalProps {
closeOnClickOutside?: boolean; closeOnClickOutside?: boolean;
closeOnEscape?: boolean; closeOnEscape?: boolean;
showCloseButton?: boolean; showCloseButton?: boolean;
tallContent?: boolean;
} }
export const Modal = ({ export const Modal = ({
@ -32,33 +33,54 @@ export const Modal = ({
closeOnClickOutside = false, closeOnClickOutside = false,
closeOnEscape = true, closeOnEscape = true,
showCloseButton = false, showCloseButton = false,
tallContent = false,
...props ...props
}: ModalProps) => ( }: ModalProps) => {
<MantineModal const content = tallContent ? (
opened={opened} <div
onClose={onClose} style={{
title={title} height: '66vh',
size={size} padding: 0,
centered display: 'flex',
closeOnClickOutside={closeOnClickOutside} flexDirection: 'column',
closeOnEscape={closeOnEscape} position: 'relative',
styles={MODAL_STYLES_WITH_TITLEBAR} }}
{...props} >
> {children}
{children} </div>
{showCloseButton && ( ) : (
<Box <>
style={{ {children}
padding: '1rem 0', {showCloseButton && (
display: 'flex', <Box
justifyContent: 'flex-end', style={{
flexShrink: 0, padding: '1rem 0',
}} display: 'flex',
> justifyContent: 'flex-end',
<Button onClick={onClose} variant="filled"> flexShrink: 0,
Close }}
</Button> >
</Box> <Button onClick={onClose} variant="filled">
)} Close
</MantineModal> </Button>
); </Box>
)}
</>
);
return (
<MantineModal
opened={opened}
onClose={onClose}
title={title}
size={size}
centered
closeOnClickOutside={closeOnClickOutside}
closeOnEscape={closeOnEscape}
styles={MODAL_STYLES_WITH_TITLEBAR}
{...props}
>
{content}
</MantineModal>
);
};

View file

@ -37,7 +37,11 @@ export const GeneralTab = ({ configLoaded = true }: GeneralTabProps) => {
tooltip="Select a GGUF text generation model file for chat and completion tasks." tooltip="Select a GGUF text generation model file for chat and completion tasks."
onChange={setModel} onChange={setModel}
onSelectFile={() => selectFile('model', 'Select Text Model')} onSelectFile={() => selectFile('model', 'Select Text Model')}
searchUrl="https://huggingface.co/models?pipeline_tag=text-generation&library=gguf&sort=trending" searchParams={{
pipelineTag: 'text-generation',
filter: 'gguf',
sort: 'trendingScore',
}}
showAnalyze showAnalyze
paramType="model" paramType="model"
/> />

View file

@ -0,0 +1,56 @@
import { Table, Text, Group, Loader } from '@mantine/core';
import { formatBytes } from '@/utils/format';
import type { HuggingFaceFileInfo } from '@/types';
interface FilesTableProps {
files: HuggingFaceFileInfo[];
loading: boolean;
onSelect: (file: HuggingFaceFileInfo) => void;
}
export const FilesTable = ({ files, loading, onSelect }: FilesTableProps) => {
if (loading) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
if (files.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No compatible files found in this model
</Text>
);
}
return (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>File</Table.Th>
<Table.Th style={{ width: 120 }}>Size</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{files.map((file) => (
<Table.Tr
key={file.path}
onClick={() => onSelect(file)}
style={{ cursor: 'pointer' }}
>
<Table.Td>
<Text size="sm" lineClamp={1}>
{file.path}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{formatBytes(file.size)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
};

View file

@ -0,0 +1,107 @@
import { useState } from 'react';
import { Accordion, Paper, Group, Loader, Text } from '@mantine/core';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { HUGGINGFACE_BASE_URL } from '@/constants';
interface ModelCardProps {
modelId: string;
}
export const ModelCard = ({ modelId }: ModelCardProps) => {
const [readme, setReadme] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadReadme = async () => {
if (readme !== null) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`${HUGGINGFACE_BASE_URL}/${modelId}/raw/main/README.md`
);
if (!response.ok) {
throw new Error('README not available');
}
let text = await response.text();
const frontmatterRegex = /^---\n[\s\S]*?\n---\n/;
text = text.replace(frontmatterRegex, '');
setReadme(text);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load README');
} finally {
setLoading(false);
}
};
return (
<Accordion>
<Accordion.Item value="readme">
<Accordion.Control onClick={loadReadme}>Model Card</Accordion.Control>
<Accordion.Panel>
{loading && (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
)}
{error && (
<Text c="dimmed" size="sm">
{error}
</Text>
)}
{readme && (
<Paper
p="md"
withBorder
style={{
overflow: 'auto',
maxWidth: '100%',
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={{
img: ({ node, ...props }) => (
<img
{...props}
style={{
maxWidth: '100%',
height: 'auto',
display: 'block',
}}
/>
),
pre: ({ node, ...props }) => (
<pre
{...props}
style={{
overflow: 'auto',
maxWidth: '100%',
}}
/>
),
code: ({ node, ...props }) => (
<code
{...props}
style={{
wordBreak: 'break-word',
}}
/>
),
}}
>
{readme}
</ReactMarkdown>
</Paper>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
};

View file

@ -0,0 +1,138 @@
import {
Table,
Text,
Group,
Loader,
Stack,
Badge,
Tooltip,
} from '@mantine/core';
import { Download, Heart, Lock } from 'lucide-react';
import { HUGGINGFACE_BASE_URL } from '@/constants';
import { formatDownloads, formatDate } from '@/utils/format';
import type { HuggingFaceModelInfo, HuggingFaceSortOption } from '@/types';
interface ModelsTableProps {
models: HuggingFaceModelInfo[];
loading: boolean;
onSelect: (model: HuggingFaceModelInfo) => void;
sortBy: HuggingFaceSortOption;
onSortChange: (sort: HuggingFaceSortOption) => void;
}
export const ModelsTable = ({
models,
loading,
onSelect,
sortBy,
onSortChange,
}: ModelsTableProps) => {
if (loading && models.length === 0) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
if (models.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No models found
</Text>
);
}
return (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Model</Table.Th>
<Table.Th style={{ width: 80 }}>Params</Table.Th>
<Table.Th
style={{ width: 120, cursor: 'pointer' }}
onClick={() => onSortChange('downloads')}
>
Downloads{sortBy === 'downloads' && ' ↓'}
</Table.Th>
<Table.Th
style={{ width: 90, cursor: 'pointer' }}
onClick={() => onSortChange('likes')}
>
Likes{sortBy === 'likes' && ' ↓'}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{models.map((model) => (
<Table.Tr
key={model.id}
onClick={() => {
if (model.gated) {
window.electronAPI.app.openExternal(
`${HUGGINGFACE_BASE_URL}/${model.id}`
);
} else {
onSelect(model);
}
}}
style={{
cursor: 'pointer',
}}
>
<Table.Td>
<Stack gap={2}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500} lineClamp={1}>
{model.name}
</Text>
{model.gated && (
<Tooltip label="Gated model - click to open on HuggingFace">
<Badge
size="xs"
color="yellow"
leftSection={<Lock size={10} />}
>
Gated
</Badge>
</Tooltip>
)}
</Group>
<Group gap="xs">
<Text size="xs" c="dimmed">
{model.author}
</Text>
<Text size="xs" c="dimmed">
</Text>
<Text size="xs" c="dimmed">
Updated {formatDate(model.updatedAt)}
</Text>
</Group>
</Stack>
</Table.Td>
<Table.Td>
{model.paramSize && (
<Badge size="sm" variant="light" color="blue">
{model.paramSize}
</Badge>
)}
</Table.Td>
<Table.Td>
<Group gap={4}>
<Download size={12} />
<Text size="sm">{formatDownloads(model.downloads)}</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap={4}>
<Heart size={12} />
<Text size="sm">{formatDownloads(model.likes)}</Text>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
};

View file

@ -0,0 +1,235 @@
import { useEffect, useState, useRef } from 'react';
import {
Stack,
TextInput,
Text,
Group,
ActionIcon,
Tooltip,
Badge,
Loader,
Alert,
ScrollArea,
SegmentedControl,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { Search, ArrowLeft, ExternalLink } from 'lucide-react';
import { Modal } from '@/components/Modal';
import { useHuggingFaceSearch } from '@/hooks/useHuggingFaceSearch';
import { HUGGINGFACE_BASE_URL } from '@/constants';
import type {
HuggingFaceModelInfo,
HuggingFaceFileInfo,
HuggingFaceSortOption,
HuggingFaceSearchParams,
} from '@/types';
import { ModelsTable } from './ModelsTable';
import { FilesTable } from './FilesTable';
import { ModelCard } from './ModelCard';
interface HuggingFaceSearchModalProps {
opened: boolean;
onClose: () => void;
onSelect: (url: string) => void;
searchParams: HuggingFaceSearchParams;
}
export const HuggingFaceSearchModal = ({
opened,
onClose,
onSelect,
searchParams: initialSearchParams,
}: HuggingFaceSearchModalProps) => {
const [searchQuery, setSearchQuery] = useState(
initialSearchParams.search || ''
);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const prevOpenedRef = useRef(false);
const {
models,
files,
loading,
loadingFiles,
error,
hasMore,
selectedModel,
sortBy,
searchParams,
searchModels,
loadMoreModels,
loadModelFiles,
changeSortOrder,
getFileDownloadUrl,
reset,
} = useHuggingFaceSearch(initialSearchParams);
const handleSortChange = (value: string) => {
changeSortOrder(value as HuggingFaceSortOption, searchQuery);
};
useEffect(() => {
if (opened && !prevOpenedRef.current) {
searchModels();
}
if (!opened && prevOpenedRef.current) {
reset();
}
prevOpenedRef.current = opened;
}, [opened, searchModels, reset]);
useEffect(() => {
if (opened && debouncedQuery !== undefined) {
searchModels(debouncedQuery);
}
}, [debouncedQuery, opened, searchModels]);
const handleClose = () => {
setSearchQuery(initialSearchParams.search || '');
onClose();
};
const handleModelSelect = (model: HuggingFaceModelInfo) => {
loadModelFiles(model);
};
const handleFileSelect = (file: HuggingFaceFileInfo) => {
if (!selectedModel) return;
const url = getFileDownloadUrl(selectedModel.id, file.path);
onSelect(url);
handleClose();
};
const handleBack = () => {
reset();
searchModels(searchQuery);
};
const handleOpenExternal = () => {
if (selectedModel) {
window.electronAPI.app.openExternal(
`${HUGGINGFACE_BASE_URL}/${selectedModel.id}`
);
}
};
const getFilterBadges = () => {
const badges: string[] = [];
if (searchParams?.filter) badges.push(searchParams.filter.toUpperCase());
if (searchParams?.pipelineTag)
badges.push(searchParams.pipelineTag.replace('-', ' '));
return badges;
};
return (
<Modal
opened={opened}
onClose={handleClose}
title="Search Hugging Face Models"
size="xl"
tallContent
>
<Stack gap="md" style={{ height: '100%' }}>
{selectedModel ? (
<Group gap="xs" wrap="nowrap">
<ActionIcon variant="subtle" onClick={handleBack}>
<ArrowLeft size={18} />
</ActionIcon>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1}>
{selectedModel.name}
</Text>
<Text size="xs" c="dimmed">
{selectedModel.author}
</Text>
</Stack>
<Tooltip label="Open on Hugging Face">
<ActionIcon variant="subtle" onClick={handleOpenExternal}>
<ExternalLink size={16} />
</ActionIcon>
</Tooltip>
</Group>
) : (
<Stack gap="xs">
<TextInput
placeholder="Search models..."
leftSection={<Search size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
rightSection={loading ? <Loader size={16} /> : null}
/>
<Group gap="xs" align="center" justify="space-between">
<SegmentedControl
size="xs"
value={sortBy}
onChange={handleSortChange}
data={[
{ label: 'Trending', value: 'trendingScore' },
{ label: 'Downloads', value: 'downloads' },
{ label: 'Likes', value: 'likes' },
{ label: 'Updated', value: 'lastModified' },
]}
/>
<Group gap="xs">
{getFilterBadges().map((badge) => (
<Badge key={badge} size="sm" variant="light">
{badge}
</Badge>
))}
</Group>
</Group>
</Stack>
)}
{error && (
<Alert color="red" title="Error">
{error}
</Alert>
)}
<ScrollArea
style={{ flex: 1 }}
onScrollPositionChange={({ y }) => {
const target = scrollAreaRef.current;
if (!target) return;
const isNearBottom =
target.scrollHeight - y <= target.clientHeight + 100;
if (isNearBottom && hasMore && !loading && !selectedModel) {
loadMoreModels();
}
}}
viewportRef={scrollAreaRef}
>
{selectedModel ? (
<Stack gap="md">
<ModelCard modelId={selectedModel.id} />
<FilesTable
files={files}
loading={loadingFiles}
onSelect={handleFileSelect}
/>
</Stack>
) : (
<ModelsTable
models={models}
loading={loading}
onSelect={handleModelSelect}
sortBy={sortBy}
onSortChange={(sort) => changeSortOrder(sort, searchQuery)}
/>
)}
</ScrollArea>
{loading && !selectedModel && models.length > 0 && (
<Group justify="center">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading more...
</Text>
</Group>
)}
</Stack>
</Modal>
);
};

View file

@ -69,7 +69,11 @@ export const ImageGenerationTab = () => {
tooltip="The primary image generation model. This is the main model that will generate images." tooltip="The primary image generation model. This is the main model that will generate images."
onChange={setSdmodel} onChange={setSdmodel}
onSelectFile={() => selectFile('sdmodel', 'Select Image Model')} onSelectFile={() => selectFile('sdmodel', 'Select Image Model')}
searchUrl="https://huggingface.co/models?pipeline_tag=text-to-image&library=gguf&sort=trending" searchParams={{
pipelineTag: 'text-to-image',
filter: 'gguf',
sort: 'trendingScore',
}}
showAnalyze showAnalyze
paramType="sdmodel" paramType="sdmodel"
/> />
@ -81,7 +85,11 @@ export const ImageGenerationTab = () => {
tooltip="T5-XXL text encoder model for advanced text understanding." tooltip="T5-XXL text encoder model for advanced text understanding."
onChange={setSdt5xxl} onChange={setSdt5xxl}
onSelectFile={() => selectFile('sdt5xxl', 'Select T5XXL Model')} onSelectFile={() => selectFile('sdt5xxl', 'Select T5XXL Model')}
searchUrl="https://huggingface.co/models?search=t5-xxl&library=gguf&sort=trending" searchParams={{
search: 't5xxl',
filter: 'safetensors',
sort: 'trendingScore',
}}
paramType="sdt5xxl" paramType="sdt5xxl"
/> />
@ -92,7 +100,11 @@ export const ImageGenerationTab = () => {
tooltip="CLIP-L text encoder model for text-image understanding." tooltip="CLIP-L text encoder model for text-image understanding."
onChange={setSdclipl} onChange={setSdclipl}
onSelectFile={() => selectFile('sdclipl', 'Select CLIP-L Model')} onSelectFile={() => selectFile('sdclipl', 'Select CLIP-L Model')}
searchUrl="https://huggingface.co/models?search=clip-l&library=gguf&sort=trending" searchParams={{
search: 'clip',
filter: 'safetensors',
sort: 'trendingScore',
}}
paramType="sdclipl" paramType="sdclipl"
/> />
@ -100,10 +112,14 @@ export const ImageGenerationTab = () => {
label="Clip-G File" label="Clip-G File"
value={sdclipg} value={sdclipg}
placeholder="Select a Clip-G file or enter a direct URL" placeholder="Select a Clip-G file or enter a direct URL"
tooltip="CLIP-G text encoder model for enhanced text-image understanding." tooltip="CLIP-G text encoder model for enhanced text-image understanding, or mmproj files for vision-language models."
onChange={setSdclipg} onChange={setSdclipg}
onSelectFile={() => selectFile('sdclipg', 'Select CLIP-G Model')} onSelectFile={() => selectFile('sdclipg', 'Select CLIP-G Model')}
searchUrl="https://huggingface.co/models?search=clip-g&library=gguf&sort=trending" searchParams={{
search: 'clip',
filter: 'gguf',
sort: 'trendingScore',
}}
paramType="sdclipg" paramType="sdclipg"
/> />
@ -116,7 +132,11 @@ export const ImageGenerationTab = () => {
onSelectFile={() => onSelectFile={() =>
selectFile('sdphotomaker', 'Select PhotoMaker Model') selectFile('sdphotomaker', 'Select PhotoMaker Model')
} }
searchUrl="https://huggingface.co/models?search=photomaker&library=safetensors&sort=trending" searchParams={{
search: 'photomaker',
filter: 'safetensors',
sort: 'trendingScore',
}}
paramType="sdphotomaker" paramType="sdphotomaker"
/> />
@ -127,7 +147,11 @@ export const ImageGenerationTab = () => {
tooltip="Variational Autoencoder model for improved image quality." tooltip="Variational Autoencoder model for improved image quality."
onChange={setSdvae} onChange={setSdvae}
onSelectFile={() => selectFile('sdvae', 'Select VAE Model')} onSelectFile={() => selectFile('sdvae', 'Select VAE Model')}
searchUrl="https://huggingface.co/models?search=vae&library=safetensors&sort=trending" searchParams={{
search: 'vae',
filter: 'safetensors',
sort: 'trendingScore',
}}
paramType="sdvae" paramType="sdvae"
/> />
@ -138,7 +162,11 @@ export const ImageGenerationTab = () => {
tooltip="LoRa (Low-Rank Adaptation) file for customizing image generation. Select a .safetensors or .gguf LoRa file to be loaded. Should be unquantized." tooltip="LoRa (Low-Rank Adaptation) file for customizing image generation. Select a .safetensors or .gguf LoRa file to be loaded. Should be unquantized."
onChange={setSdlora} onChange={setSdlora}
onSelectFile={() => selectFile('sdlora', 'Select LoRA Model')} onSelectFile={() => selectFile('sdlora', 'Select LoRA Model')}
searchUrl="https://huggingface.co/models?search=lora&library=safetensors&sort=trending" searchParams={{
search: 'lora',
filter: 'safetensors',
sort: 'trendingScore',
}}
paramType="sdlora" paramType="sdlora"
/> />

View file

@ -11,9 +11,15 @@ import {
import { File, Search, Info } from 'lucide-react'; import { File, Search, Info } from 'lucide-react';
import { LabelWithTooltip } from '@/components/LabelWithTooltip'; import { LabelWithTooltip } from '@/components/LabelWithTooltip';
import { ModelAnalysisModal } from '@/components/screens/Launch/ModelAnalysisModal'; import { ModelAnalysisModal } from '@/components/screens/Launch/ModelAnalysisModal';
import { HuggingFaceSearchModal } from '@/components/screens/Launch/HuggingFaceSearchModal';
import { getInputValidationState } from '@/utils/validation'; import { getInputValidationState } from '@/utils/validation';
import { logError } from '@/utils/logger'; import { logError } from '@/utils/logger';
import type { ModelAnalysis, ModelParamType, CachedModel } from '@/types'; import type {
ModelAnalysis,
ModelParamType,
CachedModel,
HuggingFaceSearchParams,
} from '@/types';
interface ModelFileFieldProps { interface ModelFileFieldProps {
label: string; label: string;
@ -22,7 +28,7 @@ interface ModelFileFieldProps {
tooltip?: string; tooltip?: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onSelectFile: () => void; onSelectFile: () => void;
searchUrl?: string; searchParams?: HuggingFaceSearchParams;
showAnalyze?: boolean; showAnalyze?: boolean;
paramType: ModelParamType; paramType: ModelParamType;
} }
@ -34,7 +40,7 @@ export const ModelFileField = ({
tooltip, tooltip,
onChange, onChange,
onSelectFile, onSelectFile,
searchUrl, searchParams,
showAnalyze = false, showAnalyze = false,
paramType, paramType,
}: ModelFileFieldProps) => { }: ModelFileFieldProps) => {
@ -46,6 +52,7 @@ export const ModelFileField = ({
const [analysisLoading, setAnalysisLoading] = useState(false); const [analysisLoading, setAnalysisLoading] = useState(false);
const [analysisError, setAnalysisError] = useState<string>(); const [analysisError, setAnalysisError] = useState<string>();
const [cachedModels, setCachedModels] = useState<CachedModel[]>([]); const [cachedModels, setCachedModels] = useState<CachedModel[]>([]);
const [searchModalOpened, setSearchModalOpened] = useState(false);
const combobox = useCombobox(); const combobox = useCombobox();
useEffect(() => { useEffect(() => {
@ -141,10 +148,10 @@ export const ModelFileField = ({
> >
Browse Browse
</Button> </Button>
{searchUrl && ( {searchParams && (
<Tooltip label="Search Hugging Face"> <Tooltip label="Search Hugging Face">
<ActionIcon <ActionIcon
onClick={() => window.electronAPI.app.openExternal(searchUrl)} onClick={() => setSearchModalOpened(true)}
variant="outline" variant="outline"
size="lg" size="lg"
> >
@ -182,6 +189,15 @@ export const ModelFileField = ({
loading={analysisLoading} loading={analysisLoading}
error={analysisError} error={analysisError}
/> />
{searchParams && (
<HuggingFaceSearchModal
opened={searchModalOpened}
onClose={() => setSearchModalOpened(false)}
onSelect={onChange}
searchParams={searchParams}
/>
)}
</div> </div>
); );
}; };

View file

@ -67,118 +67,105 @@ export const SettingsModal = ({
} }
size="xl" size="xl"
showCloseButton showCloseButton
tallContent
> >
<div <Tabs
style={{ value={effectiveActiveTab}
height: '66vh', onChange={(value) => value && setActiveTab(value)}
padding: 0, orientation="vertical"
display: 'flex', variant="pills"
flexDirection: 'column', styles={{
position: 'relative', root: {
flex: 1,
minHeight: 0,
},
panel: {
height: '100%',
overflow: 'auto',
paddingLeft: '1.5rem',
paddingRight: '1.5rem',
},
tabLabel: {
textAlign: 'left',
justifyContent: 'flex-start',
},
}} }}
> >
<Tabs <Tabs.List>
value={effectiveActiveTab} <Tabs.Tab
onChange={(value) => value && setActiveTab(value)} value="general"
orientation="vertical" leftSection={
variant="pills" <SlidersHorizontal style={{ width: rem(16), height: rem(16) }} />
styles={{ }
root: { >
flex: 1, General
minHeight: 0, </Tabs.Tab>
},
panel: {
height: '100%',
overflow: 'auto',
paddingLeft: '1.5rem',
paddingRight: '1.5rem',
},
tabLabel: {
textAlign: 'left',
justifyContent: 'flex-start',
},
}}
>
<Tabs.List>
<Tabs.Tab
value="general"
leftSection={
<SlidersHorizontal
style={{ width: rem(16), height: rem(16) }}
/>
}
>
General
</Tabs.Tab>
{showBackendsTab && (
<Tabs.Tab
value="backends"
leftSection={
<GitBranch style={{ width: rem(16), height: rem(16) }} />
}
>
Backends
</Tabs.Tab>
)}
<Tabs.Tab
value="appearance"
leftSection={
<Palette style={{ width: rem(16), height: rem(16) }} />
}
>
Appearance
</Tabs.Tab>
<Tabs.Tab
value="system"
leftSection={
<Monitor style={{ width: rem(16), height: rem(16) }} />
}
>
System
</Tabs.Tab>
<Tabs.Tab
value="troubleshooting"
leftSection={
<Wrench style={{ width: rem(16), height: rem(16) }} />
}
>
Troubleshooting
</Tabs.Tab>
<Tabs.Tab
value="about"
leftSection={<Info style={{ width: rem(16), height: rem(16) }} />}
>
About
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<GeneralTab />
</Tabs.Panel>
{showBackendsTab && ( {showBackendsTab && (
<Tabs.Panel value="backends"> <Tabs.Tab
<BackendsTab /> value="backends"
</Tabs.Panel> leftSection={
<GitBranch style={{ width: rem(16), height: rem(16) }} />
}
>
Backends
</Tabs.Tab>
)} )}
<Tabs.Tab
value="appearance"
leftSection={
<Palette style={{ width: rem(16), height: rem(16) }} />
}
>
Appearance
</Tabs.Tab>
<Tabs.Tab
value="system"
leftSection={
<Monitor style={{ width: rem(16), height: rem(16) }} />
}
>
System
</Tabs.Tab>
<Tabs.Tab
value="troubleshooting"
leftSection={<Wrench style={{ width: rem(16), height: rem(16) }} />}
>
Troubleshooting
</Tabs.Tab>
<Tabs.Tab
value="about"
leftSection={<Info style={{ width: rem(16), height: rem(16) }} />}
>
About
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="appearance"> <Tabs.Panel value="general">
<AppearanceTab isOnInterfaceScreen={isOnInterfaceScreen} /> <GeneralTab />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="system"> {showBackendsTab && (
<SystemTab /> <Tabs.Panel value="backends">
<BackendsTab />
</Tabs.Panel> </Tabs.Panel>
)}
<Tabs.Panel value="troubleshooting"> <Tabs.Panel value="appearance">
<TroubleshootingTab /> <AppearanceTab isOnInterfaceScreen={isOnInterfaceScreen} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="about"> <Tabs.Panel value="system">
<AboutTab /> <SystemTab />
</Tabs.Panel> </Tabs.Panel>
</Tabs>
</div> <Tabs.Panel value="troubleshooting">
<TroubleshootingTab />
</Tabs.Panel>
<Tabs.Panel value="about">
<AboutTab />
</Tabs.Panel>
</Tabs>
</Modal> </Modal>
); );
}; };

View file

@ -1,3 +1,5 @@
import { HUGGINGFACE_BASE_URL } from '@/constants';
export interface ImageModelPreset { export interface ImageModelPreset {
readonly name: string; readonly name: string;
readonly sdmodel: string; readonly sdmodel: string;
@ -11,53 +13,38 @@ export interface ImageModelPreset {
export const IMAGE_MODEL_PRESETS: readonly ImageModelPreset[] = [ export const IMAGE_MODEL_PRESETS: readonly ImageModelPreset[] = [
{ {
name: 'Z-Image', name: 'Z-Image',
sdmodel: sdmodel: `${HUGGINGFACE_BASE_URL}/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q4_0.gguf`,
'https://huggingface.co/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q4_0.gguf',
sdt5xxl: '', sdt5xxl: '',
sdclipl: sdclipl: `${HUGGINGFACE_BASE_URL}/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen3-4B-Instruct-2507-Q4_K_S.gguf`,
'https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen3-4B-Instruct-2507-Q4_K_S.gguf',
sdclipg: '', sdclipg: '',
sdphotomaker: '', sdphotomaker: '',
sdvae: sdvae: `${HUGGINGFACE_BASE_URL}/koboldcpp/GGUFDumps/resolve/main/flux1vae.safetensors`,
'https://huggingface.co/koboldcpp/GGUFDumps/resolve/main/flux1vae.safetensors',
}, },
{ {
name: 'FLUX.1', name: 'FLUX.1',
sdmodel: sdmodel: `${HUGGINGFACE_BASE_URL}/bullerwins/FLUX.1-Kontext-dev-GGUF/resolve/main/flux1-kontext-dev-Q4_K_S.gguf`,
'https://huggingface.co/bullerwins/FLUX.1-Kontext-dev-GGUF/resolve/main/flux1-kontext-dev-Q4_K_S.gguf', sdt5xxl: `${HUGGINGFACE_BASE_URL}/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors`,
sdt5xxl: sdclipl: `${HUGGINGFACE_BASE_URL}/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors`,
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors',
sdclipl:
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors',
sdclipg: '', sdclipg: '',
sdphotomaker: '', sdphotomaker: '',
sdvae: sdvae: `${HUGGINGFACE_BASE_URL}/camenduru/FLUX.1-dev/resolve/main/ae.safetensors`,
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/ae.safetensors',
}, },
{ {
name: 'Chroma', name: 'Chroma',
sdmodel: sdmodel: `${HUGGINGFACE_BASE_URL}/silveroxides/Chroma-GGUF/resolve/main/chroma-unlocked-v45/chroma-unlocked-v45-Q4_0.gguf`,
'https://huggingface.co/silveroxides/Chroma-GGUF/resolve/main/chroma-unlocked-v45/chroma-unlocked-v45-Q4_0.gguf', sdt5xxl: `${HUGGINGFACE_BASE_URL}/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors`,
sdt5xxl: sdclipl: `${HUGGINGFACE_BASE_URL}/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors`,
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/t5xxl_fp8_e4m3fn.safetensors',
sdclipl:
'https://huggingface.co/camenduru/FLUX.1-dev/resolve/main/clip_l.safetensors',
sdclipg: '', sdclipg: '',
sdphotomaker: '', sdphotomaker: '',
sdvae: sdvae: `${HUGGINGFACE_BASE_URL}/lodestones/Chroma/resolve/main/ae.safetensors`,
'https://huggingface.co/lodestones/Chroma/resolve/main/ae.safetensors',
}, },
{ {
name: 'Qwen Image Edit 2509', name: 'Qwen Image Edit 2509',
sdmodel: sdmodel: `${HUGGINGFACE_BASE_URL}/QuantStack/Qwen-Image-Edit-2509-GGUF/resolve/main/Qwen-Image-Edit-2509-Q4_K_S.gguf`,
'https://huggingface.co/QuantStack/Qwen-Image-Edit-2509-GGUF/resolve/main/Qwen-Image-Edit-2509-Q4_K_S.gguf',
sdt5xxl: '', sdt5xxl: '',
sdclipl: sdclipl: `${HUGGINGFACE_BASE_URL}/mradermacher/Qwen2.5-VL-7B-Instruct-GGUF/resolve/main/Qwen2.5-VL-7B-Instruct.Q4_K_S.gguf`,
'https://huggingface.co/mradermacher/Qwen2.5-VL-7B-Instruct-GGUF/resolve/main/Qwen2.5-VL-7B-Instruct.Q4_K_S.gguf', sdclipg: `${HUGGINGFACE_BASE_URL}/mradermacher/Qwen2.5-VL-7B-Instruct-GGUF/resolve/main/Qwen2.5-VL-7B-Instruct.mmproj-Q8_0.gguf`,
sdclipg:
'https://huggingface.co/mradermacher/Qwen2.5-VL-7B-Instruct-GGUF/resolve/main/Qwen2.5-VL-7B-Instruct.mmproj-Q8_0.gguf',
sdphotomaker: '', sdphotomaker: '',
sdvae: sdvae: `${HUGGINGFACE_BASE_URL}/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors`,
'https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors',
}, },
] as const; ] as const;

View file

@ -6,6 +6,8 @@ export const TITLEBAR_HEIGHT = '2.5rem';
export const STATUSBAR_HEIGHT = '1.5rem'; export const STATUSBAR_HEIGHT = '1.5rem';
export const HUGGINGFACE_BASE_URL = 'https://huggingface.co';
export const SERVER_READY_SIGNALS = { export const SERVER_READY_SIGNALS = {
KOBOLDCPP: 'Please connect to custom endpoint at', KOBOLDCPP: 'Please connect to custom endpoint at',
SILLYTAVERN: 'SillyTavern is listening on', SILLYTAVERN: 'SillyTavern is listening on',
@ -14,8 +16,7 @@ export const SERVER_READY_SIGNALS = {
export const DEFAULT_CONTEXT_SIZE = 4096; export const DEFAULT_CONTEXT_SIZE = 4096;
export const DEFAULT_MODEL_URL = export const DEFAULT_MODEL_URL = `${HUGGINGFACE_BASE_URL}/MaziyarPanahi/gemma-3-4b-it-GGUF/resolve/main/gemma-3-4b-it.Q8_0.gguf`;
'https://huggingface.co/MaziyarPanahi/gemma-3-4b-it-GGUF/resolve/main/gemma-3-4b-it.Q8_0.gguf';
export const DEFAULT_AUTO_GPU_LAYERS = true; export const DEFAULT_AUTO_GPU_LAYERS = true;

View file

@ -0,0 +1,356 @@
import { useState, useCallback, useRef } from 'react';
import { logError } from '@/utils/logger';
import type {
HuggingFaceModelInfo,
HuggingFaceFileInfo,
HuggingFaceSearchParams,
HuggingFaceSortOption,
} from '@/types';
import { HUGGINGFACE_BASE_URL } from '@/constants';
const MODELS_PER_PAGE = 20;
const HF_API_BASE = `${HUGGINGFACE_BASE_URL}/api`;
interface HFApiModel {
id: string;
downloads: number;
likes: number;
lastModified: string;
gated: boolean | 'auto' | 'manual';
tags: string[];
}
interface HFApiFile {
type: 'file' | 'directory';
path: string;
size: number;
lfs?: {
size: number;
};
}
interface HFApiBaseModel {
safetensors?: {
parameters?: {
BF16?: number;
F32?: number;
total?: number;
};
total?: number;
};
}
const isValidModelId = (id: string) => id.includes('/');
const extractParamSize = (name: string) => {
const match = name.match(/(\d+(?:\.\d+)?[BM])(?!\d)/i);
return match ? match[1].toUpperCase() : undefined;
};
const extractBaseModelId = (tags: string[]) => {
const baseModelTag = tags.find(
(tag) => tag.startsWith('base_model:') && !tag.includes('quantized')
);
return baseModelTag?.replace('base_model:', '');
};
const formatParamCount = (paramCount: number) => {
if (paramCount >= 1_000_000_000) {
const billions = paramCount / 1_000_000_000;
return billions % 1 === 0 ? `${billions}B` : `${billions.toFixed(1)}B`;
}
if (paramCount >= 1_000_000) {
const millions = paramCount / 1_000_000;
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
}
return `${paramCount}`;
};
const fetchBaseModelParams = async (baseModelId: string) => {
try {
const response = await fetch(`${HF_API_BASE}/models/${baseModelId}`);
if (!response.ok) return undefined;
const data: HFApiBaseModel = await response.json();
return data.safetensors?.total;
} catch {
return undefined;
}
};
export const useHuggingFaceSearch = (
initialParams: HuggingFaceSearchParams
) => {
const [models, setModels] = useState<HuggingFaceModelInfo[]>([]);
const [files, setFiles] = useState<HuggingFaceFileInfo[]>([]);
const [loading, setLoading] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false);
const [error, setError] = useState<string>();
const [hasMore, setHasMore] = useState(true);
const [selectedModel, setSelectedModel] = useState<HuggingFaceModelInfo>();
const [sortBy, setSortBy] = useState<HuggingFaceSortOption>(
initialParams.sort
);
const [searchParams] = useState<HuggingFaceSearchParams>(initialParams);
const pageRef = useRef(0);
const currentQueryRef = useRef<string | undefined>(undefined);
const searchModels = useCallback(
async (
query?: string,
reset = true,
sort: HuggingFaceSortOption = sortBy
) => {
if (reset) {
setModels([]);
setHasMore(true);
setError(undefined);
setSelectedModel(undefined);
setFiles([]);
pageRef.current = 0;
}
setLoading(true);
setSortBy(sort);
currentQueryRef.current = query;
try {
const searchQuery =
query !== undefined ? query.trim() || undefined : searchParams.search;
const params = new URLSearchParams();
if (searchQuery) params.set('search', searchQuery);
if (searchParams.pipelineTag)
params.set('pipeline_tag', searchParams.pipelineTag);
if (searchParams.filter) params.set('filter', searchParams.filter);
params.set('sort', sort);
params.set('limit', String(MODELS_PER_PAGE));
params.set('full', 'false');
const response = await fetch(
`${HF_API_BASE}/models?${params.toString()}`
);
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`);
}
const data: HFApiModel[] = await response.json();
const validModels = data.filter((model) => isValidModelId(model.id));
const results = await Promise.all(
validModels.map(async (model) => {
const [author, ...nameParts] = model.id.split('/');
const name = nameParts.join('/');
let paramSize = extractParamSize(name);
if (!paramSize) {
const baseModelId = extractBaseModelId(model.tags);
if (baseModelId) {
const paramCount = await fetchBaseModelParams(baseModelId);
if (paramCount) {
paramSize = formatParamCount(paramCount);
}
}
}
return {
id: model.id,
name,
author,
downloads: model.downloads ?? 0,
likes: model.likes ?? 0,
updatedAt: new Date(model.lastModified),
gated: model.gated ?? false,
paramSize,
};
})
);
setModels(results);
setHasMore(data.length === MODELS_PER_PAGE);
pageRef.current = 1;
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to search models';
setError(message);
logError('HuggingFace search failed:', err as Error);
} finally {
setLoading(false);
}
},
[searchParams, sortBy]
);
const changeSortOrder = useCallback(
(sort: HuggingFaceSortOption, query?: string) => {
searchModels(query, true, sort);
},
[searchModels]
);
const loadMoreModels = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const searchQuery =
currentQueryRef.current !== undefined
? currentQueryRef.current.trim() || undefined
: searchParams.search;
const params = new URLSearchParams();
if (searchQuery) params.set('search', searchQuery);
if (searchParams.pipelineTag)
params.set('pipeline_tag', searchParams.pipelineTag);
if (searchParams.filter) params.set('filter', searchParams.filter);
params.set('sort', sortBy);
params.set('limit', String(MODELS_PER_PAGE));
params.set('skip', String(pageRef.current * MODELS_PER_PAGE));
params.set('full', 'false');
const response = await fetch(
`${HF_API_BASE}/models?${params.toString()}`
);
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`);
}
const data: HFApiModel[] = await response.json();
const validModels = data.filter((model) => isValidModelId(model.id));
const results = await Promise.all(
validModels.map(async (model) => {
const [author, ...nameParts] = model.id.split('/');
const name = nameParts.join('/');
let paramSize = extractParamSize(name);
if (!paramSize) {
const baseModelId = extractBaseModelId(model.tags);
if (baseModelId) {
const paramCount = await fetchBaseModelParams(baseModelId);
if (paramCount) {
paramSize = formatParamCount(paramCount);
}
}
}
return {
id: model.id,
name,
author,
downloads: model.downloads ?? 0,
likes: model.likes ?? 0,
updatedAt: new Date(model.lastModified),
gated: model.gated ?? false,
paramSize,
};
})
);
setModels((prev) => [...prev, ...results]);
setHasMore(validModels.length === MODELS_PER_PAGE);
pageRef.current += 1;
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load more models';
setError(message);
logError('HuggingFace load more failed:', err as Error);
} finally {
setLoading(false);
}
}, [loading, hasMore, searchParams, sortBy]);
const loadModelFiles = useCallback(
async (model: HuggingFaceModelInfo) => {
setSelectedModel(model);
setFiles([]);
setLoadingFiles(true);
setError(undefined);
try {
const filter = searchParams?.filter;
const extensions = getExtensionsForLibrary(filter);
const response = await fetch(
`${HF_API_BASE}/models/${model.id}/tree/main?recursive=true`
);
if (!response.ok) {
throw new Error(`Failed to fetch files: ${response.statusText}`);
}
const items: HFApiFile[] = await response.json();
const fileResults: HuggingFaceFileInfo[] = [];
for (const file of items) {
if (
file.type === 'file' &&
extensions.some((ext) => file.path.endsWith(ext))
) {
fileResults.push({
path: file.path,
size: file.lfs?.size ?? file.size,
});
}
}
fileResults.sort((a, b) => b.size - a.size);
setFiles(fileResults);
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load model files';
setError(message);
logError('HuggingFace list files failed:', err as Error);
} finally {
setLoadingFiles(false);
}
},
[searchParams?.filter]
);
const getFileDownloadUrl = useCallback(
(modelId: string, filePath: string) =>
`${HUGGINGFACE_BASE_URL}/${modelId}/resolve/main/${filePath}`,
[]
);
const reset = useCallback(() => {
setModels([]);
setFiles([]);
setSelectedModel(undefined);
setError(undefined);
setHasMore(true);
pageRef.current = 0;
}, []);
return {
models,
files,
loading,
loadingFiles,
error,
hasMore,
selectedModel,
sortBy,
searchParams,
searchModels,
loadMoreModels,
loadModelFiles,
changeSortOrder,
getFileDownloadUrl,
reset,
};
};
const getExtensionsForLibrary = (filter?: string) => {
switch (filter) {
case 'gguf':
return ['.gguf'];
case 'safetensors':
return ['.safetensors'];
default:
return ['.gguf', '.safetensors', '.bin', '.pt', '.pth'];
}
};

View file

@ -3,7 +3,7 @@ import { readdir, stat, unlink } from 'fs/promises';
import { dialog } from 'electron'; import { dialog } from 'electron';
import { getInstallDir, setInstallDir } from '../config'; import { getInstallDir, setInstallDir } from '../config';
import { logError } from '@/utils/node/logging'; import { logError, safeExecute, tryExecute } from '@/utils/node/logging';
import { pathExists, readJsonFile, writeJsonFile } from '@/utils/node/fs'; import { pathExists, readJsonFile, writeJsonFile } from '@/utils/node/fs';
import { getMainWindow, sendToRenderer } from '../window'; import { getMainWindow, sendToRenderer } from '../window';
import { PRODUCT_NAME } from '@/constants'; import { PRODUCT_NAME } from '@/constants';
@ -43,48 +43,37 @@ export async function getConfigFiles() {
} }
export async function parseConfigFile(filePath: string) { export async function parseConfigFile(filePath: string) {
try { return safeExecute(async () => {
if (!(await pathExists(filePath))) { if (!(await pathExists(filePath))) {
return null; return null;
} }
const config = await readJsonFile(filePath); const config = await readJsonFile(filePath);
return config as KoboldConfig; return config as KoboldConfig;
} catch (error) { }, 'Error parsing config file');
logError('Error parsing config file:', error as Error);
return null;
}
} }
export async function saveConfigFile( export async function saveConfigFile(
configFileName: string, configFileName: string,
configData: KoboldConfig configData: KoboldConfig
) { ) {
try { return tryExecute(async () => {
const installDir = getInstallDir(); const installDir = getInstallDir();
const configPath = join(installDir, configFileName); const configPath = join(installDir, configFileName);
await writeJsonFile(configPath, configData); await writeJsonFile(configPath, configData);
return true; }, 'Error saving config file');
} catch (error) {
logError('Error saving config file:', error as Error);
return false;
}
} }
export async function deleteConfigFile(configFileName: string) { export async function deleteConfigFile(configFileName: string) {
try { return tryExecute(async () => {
const installDir = getInstallDir(); const installDir = getInstallDir();
const configPath = join(installDir, configFileName); const configPath = join(installDir, configFileName);
await unlink(configPath); await unlink(configPath);
return true; }, 'Error deleting config file');
} catch (error) {
logError('Error deleting config file:', error as Error);
return false;
}
} }
export async function selectModelFile(title = 'Select Model File') { export async function selectModelFile(title = 'Select Model File') {
try { return safeExecute(async () => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (!mainWindow) { if (!mainWindow) {
return null; return null;
@ -107,10 +96,7 @@ export async function selectModelFile(title = 'Select Model File') {
} }
return result.filePaths[0]; return result.filePaths[0];
} catch (error) { }, 'Error selecting model file');
logError('Error selecting model file:', error as Error);
return null;
}
} }
export async function selectInstallDirectory() { export async function selectInstallDirectory() {

29
src/types/index.d.ts vendored
View file

@ -125,3 +125,32 @@ export interface ModelAnalysis {
vramPerLayer?: string; vramPerLayer?: string;
}; };
} }
export interface HuggingFaceModelInfo {
id: string;
name: string;
author: string;
downloads: number;
likes: number;
updatedAt: Date;
gated: boolean | 'auto' | 'manual';
paramSize?: string;
}
export interface HuggingFaceFileInfo {
path: string;
size: number;
}
export interface HuggingFaceSearchParams {
search?: string;
pipelineTag?: string;
filter?: string;
sort: HuggingFaceSortOption;
}
export type HuggingFaceSortOption =
| 'trendingScore'
| 'downloads'
| 'likes'
| 'lastModified';

View file

@ -48,3 +48,22 @@ export const formatDeviceName = (deviceName: string) =>
export const stripFileExtension = (filename: string) => export const stripFileExtension = (filename: string) =>
filename.replace(/\.[^/.]+$/, ''); filename.replace(/\.[^/.]+$/, '');
export const formatDownloads = (count: number) => {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return count.toString();
};
export const formatDate = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 7) return `${days}d ago`;
if (days < 30) return `${Math.floor(days / 7)}w ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
};

1174
yarn.lock

File diff suppressed because it is too large Load diff