From 273d1b690f61067a5439784d7248f6b2314d98f8 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 24 Oct 2025 23:31:13 -0700 Subject: [PATCH] allow analyzing local .gguf model files --- package.json | 9 +- .../screens/Launch/GeneralTab/index.tsx | 2 +- .../screens/Launch/ImageGenerationTab.tsx | 2 +- .../screens/Launch/ModelAnalysisModal.tsx | 135 ++++++++++++++++++ .../screens/Launch/ModelFileField.tsx | 81 +++++++++-- src/main/ipc.ts | 5 + src/main/modules/koboldcpp/analyze.ts | 110 ++++++++++++++ src/preload/index.ts | 2 + src/types/electron.d.ts | 8 +- src/types/index.d.ts | 21 +++ src/utils/validation.ts | 8 +- yarn.lock | 77 ++++++---- 12 files changed, 408 insertions(+), 52 deletions(-) create mode 100644 src/components/screens/Launch/ModelAnalysisModal.tsx create mode 100644 src/main/modules/koboldcpp/analyze.ts diff --git a/package.json b/package.json index 2d07cf3..0931edf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gerbil", "productName": "Gerbil", - "version": "1.7.3", + "version": "1.8.0", "description": "Run Large Language Models locally", "main": "out/main/index.js", "homepage": "./", @@ -45,7 +45,7 @@ "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", - "@vitejs/plugin-react": "^5.0.4", + "@vitejs/plugin-react": "^5.1.0", "cross-env": "^10.1.0", "electron": "^38.4.0", "electron-builder": "^26.0.12", @@ -55,7 +55,7 @@ "eslint-plugin-no-comments": "^1.1.10", "eslint-plugin-promise": "^7.2.1", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.0", + "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-sonarjs": "^3.0.5", "globals": "^16.4.0", "jiti": "^2.6.1", @@ -69,12 +69,13 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.6", "@fontsource/inter": "^5.2.8", + "@huggingface/gguf": "^0.3.2", "@mantine/core": "^8.3.5", "@mantine/hooks": "^8.3.5", "@uiw/react-codemirror": "^4.25.2", "electron-updater": "^6.6.2", "execa": "^9.6.0", - "lucide-react": "^0.546.0", + "lucide-react": "^0.548.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", diff --git a/src/components/screens/Launch/GeneralTab/index.tsx b/src/components/screens/Launch/GeneralTab/index.tsx index f77a852..58e9aa7 100644 --- a/src/components/screens/Launch/GeneralTab/index.tsx +++ b/src/components/screens/Launch/GeneralTab/index.tsx @@ -42,8 +42,8 @@ export const GeneralTab = ({ configLoaded = true }: GeneralTabProps) => { tooltip="Select a GGUF text generation model file for chat and completion tasks." onChange={handleModelChange} onSelectFile={handleSelectModelFile} - showSearchHF searchUrl="https://huggingface.co/models?pipeline_tag=text-generation&library=gguf&sort=trending" + showAnalyze />
diff --git a/src/components/screens/Launch/ImageGenerationTab.tsx b/src/components/screens/Launch/ImageGenerationTab.tsx index 43426c3..6057431 100644 --- a/src/components/screens/Launch/ImageGenerationTab.tsx +++ b/src/components/screens/Launch/ImageGenerationTab.tsx @@ -75,8 +75,8 @@ export const ImageGenerationTab = () => { tooltip="The primary image generation model. This is the main model that will generate images." onChange={handleSdmodelChange} onSelectFile={handleSelectSdmodelFile} - showSearchHF searchUrl="https://huggingface.co/models?pipeline_tag=text-to-image&library=gguf&sort=trending" + showAnalyze /> void; + analysis: ModelAnalysis | null; + loading?: boolean; + error?: string; +} + +interface InfoRowProps { + label: string; + value?: string | number; +} + +const InfoRow = ({ label, value }: InfoRowProps) => { + if (!value) return null; + + return ( + + + {label}: + + + {value} + + + ); +}; + +export const ModelAnalysisModal = ({ + opened, + onClose, + analysis, + loading = false, + error, +}: ModelAnalysisModalProps) => ( + + + Model Analysis + + } + size="lg" + showCloseButton + > + {loading && ( + + Analyzing model... + + )} + + {error && ( + + {error} + + )} + + {analysis && ( + +
+ + General Information + + + + {analysis.general.name && ( + + )} + {analysis.general.parameterCount && ( + + )} + + {analysis.architecture.layers && ( + + )} + {analysis.architecture.expertCount && ( + + )} + +
+ + {analysis.context.maxContextLength && ( + <> + +
+ + Context + + + + +
+ + )} + + + +
+ + Memory Estimates + + + + + {analysis.estimates.vramPerLayer && ( + + )} + +
+
+ )} +
+); diff --git a/src/components/screens/Launch/ModelFileField.tsx b/src/components/screens/Launch/ModelFileField.tsx index fe11ac0..03becb5 100644 --- a/src/components/screens/Launch/ModelFileField.tsx +++ b/src/components/screens/Launch/ModelFileField.tsx @@ -1,7 +1,11 @@ -import { Group, TextInput, Button } from '@mantine/core'; -import { File, Search } from 'lucide-react'; +import { Group, TextInput, ActionIcon, Tooltip, Button } from '@mantine/core'; +import { useState } from 'react'; +import { File, Search, Info } from 'lucide-react'; import { LabelWithTooltip } from '@/components/LabelWithTooltip'; +import { ModelAnalysisModal } from '@/components/screens/Launch/ModelAnalysisModal'; import { getInputValidationState } from '@/utils/validation'; +import { logError } from '@/utils/logger'; +import type { ModelAnalysis } from '@/types'; interface ModelFileFieldProps { label: string; @@ -10,8 +14,8 @@ interface ModelFileFieldProps { tooltip?: string; onChange: (value: string) => void; onSelectFile: () => void; - showSearchHF?: boolean; searchUrl?: string; + showAnalyze?: boolean; } export const ModelFileField = ({ @@ -21,10 +25,16 @@ export const ModelFileField = ({ tooltip, onChange, onSelectFile, - showSearchHF = false, - searchUrl = 'https://huggingface.co/models?pipeline_tag=text-to-image&library=gguf&sort=trending', + searchUrl, + showAnalyze = false, }: ModelFileFieldProps) => { const validationState = getInputValidationState(value); + const [analysisModalOpened, setAnalysisModalOpened] = useState(false); + const [modelAnalysis, setModelAnalysis] = useState( + null + ); + const [analysisLoading, setAnalysisLoading] = useState(false); + const [analysisError, setAnalysisError] = useState(); const getHelperText = () => { if (!value.trim()) return undefined; @@ -36,6 +46,29 @@ export const ModelFileField = ({ return undefined; }; + const isLocalFile = value.trim() && validationState === 'local'; + + const handleAnalyzeModel = async () => { + if (!value.trim()) return; + + setAnalysisModalOpened(true); + setAnalysisLoading(true); + setAnalysisError(undefined); + setModelAnalysis(null); + + try { + const analysis = await window.electronAPI.kobold.analyzeModel(value); + setModelAnalysis(analysis); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to analyze model'; + setAnalysisError(errorMessage); + logError('Failed to analyze model:', error as Error); + } finally { + setAnalysisLoading(false); + } + }; + return (
@@ -55,16 +88,38 @@ export const ModelFileField = ({ > Browse - {showSearchHF && ( - + {searchUrl && ( + + window.electronAPI.app.openExternal(searchUrl)} + variant="outline" + size="lg" + > + + + + )} + {showAnalyze && isLocalFile && ( + + + + + )} + + setAnalysisModalOpened(false)} + analysis={modelAnalysis} + loading={analysisLoading} + error={analysisError} + />
); }; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index eb3e1dd..7d747a9 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -21,6 +21,7 @@ import { selectModelFile, selectInstallDirectory, } from '@/main/modules/koboldcpp/config'; +import { analyzeGGUFModel } from '@/main/modules/koboldcpp/analyze'; import { get as getConfig, set as setConfig, @@ -156,6 +157,10 @@ export function setupIPCHandlers() { selectModelFile(title) ); + ipcMain.handle('kobold:analyzeModel', async (_, filePath: string) => + analyzeGGUFModel(filePath) + ); + ipcMain.handle('config:get', (_, key) => getConfig(key)); ipcMain.on('config:set', (_, key, value) => setConfig(key, value)); diff --git a/src/main/modules/koboldcpp/analyze.ts b/src/main/modules/koboldcpp/analyze.ts new file mode 100644 index 0000000..789a372 --- /dev/null +++ b/src/main/modules/koboldcpp/analyze.ts @@ -0,0 +1,110 @@ +import { gguf } from '@huggingface/gguf'; +import { stat } from 'fs/promises'; +import { logError } from '@/utils/node/logging'; +import { formatBytes } from '@/utils/format'; + +function estimateMemoryRequirements(fileSize: number) { + const vramOverhead = 1.1; + const fullGpuVram = fileSize * vramOverhead; + + const ramOverhead = 1.2; + const systemRam = fileSize * ramOverhead; + + return { + fullGpuVram: formatBytes(fullGpuVram), + systemRam: formatBytes(systemRam), + }; +} + +function estimateVramPerLayer(fileSize: number, layers?: number) { + if (!layers || layers === 0) return undefined; + const perLayer = fileSize / layers; + return formatBytes(perLayer); +} + +function formatParameterCount(params?: number) { + if (!params) return undefined; + if (params >= 1e9) return `${(params / 1e9).toFixed(1)}B`; + if (params >= 1e6) return `${(params / 1e6).toFixed(1)}M`; + if (params >= 1e3) return `${(params / 1e3).toFixed(1)}K`; + return params.toString(); +} + +function formatContextLength(length?: number) { + if (!length) return undefined; + if (length >= 1e6) return `${(length / 1e6).toFixed(1)}M`; + if (length >= 1e3) return `${(length / 1e3).toFixed(0)}K`; + return length.toString(); +} + +function getMetadataValue(metadata: Record, key: string) { + return metadata[key]; +} + +export async function analyzeGGUFModel(filePath: string) { + try { + const stats = await stat(filePath); + const fileSize = stats.size; + + const { metadata } = await gguf(filePath, { + allowLocalFile: true, + }); + + const metadataRecord = metadata as Record; + + const architecture = getMetadataValue( + metadataRecord, + 'general.architecture' + ) as string; + const name = getMetadataValue(metadataRecord, 'general.name') as + | string + | undefined; + const paramCount = getMetadataValue( + metadataRecord, + 'general.parameter_count' + ) as number | undefined; + + const contextLength = getMetadataValue( + metadataRecord, + `${architecture}.context_length` + ) as number | undefined; + + const blockCount = getMetadataValue( + metadataRecord, + `${architecture}.block_count` + ) as number | undefined; + const expertCount = getMetadataValue( + metadataRecord, + `${architecture}.expert_count` + ) as number | undefined; + + const memoryEstimates = estimateMemoryRequirements(fileSize); + const vramPerLayer = estimateVramPerLayer(fileSize, blockCount); + + return { + general: { + architecture, + name, + fileSize: formatBytes(fileSize), + parameterCount: formatParameterCount(paramCount), + }, + context: { + maxContextLength: formatContextLength(contextLength), + }, + architecture: { + layers: blockCount, + expertCount, + }, + estimates: { + fullGpuVram: memoryEstimates.fullGpuVram, + systemRam: memoryEstimates.systemRam, + vramPerLayer, + }, + }; + } catch (error) { + logError('Error analyzing GGUF model:', error as Error); + throw new Error( + `Failed to analyze model: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 4fcde90..b7dbda8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -51,6 +51,8 @@ const koboldAPI: KoboldAPI = { ipcRenderer.invoke('kobold:parseConfigFile', filePath), selectModelFile: (title) => ipcRenderer.invoke('kobold:selectModelFile', title), + analyzeModel: (filePath) => + ipcRenderer.invoke('kobold:analyzeModel', filePath), stopKoboldCpp: () => ipcRenderer.invoke('kobold:stopKoboldCpp'), onDownloadProgress: (callback) => { const handler = (_: IpcRendererEvent, progress: number) => diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ec6b2ea..b99bf17 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -5,7 +5,12 @@ import type { GPUMemoryInfo, SystemMemoryInfo, } from '@/types/hardware'; -import type { BackendOption, BackendSupport, Screen } from '@/types'; +import type { + BackendOption, + BackendSupport, + Screen, + ModelAnalysis, +} from '@/types'; import type { MantineColorScheme } from '@mantine/core'; import type { CpuMetrics, @@ -142,6 +147,7 @@ export interface KoboldAPI { setSelectedConfig: (configName: string) => Promise; parseConfigFile: (filePath: string) => Promise; selectModelFile: (title?: string) => Promise; + analyzeModel: (filePath: string) => Promise; stopKoboldCpp: () => void; onDownloadProgress: (callback: (progress: number) => void) => () => void; onInstallDirChanged: (callback: (newPath: string) => void) => () => void; diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 0a1621f..8edeb61 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -86,3 +86,24 @@ export interface BackendSupport { failsafe: boolean; cuda: boolean; } + +export interface ModelAnalysis { + general: { + architecture: string; + name?: string; + fileSize: string; + parameterCount?: string; + }; + context: { + maxContextLength?: string; + }; + architecture: { + layers?: number; + expertCount?: number; + }; + estimates: { + fullGpuVram: string; + systemRam: string; + vramPerLayer?: string; + }; +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 660c52d..6c96775 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -21,9 +21,11 @@ const isValidFilePath = (path: string) => { export const getInputValidationState = (path: string) => { if (!path.trim()) return 'neutral'; - if (isValidUrl(path) || isValidFilePath(path)) { - return 'valid'; - } + const isUrl = isValidUrl(path); + const isFile = isValidFilePath(path); + + if (isUrl) return 'valid'; + if (isFile) return 'local'; return 'invalid'; }; diff --git a/yarn.lock b/yarn.lock index d6b6de8..ca5f041 100644 --- a/yarn.lock +++ b/yarn.lock @@ -803,6 +803,24 @@ __metadata: languageName: node linkType: hard +"@huggingface/gguf@npm:^0.3.2": + version: 0.3.2 + resolution: "@huggingface/gguf@npm:0.3.2" + dependencies: + "@huggingface/tasks": "npm:^0.19.47" + bin: + gguf-view: dist/cli.js + checksum: 10c0/f39bfc6b468bdd4f9ee1da548dde504dd1cb6ceee75c6c8c2c5473ed48bed789cbf75604f3e04a1b91cf9b3714e4fa081a3fb8f2189196b659bfce4824e558f9 + languageName: node + linkType: hard + +"@huggingface/tasks@npm:^0.19.47": + version: 0.19.58 + resolution: "@huggingface/tasks@npm:0.19.58" + checksum: 10c0/3349a674bba49dfd729285ae6bb5698291748ba1cc80c8eb1e390b6627d7e97acad52114dbf196b1215a4e962d50c4cad6ec1cf91887301fd7c1115fa8ac4217 + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -1053,10 +1071,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-beta.38": - version: 1.0.0-beta.38 - resolution: "@rolldown/pluginutils@npm:1.0.0-beta.38" - checksum: 10c0/8353ec2528349f79e27d1a3193806725b85830da334e935cbb606d88c1177c58ea6519c578e4e93e5f677f5b22aecb8738894dbed14603e14b6bffe3facf1002 +"@rolldown/pluginutils@npm:1.0.0-beta.43": + version: 1.0.0-beta.43 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.43" + checksum: 10c0/1c17a0b16c277a0fdbab080fd22ef91e37c1f0d710ecfdacb6a080068062eb14ff030d0e9d2ec2325a1d4246dba0c49625755c82c0090f6cbf98d16e80183e02 languageName: node linkType: hard @@ -1636,19 +1654,19 @@ __metadata: languageName: node linkType: hard -"@vitejs/plugin-react@npm:^5.0.4": - version: 5.0.4 - resolution: "@vitejs/plugin-react@npm:5.0.4" +"@vitejs/plugin-react@npm:^5.1.0": + version: 5.1.0 + resolution: "@vitejs/plugin-react@npm:5.1.0" dependencies: "@babel/core": "npm:^7.28.4" "@babel/plugin-transform-react-jsx-self": "npm:^7.27.1" "@babel/plugin-transform-react-jsx-source": "npm:^7.27.1" - "@rolldown/pluginutils": "npm:1.0.0-beta.38" + "@rolldown/pluginutils": "npm:1.0.0-beta.43" "@types/babel__core": "npm:^7.20.5" - react-refresh: "npm:^0.17.0" + react-refresh: "npm:^0.18.0" peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/bb9360a4b4c0abf064d22211756b999faf23889ac150de490590ca7bd029b0ef7f4cd8ba3a32b86682a62d46fb7bebd75b3fa9835c57c78123f4a646de2e0136 + checksum: 10c0/e192a12e2b854df109eafb1d06c0bc848e8e2b162c686aa6b999b1048658983e72674b2068ccc37562fcce44d32ad92b65f3a4e1897a0cb7859c2ee69cc63eac languageName: node linkType: hard @@ -3191,18 +3209,18 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:^7.0.0": - version: 7.0.0 - resolution: "eslint-plugin-react-hooks@npm:7.0.0" +"eslint-plugin-react-hooks@npm:^7.0.1": + version: 7.0.1 + resolution: "eslint-plugin-react-hooks@npm:7.0.1" dependencies: "@babel/core": "npm:^7.24.4" "@babel/parser": "npm:^7.24.4" hermes-parser: "npm:^0.25.1" - zod: "npm:^3.22.4 || ^4.0.0" - zod-validation-error: "npm:^3.0.3 || ^4.0.0" + zod: "npm:^3.25.0 || ^4.0.0" + zod-validation-error: "npm:^3.5.0 || ^4.0.0" peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - checksum: 10c0/911c9efdd9b102ce2eabac247dff8c217ecb8d6972aaf3b7eecfb1cfc293d4d902766355993ff7a37a33c0abde3e76971f43bc1c8ff36d6c123310e5680d0423 + checksum: 10c0/1e711d1a9d1fa9cfc51fa1572500656577201199c70c795c6a27adfc1df39e5c598f69aab6aa91117753d23cc1f11388579a2bed14921cf9a4efe60ae8618496 languageName: node linkType: hard @@ -3750,6 +3768,7 @@ __metadata: "@codemirror/view": "npm:^6.38.6" "@eslint/js": "npm:^9.38.0" "@fontsource/inter": "npm:^5.2.8" + "@huggingface/gguf": "npm:^0.3.2" "@mantine/core": "npm:^8.3.5" "@mantine/hooks": "npm:^8.3.5" "@types/node": "npm:^24.9.1" @@ -3759,7 +3778,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.46.2" "@typescript-eslint/parser": "npm:^8.46.2" "@uiw/react-codemirror": "npm:^4.25.2" - "@vitejs/plugin-react": "npm:^5.0.4" + "@vitejs/plugin-react": "npm:^5.1.0" cross-env: "npm:^10.1.0" electron: "npm:^38.4.0" electron-builder: "npm:^26.0.12" @@ -3770,12 +3789,12 @@ __metadata: eslint-plugin-no-comments: "npm:^1.1.10" eslint-plugin-promise: "npm:^7.2.1" eslint-plugin-react: "npm:^7.37.5" - eslint-plugin-react-hooks: "npm:^7.0.0" + eslint-plugin-react-hooks: "npm:^7.0.1" eslint-plugin-sonarjs: "npm:^3.0.5" execa: "npm:^9.6.0" globals: "npm:^16.4.0" jiti: "npm:^2.6.1" - lucide-react: "npm:^0.546.0" + lucide-react: "npm:^0.548.0" prettier: "npm:^3.6.2" react: "npm:^19.2.0" react-dom: "npm:^19.2.0" @@ -4850,12 +4869,12 @@ __metadata: languageName: node linkType: hard -"lucide-react@npm:^0.546.0": - version: 0.546.0 - resolution: "lucide-react@npm:0.546.0" +"lucide-react@npm:^0.548.0": + version: 0.548.0 + resolution: "lucide-react@npm:0.548.0" peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/42ee0bd358517f012297aefb69b54da0b3c62f1ac8485ffa24141b63f05c9f4a682eacc2637c4b13f597aed11f9a8c79627af0c17717400bebb25581daeaad80 + checksum: 10c0/4b3416982927622a8aad49edd3ed53c7a7c202e7357188f56b8dd582e8f22d33b30fda736440a2e640e0faf9baa724a06d0d3c1de5ecf42d2844eb6b24ddefdd languageName: node linkType: hard @@ -5693,10 +5712,10 @@ __metadata: languageName: node linkType: hard -"react-refresh@npm:^0.17.0": - version: 0.17.0 - resolution: "react-refresh@npm:0.17.0" - checksum: 10c0/002cba940384c9930008c0bce26cac97a9d5682bc623112c2268ba0c155127d9c178a9a5cc2212d560088d60dfd503edd808669a25f9b377f316a32361d0b23c +"react-refresh@npm:^0.18.0": + version: 0.18.0 + resolution: "react-refresh@npm:0.18.0" + checksum: 10c0/34a262f7fd803433a534f50deb27a148112a81adcae440c7d1cbae7ef14d21ea8f2b3d783e858cb7698968183b77755a38b4d4b5b1d79b4f4689c2f6d358fff2 languageName: node linkType: hard @@ -7388,7 +7407,7 @@ __metadata: languageName: node linkType: hard -"zod-validation-error@npm:^3.0.3 || ^4.0.0": +"zod-validation-error@npm:^3.5.0 || ^4.0.0": version: 4.0.2 resolution: "zod-validation-error@npm:4.0.2" peerDependencies: @@ -7397,7 +7416,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.22.4 || ^4.0.0": +"zod@npm:^3.25.0 || ^4.0.0": version: 4.1.12 resolution: "zod@npm:4.1.12" checksum: 10c0/b64c1feb19e99d77075261eaf613e0b2be4dfcd3551eff65ad8b4f2a079b61e379854d066f7d447491fcf193f45babd8095551a9d47973d30b46b6d8e2c46774