mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-03 09:33:10 -07:00
allow gerbil to work fully offline by allowing kcpp binary imports on the initial Download screen, more version -> backend renames
This commit is contained in:
parent
4b2e9b2ae9
commit
05cb3b8c05
16 changed files with 346 additions and 198 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "gerbil",
|
||||
"productName": "Gerbil",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"description": "Run Large Language Models locally",
|
||||
"main": "out/main/index.js",
|
||||
"homepage": "./",
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
"eslint-plugin-sonarjs": "^3.0.5",
|
||||
"globals": "^16.5.0",
|
||||
"jiti": "^2.6.1",
|
||||
"prettier": "^3.7.2",
|
||||
"prettier": "^3.7.3",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
"@mantine/hooks": "^8.3.9",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"execa": "^9.6.0",
|
||||
"execa": "^9.6.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"mime-types": "^3.0.2",
|
||||
"react": "^19.2.0",
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
"winston": "^3.18.3",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"yauzl": "^3.2.0",
|
||||
"zustand": "^5.0.8"
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.gerbil.app",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Download, X, ExternalLink } from 'lucide-react';
|
|||
import { useState } from 'react';
|
||||
import type { DownloadItem } from '@/types/electron';
|
||||
import type { BinaryUpdateInfo } from '@/hooks/useUpdateChecker';
|
||||
import { useKoboldVersionsStore } from '@/stores/koboldVersions';
|
||||
import { useKoboldBackendsStore } from '@/stores/koboldBackends';
|
||||
import { pretifyBinName } from '@/utils/assets';
|
||||
import { formatDownloadSize } from '@/utils/format';
|
||||
import { GITHUB_API } from '@/constants';
|
||||
|
|
@ -34,7 +34,7 @@ export const UpdateAvailableModal = ({
|
|||
updateInfo,
|
||||
onUpdate,
|
||||
}: UpdateAvailableModalProps) => {
|
||||
const { downloading, downloadProgress } = useKoboldVersionsStore();
|
||||
const { downloading, downloadProgress } = useKoboldBackendsStore();
|
||||
const currentBackend = updateInfo?.currentBackend;
|
||||
const availableUpdate = updateInfo?.availableUpdate;
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { ErrorBoundary } from '@/components/App/ErrorBoundary';
|
|||
import { AppRouter } from '@/components/App/Router';
|
||||
import { NotepadContainer } from '@/components/Notepad/Container';
|
||||
import { useUpdateChecker } from '@/hooks/useUpdateChecker';
|
||||
import { useKoboldVersionsStore } from '@/stores/koboldVersions';
|
||||
import { useKoboldBackendsStore } from '@/stores/koboldBackends';
|
||||
import { usePreferencesStore } from '@/stores/preferences';
|
||||
import { useLaunchConfigStore } from '@/stores/launchConfig';
|
||||
import { STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants';
|
||||
|
|
@ -81,7 +81,7 @@ export const App = () => {
|
|||
closeModal,
|
||||
} = useUpdateChecker();
|
||||
|
||||
const { handleDownload, loadingRemote } = useKoboldVersionsStore();
|
||||
const { handleDownload, loadingRemote } = useKoboldBackendsStore();
|
||||
|
||||
const determineScreen = (
|
||||
currentVersion: unknown,
|
||||
|
|
@ -142,7 +142,7 @@ export const App = () => {
|
|||
item: download,
|
||||
isUpdate: true,
|
||||
wasCurrentBinary: true,
|
||||
oldVersionPath: currentBackend?.path,
|
||||
oldBackendPath: currentBackend?.path,
|
||||
});
|
||||
|
||||
closeModal();
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ import { Download, Trash2 } from 'lucide-react';
|
|||
import { MouseEvent } from 'react';
|
||||
import { pretifyBinName, isWindowsROCmBuild } from '@/utils/assets';
|
||||
import { usePreferencesStore } from '@/stores/preferences';
|
||||
import { useKoboldVersionsStore } from '@/stores/koboldVersions';
|
||||
import type { VersionInfo } from '@/types';
|
||||
import { useKoboldBackendsStore } from '@/stores/koboldBackends';
|
||||
import type { BackendInfo } from '@/types';
|
||||
|
||||
interface DownloadCardProps {
|
||||
version: VersionInfo;
|
||||
backend: BackendInfo;
|
||||
size: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
|
|
@ -29,7 +29,7 @@ interface DownloadCardProps {
|
|||
}
|
||||
|
||||
export const DownloadCard = ({
|
||||
version: versionInfo,
|
||||
backend,
|
||||
size,
|
||||
description,
|
||||
disabled = false,
|
||||
|
|
@ -40,23 +40,23 @@ export const DownloadCard = ({
|
|||
onDelete,
|
||||
}: DownloadCardProps) => {
|
||||
const { resolvedColorScheme: colorScheme } = usePreferencesStore();
|
||||
const { downloading, downloadProgress } = useKoboldVersionsStore();
|
||||
const { downloading, downloadProgress } = useKoboldBackendsStore();
|
||||
|
||||
const isLoading = downloading === versionInfo.name;
|
||||
const isLoading = downloading === backend.name;
|
||||
const currentProgress = isLoading
|
||||
? Math.min(downloadProgress[versionInfo.name], 100) || 0
|
||||
? Math.min(downloadProgress[backend.name], 100) || 0
|
||||
: 0;
|
||||
const hasVersionMismatch = Boolean(
|
||||
versionInfo.version &&
|
||||
versionInfo.actualVersion &&
|
||||
versionInfo.version !== versionInfo.actualVersion
|
||||
backend.version &&
|
||||
backend.actualVersion &&
|
||||
backend.version !== backend.actualVersion
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const renderActionButtons = () => {
|
||||
const buttons = [];
|
||||
|
||||
if (!versionInfo.isInstalled) {
|
||||
if (!backend.isInstalled) {
|
||||
return (
|
||||
<Button
|
||||
key="download"
|
||||
|
|
@ -78,7 +78,7 @@ export const DownloadCard = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (!versionInfo.isCurrent && onMakeCurrent) {
|
||||
if (!backend.isCurrent && onMakeCurrent) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key="makeCurrent"
|
||||
|
|
@ -92,7 +92,7 @@ export const DownloadCard = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (versionInfo.hasUpdate && onUpdate) {
|
||||
if (backend.hasUpdate && onUpdate) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key="update"
|
||||
|
|
@ -110,7 +110,7 @@ export const DownloadCard = ({
|
|||
)
|
||||
}
|
||||
>
|
||||
{isLoading ? 'Updating...' : `Update to ${versionInfo.newerVersion}`}
|
||||
{isLoading ? 'Updating...' : `Update to ${backend.newerVersion}`}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -138,7 +138,7 @@ export const DownloadCard = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (onDelete && versionInfo.isInstalled && !versionInfo.isCurrent) {
|
||||
if (onDelete && backend.isInstalled && !backend.isCurrent) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key="delete"
|
||||
|
|
@ -169,7 +169,7 @@ export const DownloadCard = ({
|
|||
withBorder
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
{...(versionInfo.isCurrent && {
|
||||
{...(backend.isCurrent && {
|
||||
bg: colorScheme === 'dark' ? 'dark.6' : 'gray.0',
|
||||
bd: `2px solid var(--mantine-color-${colorScheme === 'dark' ? 'blue-4' : 'blue-6'})`,
|
||||
})}
|
||||
|
|
@ -178,19 +178,19 @@ export const DownloadCard = ({
|
|||
<div style={{ flex: 1 }}>
|
||||
<Group gap="xs" align="center" mb="xs">
|
||||
<Text fw={500} size="sm">
|
||||
{pretifyBinName(versionInfo.name)}
|
||||
{pretifyBinName(backend.name)}
|
||||
</Text>
|
||||
{versionInfo.isCurrent && (
|
||||
{backend.isCurrent && (
|
||||
<Badge variant="light" color="blue" size="sm">
|
||||
Current
|
||||
</Badge>
|
||||
)}
|
||||
{versionInfo.hasUpdate && (
|
||||
{backend.hasUpdate && (
|
||||
<Badge variant="light" color="orange" size="sm">
|
||||
Update Available
|
||||
</Badge>
|
||||
)}
|
||||
{isWindowsROCmBuild(versionInfo.name) && (
|
||||
{isWindowsROCmBuild(backend.name) && (
|
||||
<Badge variant="light" color="yellow" size="sm">
|
||||
Experimental
|
||||
</Badge>
|
||||
|
|
@ -202,13 +202,13 @@ export const DownloadCard = ({
|
|||
</Text>
|
||||
)}
|
||||
<Group gap="xs" align="center">
|
||||
{versionInfo.version && (
|
||||
{backend.version && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Version {versionInfo.version}
|
||||
{hasVersionMismatch && versionInfo.actualVersion && (
|
||||
Version {backend.version}
|
||||
{hasVersionMismatch && backend.actualVersion && (
|
||||
<span style={{ color: 'var(--mantine-color-red-6)' }}>
|
||||
{' '}
|
||||
(actual: {versionInfo.actualVersion})
|
||||
(actual: {backend.actualVersion})
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Card, Text, Title, Loader, Stack, Container } from '@mantine/core';
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Title,
|
||||
Loader,
|
||||
Stack,
|
||||
Container,
|
||||
Anchor,
|
||||
} from '@mantine/core';
|
||||
import { DownloadCard } from '@/components/DownloadCard';
|
||||
import { getPlatformDisplayName } from '@/utils/platform';
|
||||
import { formatDownloadSize } from '@/utils/format';
|
||||
import { getAssetDescription } from '@/utils/assets';
|
||||
import { useKoboldVersionsStore } from '@/stores/koboldVersions';
|
||||
import { useKoboldBackendsStore } from '@/stores/koboldBackends';
|
||||
import type { DownloadItem } from '@/types/electron';
|
||||
|
||||
interface DownloadScreenProps {
|
||||
|
|
@ -19,9 +27,11 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
|
|||
loadingRemote,
|
||||
downloading,
|
||||
handleDownload: handleDownloadFromStore,
|
||||
} = useKoboldVersionsStore();
|
||||
} = useKoboldBackendsStore();
|
||||
|
||||
const [downloadingAsset, setDownloadingAsset] = useState<string | null>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const downloadingItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loading = loadingPlatform || loadingRemote;
|
||||
|
|
@ -56,75 +66,101 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
|
|||
|
||||
return (
|
||||
<Container size="sm" mt="md">
|
||||
<Stack gap="xl">
|
||||
<Card withBorder radius="md" shadow="sm">
|
||||
<Stack gap="lg">
|
||||
<Title order={3}>Select a Backend</Title>
|
||||
<Card withBorder radius="md" shadow="sm">
|
||||
<Stack gap="lg">
|
||||
<Title order={3}>Select a Backend</Title>
|
||||
|
||||
{loading ? (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<Loader color="blue" />
|
||||
<Text c="dimmed">Preparing download options...</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{availableDownloads.length > 0 ? (
|
||||
<Stack gap="sm">
|
||||
{availableDownloads.map((download) => {
|
||||
const isDownloading =
|
||||
Boolean(downloading) &&
|
||||
downloadingAsset === download.name;
|
||||
{loading ? (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<Loader color="blue" />
|
||||
<Text c="dimmed">Preparing download options...</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{availableDownloads.length > 0 ? (
|
||||
<Stack gap="sm">
|
||||
{availableDownloads.map((download) => {
|
||||
const isDownloading =
|
||||
Boolean(downloading) &&
|
||||
downloadingAsset === download.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={download.name}
|
||||
ref={isDownloading ? downloadingItemRef : null}
|
||||
>
|
||||
<DownloadCard
|
||||
version={{
|
||||
name: download.name,
|
||||
version: download.version || '',
|
||||
size: download.size,
|
||||
isInstalled: false,
|
||||
isCurrent: false,
|
||||
downloadUrl: download.url,
|
||||
hasUpdate: false,
|
||||
}}
|
||||
size={formatDownloadSize(
|
||||
download.size,
|
||||
download.url
|
||||
)}
|
||||
description={getAssetDescription(download.name)}
|
||||
disabled={
|
||||
Boolean(downloading) &&
|
||||
downloadingAsset !== download.name
|
||||
}
|
||||
onDownload={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(download);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
<Card withBorder p="md" bg="red.0" c="red.9">
|
||||
<Stack gap="xs">
|
||||
<Text fw={500}>No downloads available</Text>
|
||||
<Text size="sm">
|
||||
Unable to fetch downloads for your platform (
|
||||
{getPlatformDisplayName(platform)}). Please check your
|
||||
internet connection and try again.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
return (
|
||||
<div
|
||||
key={download.name}
|
||||
ref={isDownloading ? downloadingItemRef : null}
|
||||
>
|
||||
<DownloadCard
|
||||
backend={{
|
||||
name: download.name,
|
||||
version: download.version || '',
|
||||
size: download.size,
|
||||
isInstalled: false,
|
||||
isCurrent: false,
|
||||
downloadUrl: download.url,
|
||||
hasUpdate: false,
|
||||
}}
|
||||
size={formatDownloadSize(download.size, download.url)}
|
||||
description={getAssetDescription(download.name)}
|
||||
disabled={
|
||||
importing ||
|
||||
(Boolean(downloading) &&
|
||||
downloadingAsset !== download.name)
|
||||
}
|
||||
onDownload={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(download);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Unable to fetch downloads for your platform (
|
||||
{getPlatformDisplayName(platform)}). Check your internet
|
||||
connection and try again.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{importError && (
|
||||
<Text size="sm" c="red" ta="center">
|
||||
{importError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Already have a backend downloaded?{' '}
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={importing || Boolean(downloading)}
|
||||
onClick={async () => {
|
||||
setImportError(null);
|
||||
setImporting(true);
|
||||
|
||||
try {
|
||||
const result =
|
||||
await window.electronAPI.kobold.importLocalBackend();
|
||||
|
||||
if (result.success) {
|
||||
onDownloadComplete();
|
||||
} else if (result.error) {
|
||||
setImportError(result.error);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{importing ? 'Importing...' : 'Select a local file'}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ import {
|
|||
} from '@/utils/version';
|
||||
import { formatDownloadSize } from '@/utils/format';
|
||||
|
||||
import { useKoboldVersionsStore } from '@/stores/koboldVersions';
|
||||
import { useKoboldBackendsStore } from '@/stores/koboldBackends';
|
||||
import type { InstalledBackend, ReleaseWithStatus } from '@/types/electron';
|
||||
import type { VersionInfo } from '@/types';
|
||||
import type { BackendInfo } from '@/types';
|
||||
|
||||
export const BackendsTab = () => {
|
||||
const {
|
||||
|
|
@ -31,7 +31,7 @@ export const BackendsTab = () => {
|
|||
handleDownload: handleDownloadFromStore,
|
||||
getLatestReleaseWithDownloadStatus,
|
||||
initialize,
|
||||
} = useKoboldVersionsStore();
|
||||
} = useKoboldBackendsStore();
|
||||
|
||||
const [installedBackends, setInstalledBackends] = useState<
|
||||
InstalledBackend[]
|
||||
|
|
@ -83,8 +83,8 @@ export const BackendsTab = () => {
|
|||
initialize,
|
||||
]);
|
||||
|
||||
const allBackends = useMemo((): VersionInfo[] => {
|
||||
const backends: VersionInfo[] = [];
|
||||
const allBackends = useMemo((): BackendInfo[] => {
|
||||
const backends: BackendInfo[] = [];
|
||||
const processedInstalled = new Set<string>();
|
||||
|
||||
availableDownloads.forEach((download) => {
|
||||
|
|
@ -170,7 +170,7 @@ export const BackendsTab = () => {
|
|||
}
|
||||
}, [downloading]);
|
||||
|
||||
const handleDownload = async (backend: VersionInfo) => {
|
||||
const handleDownload = async (backend: BackendInfo) => {
|
||||
const download = availableDownloads.find((d) => d.name === backend.name);
|
||||
if (!download) return;
|
||||
|
||||
|
|
@ -183,7 +183,7 @@ export const BackendsTab = () => {
|
|||
await loadInstalledBackends();
|
||||
};
|
||||
|
||||
const handleUpdate = async (backend: VersionInfo) => {
|
||||
const handleUpdate = async (backend: BackendInfo) => {
|
||||
const download = availableDownloads.find((d) => d.name === backend.name);
|
||||
if (!download) return;
|
||||
|
||||
|
|
@ -191,13 +191,13 @@ export const BackendsTab = () => {
|
|||
item: download,
|
||||
isUpdate: true,
|
||||
wasCurrentBinary: backend.isCurrent,
|
||||
oldVersionPath: backend.installedPath,
|
||||
oldBackendPath: backend.installedPath,
|
||||
});
|
||||
|
||||
await loadInstalledBackends();
|
||||
};
|
||||
|
||||
const handleRedownload = async (backend: VersionInfo) => {
|
||||
const handleRedownload = async (backend: BackendInfo) => {
|
||||
const download = availableDownloads.find((d) => d.name === backend.name);
|
||||
if (!download) return;
|
||||
|
||||
|
|
@ -205,13 +205,13 @@ export const BackendsTab = () => {
|
|||
item: download,
|
||||
isUpdate: true,
|
||||
wasCurrentBinary: backend.isCurrent,
|
||||
oldVersionPath: backend.installedPath,
|
||||
oldBackendPath: backend.installedPath,
|
||||
});
|
||||
|
||||
await loadInstalledBackends();
|
||||
};
|
||||
|
||||
const handleDelete = async (backend: VersionInfo) => {
|
||||
const handleDelete = async (backend: BackendInfo) => {
|
||||
if (!backend.installedPath || backend.isCurrent) return;
|
||||
|
||||
const result = await window.electronAPI.kobold.deleteRelease(
|
||||
|
|
@ -222,7 +222,7 @@ export const BackendsTab = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const makeCurrent = (backend: VersionInfo) => {
|
||||
const makeCurrent = (backend: BackendInfo) => {
|
||||
if (!backend.installedPath) return;
|
||||
|
||||
const targetBackend = installedBackends.find(
|
||||
|
|
@ -286,7 +286,7 @@ export const BackendsTab = () => {
|
|||
ref={isDownloading ? downloadingItemRef : null}
|
||||
>
|
||||
<DownloadCard
|
||||
version={backend}
|
||||
backend={backend}
|
||||
size={
|
||||
backend.size
|
||||
? formatDownloadSize(backend.size, backend.downloadUrl)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
compareVersions,
|
||||
stripAssetExtensions,
|
||||
} from '@/utils/version';
|
||||
import { useKoboldVersionsStore } from '@/stores/koboldVersions';
|
||||
import { useKoboldBackendsStore } from '@/stores/koboldBackends';
|
||||
import { getROCmDownload } from '@/utils/rocm';
|
||||
import type { InstalledBackend, DownloadItem } from '@/types/electron';
|
||||
import type { DismissedUpdate } from '@/types';
|
||||
|
|
@ -23,7 +23,7 @@ export const useUpdateChecker = () => {
|
|||
);
|
||||
|
||||
const { availableDownloads: releases, loadingRemote } =
|
||||
useKoboldVersionsStore();
|
||||
useKoboldBackendsStore();
|
||||
useEffect(() => {
|
||||
const loadDismissedUpdates = async () => {
|
||||
const dismissed = (await window.electronAPI.config.get(
|
||||
|
|
@ -75,7 +75,7 @@ export const useUpdateChecker = () => {
|
|||
if (hasUpdate) {
|
||||
const isUpdateDismissed = dismissedUpdates.some(
|
||||
(dismissedUpdate) =>
|
||||
dismissedUpdate.currentVersionPath === currentBackend.path &&
|
||||
dismissedUpdate.currentBackendPath === currentBackend.path &&
|
||||
dismissedUpdate.targetVersion === matchingDownload.version
|
||||
);
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ export const useUpdateChecker = () => {
|
|||
const skipUpdate = useCallback(() => {
|
||||
if (updateInfo && updateInfo.availableUpdate.version) {
|
||||
const newDismissedUpdate: DismissedUpdate = {
|
||||
currentVersionPath: updateInfo.currentBackend.path,
|
||||
currentBackendPath: updateInfo.currentBackend.path,
|
||||
targetVersion: updateInfo.availableUpdate.version,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import {
|
|||
stopKoboldCpp,
|
||||
launchKoboldCppWithCustomFrontends,
|
||||
} from '@/main/modules/koboldcpp/launcher';
|
||||
import { downloadRelease } from '@/main/modules/koboldcpp/download';
|
||||
import {
|
||||
downloadRelease,
|
||||
importLocalBackend,
|
||||
} from '@/main/modules/koboldcpp/download';
|
||||
import {
|
||||
getInstalledBackends,
|
||||
getCurrentBackend,
|
||||
|
|
@ -160,6 +163,8 @@ export function setupIPCHandlers() {
|
|||
selectModelFile(title)
|
||||
);
|
||||
|
||||
ipcMain.handle('kobold:importLocalBackend', () => importLocalBackend());
|
||||
|
||||
ipcMain.handle('kobold:getLocalModels', (_, paramType: string) =>
|
||||
getLocalModelsForType(
|
||||
paramType as Parameters<typeof getLocalModelsForType>[0]
|
||||
|
|
|
|||
|
|
@ -13,16 +13,16 @@ import { logError } from '@/utils/node/logging';
|
|||
import { getLauncherPath } from '@/utils/node/path';
|
||||
import type { InstalledBackend } from '@/types/electron';
|
||||
|
||||
const versionCache = new Map<
|
||||
const backendVersionCache = new Map<
|
||||
string,
|
||||
{ version: string; actualVersion?: string } | null
|
||||
>();
|
||||
|
||||
export function clearVersionCache(path?: string) {
|
||||
export function clearBackendVersionCache(path?: string) {
|
||||
if (path) {
|
||||
versionCache.delete(path);
|
||||
backendVersionCache.delete(path);
|
||||
} else {
|
||||
versionCache.clear();
|
||||
backendVersionCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ export async function deleteRelease(binaryPath: string) {
|
|||
if (await pathExists(releaseDir)) {
|
||||
await rm(releaseDir, { recursive: true, force: true });
|
||||
|
||||
clearVersionCache(binaryPath);
|
||||
clearBackendVersionCache(binaryPath);
|
||||
sendToRenderer('versions-updated');
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -179,8 +179,8 @@ export async function getVersionFromBinary(launcherPath: string) {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (versionCache.has(launcherPath)) {
|
||||
return versionCache.get(launcherPath);
|
||||
if (backendVersionCache.has(launcherPath)) {
|
||||
return backendVersionCache.get(launcherPath);
|
||||
}
|
||||
|
||||
let folderVersion: string | null = null;
|
||||
|
|
@ -224,10 +224,10 @@ export async function getVersionFromBinary(launcherPath: string) {
|
|||
: undefined,
|
||||
};
|
||||
|
||||
versionCache.set(launcherPath, result);
|
||||
backendVersionCache.set(launcherPath, result);
|
||||
return result;
|
||||
} catch {
|
||||
versionCache.set(launcherPath, null);
|
||||
backendVersionCache.set(launcherPath, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { createWriteStream } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { join, basename } from 'path';
|
||||
import { platform } from 'process';
|
||||
import { rm, unlink, rename, mkdir, chmod } from 'fs/promises';
|
||||
import { rm, unlink, rename, mkdir, chmod, copyFile } from 'fs/promises';
|
||||
import { execa } from 'execa';
|
||||
import { dialog } from 'electron';
|
||||
|
||||
import {
|
||||
getInstallDir,
|
||||
|
|
@ -15,7 +16,7 @@ import { pathExists } from '@/utils/node/fs';
|
|||
import { stripAssetExtensions } from '@/utils/version';
|
||||
import { getLauncherPath } from '@/utils/node/path';
|
||||
import type { DownloadReleaseOptions, GitHubAsset } from '@/types/electron';
|
||||
import { clearVersionCache } from './backend';
|
||||
import { clearBackendVersionCache, getVersionFromBinary } from './backend';
|
||||
|
||||
async function removeDirectoryWithRetry(
|
||||
dirPath: string,
|
||||
|
|
@ -150,6 +151,59 @@ async function unpackKoboldCpp(packedPath: string, unpackDir: string) {
|
|||
}
|
||||
}
|
||||
|
||||
interface InstallBackendOptions {
|
||||
packedFilePath: string;
|
||||
unpackedDirPath: string;
|
||||
isUpdate?: boolean;
|
||||
wasCurrentBinary?: boolean;
|
||||
oldBackendPath?: string;
|
||||
skipUnpackError?: boolean;
|
||||
skipCleanup?: boolean;
|
||||
}
|
||||
|
||||
async function installBackend({
|
||||
packedFilePath,
|
||||
unpackedDirPath,
|
||||
isUpdate = false,
|
||||
wasCurrentBinary = false,
|
||||
oldBackendPath,
|
||||
skipUnpackError = false,
|
||||
skipCleanup = false,
|
||||
}: InstallBackendOptions) {
|
||||
if (!skipCleanup && (await pathExists(unpackedDirPath))) {
|
||||
await removeDirectoryWithRetry(unpackedDirPath);
|
||||
}
|
||||
|
||||
await mkdir(unpackedDirPath, { recursive: true });
|
||||
|
||||
if (skipUnpackError) {
|
||||
try {
|
||||
await unpackKoboldCpp(packedFilePath, unpackedDirPath);
|
||||
} catch {}
|
||||
} else {
|
||||
await unpackKoboldCpp(packedFilePath, unpackedDirPath);
|
||||
}
|
||||
|
||||
const launcherPath = await setupLauncher(packedFilePath, unpackedDirPath);
|
||||
|
||||
clearBackendVersionCache(launcherPath);
|
||||
|
||||
if (oldBackendPath && isUpdate) {
|
||||
const oldInstallDir = join(oldBackendPath, '..');
|
||||
if (oldInstallDir !== unpackedDirPath) {
|
||||
await removeDirectoryWithRetry(oldInstallDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (!getCurrentKoboldBinary() || (isUpdate && wasCurrentBinary)) {
|
||||
await setCurrentKoboldBinary(launcherPath);
|
||||
}
|
||||
|
||||
sendToRenderer('versions-updated');
|
||||
|
||||
return launcherPath;
|
||||
}
|
||||
|
||||
export async function downloadRelease(
|
||||
asset: GitHubAsset,
|
||||
options: DownloadReleaseOptions
|
||||
|
|
@ -162,39 +216,73 @@ export async function downloadRelease(
|
|||
const unpackedDirPath = join(getInstallDir(), folderName);
|
||||
|
||||
try {
|
||||
if (await pathExists(unpackedDirPath)) {
|
||||
await removeDirectoryWithRetry(unpackedDirPath);
|
||||
}
|
||||
|
||||
await downloadFile(asset, tempPackedFilePath);
|
||||
|
||||
await unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
|
||||
|
||||
const launcherPath = await setupLauncher(
|
||||
tempPackedFilePath,
|
||||
unpackedDirPath
|
||||
);
|
||||
|
||||
clearVersionCache(launcherPath);
|
||||
|
||||
if (options.oldVersionPath && options.isUpdate) {
|
||||
const oldInstallDir = join(options.oldVersionPath, '..');
|
||||
|
||||
if (oldInstallDir !== unpackedDirPath) {
|
||||
await removeDirectoryWithRetry(oldInstallDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!getCurrentKoboldBinary() ||
|
||||
(options.isUpdate && options.wasCurrentBinary)
|
||||
) {
|
||||
await setCurrentKoboldBinary(launcherPath);
|
||||
}
|
||||
|
||||
sendToRenderer('versions-updated');
|
||||
await installBackend({
|
||||
packedFilePath: tempPackedFilePath,
|
||||
unpackedDirPath,
|
||||
isUpdate: options.isUpdate,
|
||||
wasCurrentBinary: options.wasCurrentBinary,
|
||||
oldBackendPath: options.oldBackendPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Failed to download or unpack binary:', error as Error);
|
||||
throw new Error('Failed to download or unpack binary');
|
||||
}
|
||||
}
|
||||
|
||||
export async function importLocalBackend() {
|
||||
const result = await dialog.showOpenDialog(getMainWindow(), {
|
||||
title: 'Select Backend Executable',
|
||||
filters:
|
||||
platform === 'win32'
|
||||
? [
|
||||
{ name: 'Executable Files', extensions: ['exe'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
]
|
||||
: [{ name: 'All Files', extensions: ['*'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const selectedPath = result.filePaths[0];
|
||||
|
||||
try {
|
||||
if (platform !== 'win32') {
|
||||
await chmod(selectedPath, 0o755);
|
||||
}
|
||||
|
||||
const backendVersion = await getVersionFromBinary(selectedPath);
|
||||
|
||||
if (!backendVersion || backendVersion.version === 'unknown') {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Invalid backend executable. Could not determine version information.',
|
||||
};
|
||||
}
|
||||
|
||||
const version = backendVersion.actualVersion || backendVersion.version;
|
||||
const filename = basename(selectedPath);
|
||||
const baseFilename = stripAssetExtensions(filename);
|
||||
const folderName = `${baseFilename}-${version}`;
|
||||
const installDir = join(getInstallDir(), folderName);
|
||||
const packedFilePath = join(getInstallDir(), `${filename}.packed`);
|
||||
|
||||
await copyFile(selectedPath, packedFilePath);
|
||||
|
||||
await installBackend({
|
||||
packedFilePath,
|
||||
unpackedDirPath: installDir,
|
||||
skipUnpackError: true,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logError('Failed to import local backend:', error as Error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import type {
|
|||
const koboldAPI: KoboldAPI = {
|
||||
getInstalledBackends: () => ipcRenderer.invoke('kobold:getInstalledBackends'),
|
||||
getCurrentBackend: () => ipcRenderer.invoke('kobold:getCurrentBackend'),
|
||||
setCurrentBackend: (version) =>
|
||||
ipcRenderer.invoke('kobold:setCurrentBackend', version),
|
||||
setCurrentBackend: (backend) =>
|
||||
ipcRenderer.invoke('kobold:setCurrentBackend', backend),
|
||||
getPlatform: () => ipcRenderer.invoke('kobold:getPlatform'),
|
||||
detectGPU: () => ipcRenderer.invoke('kobold:detectGPU'),
|
||||
detectCPU: () => ipcRenderer.invoke('kobold:detectCPU'),
|
||||
|
|
@ -53,6 +53,7 @@ const koboldAPI: KoboldAPI = {
|
|||
ipcRenderer.invoke('kobold:parseConfigFile', filePath),
|
||||
selectModelFile: (title) =>
|
||||
ipcRenderer.invoke('kobold:selectModelFile', title),
|
||||
importLocalBackend: () => ipcRenderer.invoke('kobold:importLocalBackend'),
|
||||
getLocalModels: (paramType) =>
|
||||
ipcRenderer.invoke('kobold:getLocalModels', paramType),
|
||||
analyzeModel: (filePath) =>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ interface HandleDownloadParams {
|
|||
item: DownloadItem;
|
||||
isUpdate?: boolean;
|
||||
wasCurrentBinary?: boolean;
|
||||
oldVersionPath?: string;
|
||||
oldBackendPath?: string;
|
||||
}
|
||||
|
||||
const transformReleaseToDownloadItems = (
|
||||
|
|
@ -48,7 +48,7 @@ const fetchLatestReleaseFromAPI = async (platform: string) => {
|
|||
return transformReleaseToDownloadItems(release, platform);
|
||||
};
|
||||
|
||||
interface KoboldVersionsState {
|
||||
interface KoboldBackendsState {
|
||||
platform: string;
|
||||
availableDownloads: DownloadItem[];
|
||||
loadingPlatform: boolean;
|
||||
|
|
@ -61,7 +61,7 @@ interface KoboldVersionsState {
|
|||
getLatestReleaseWithDownloadStatus: () => Promise<ReleaseWithStatus | null>;
|
||||
}
|
||||
|
||||
export const useKoboldVersionsStore = create<KoboldVersionsState>(
|
||||
export const useKoboldBackendsStore = create<KoboldBackendsState>(
|
||||
(set, get) => ({
|
||||
platform: '',
|
||||
availableDownloads: [],
|
||||
|
|
@ -101,7 +101,7 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
|
|||
item,
|
||||
isUpdate = false,
|
||||
wasCurrentBinary = false,
|
||||
oldVersionPath,
|
||||
oldBackendPath,
|
||||
} = params;
|
||||
const { downloading } = get();
|
||||
|
||||
|
|
@ -127,15 +127,16 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
|
|||
await window.electronAPI.kobold.downloadRelease(asset, {
|
||||
isUpdate,
|
||||
wasCurrentBinary,
|
||||
oldVersionPath,
|
||||
oldBackendPath,
|
||||
});
|
||||
|
||||
progressCleanup();
|
||||
set({ downloading: null, downloadProgress: {} });
|
||||
},
|
||||
|
||||
getLatestReleaseWithDownloadStatus: async () =>
|
||||
safeExecute(async () => {
|
||||
const [response, installedVersions] = await Promise.all([
|
||||
const [response, installedBackends] = await Promise.all([
|
||||
fetch(GITHUB_API.LATEST_RELEASE_URL),
|
||||
window.electronAPI.kobold.getInstalledBackends(),
|
||||
]);
|
||||
|
|
@ -147,9 +148,9 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
|
|||
|
||||
const availableAssets = latestRelease.assets.map(
|
||||
(asset: GitHubAsset) => {
|
||||
const installedBackend = installedVersions.find(
|
||||
(v: InstalledBackend) => {
|
||||
const pathParts = v.path.split(/[/\\]/);
|
||||
const installedBackend = installedBackends.find(
|
||||
(b: InstalledBackend) => {
|
||||
const pathParts = b.path.split(/[/\\]/);
|
||||
const launcherIndex = pathParts.findIndex(
|
||||
(part: string) =>
|
||||
part === 'koboldcpp-launcher' ||
|
||||
|
|
@ -168,7 +169,7 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
|
|||
return {
|
||||
asset,
|
||||
isDownloaded: !!installedBackend,
|
||||
installedVersion: installedBackend?.version,
|
||||
installedBackendVersion: installedBackend?.version,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
@ -181,4 +182,4 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
|
|||
})
|
||||
);
|
||||
|
||||
useKoboldVersionsStore.getState().initialize();
|
||||
useKoboldBackendsStore.getState().initialize();
|
||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
|
|
@ -29,7 +29,7 @@ export interface GitHubAsset {
|
|||
export interface DownloadReleaseOptions {
|
||||
isUpdate?: boolean;
|
||||
wasCurrentBinary?: boolean;
|
||||
oldVersionPath?: string;
|
||||
oldBackendPath?: string;
|
||||
}
|
||||
|
||||
export interface GitHubRelease {
|
||||
|
|
@ -53,7 +53,7 @@ export interface ReleaseWithStatus {
|
|||
availableAssets: {
|
||||
asset: GitHubAsset;
|
||||
isDownloaded: boolean;
|
||||
installedVersion?: string;
|
||||
installedBackendVersion?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
|
@ -162,6 +162,7 @@ export interface KoboldAPI {
|
|||
setSelectedConfig: (configName: string) => Promise<boolean>;
|
||||
parseConfigFile: (filePath: string) => Promise<KoboldConfig | null>;
|
||||
selectModelFile: (title?: string) => Promise<string | null>;
|
||||
importLocalBackend: () => Promise<{ success: boolean; error?: string }>;
|
||||
getLocalModels: (paramType: string) => Promise<CachedModel[]>;
|
||||
analyzeModel: (filePath: string) => Promise<ModelAnalysis>;
|
||||
calculateOptimalLayers: (
|
||||
|
|
|
|||
4
src/types/index.d.ts
vendored
4
src/types/index.d.ts
vendored
|
|
@ -66,7 +66,7 @@ export interface UpdateInfo {
|
|||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
export interface BackendInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
size?: number;
|
||||
|
|
@ -80,7 +80,7 @@ export interface VersionInfo {
|
|||
}
|
||||
|
||||
export interface DismissedUpdate {
|
||||
currentVersionPath: string;
|
||||
currentBackendPath: string;
|
||||
targetVersion: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,14 +9,22 @@ export const getAssetDescription = (assetName: string) => {
|
|||
}
|
||||
|
||||
if (name.endsWith(ASSET_SUFFIXES.OLDPC)) {
|
||||
return 'Meant for old PCs with outdated CPUs that may not work with the standard build. Does not support modern AVX2 CPU architectures or modern CUDA.';
|
||||
return 'Meant for old PCs with outdated CPUs that may not work with the standard backend. Does not support modern AVX2 CPU architectures or modern CUDA.';
|
||||
}
|
||||
|
||||
if (name.endsWith(ASSET_SUFFIXES.NOCUDA)) {
|
||||
return 'Standard build with NVIDIA CUDA support removed for minimal file size.';
|
||||
return 'Standard backend with NVIDIA CUDA support removed for minimal file size.';
|
||||
}
|
||||
|
||||
return "Standard build that's ideal for most cases.";
|
||||
if (
|
||||
name === 'koboldcpp-linux-x64' ||
|
||||
name === 'koboldcpp-mac-arm64' ||
|
||||
name === 'koboldcpp'
|
||||
) {
|
||||
return "Standard backend that's ideal for most cases.";
|
||||
}
|
||||
|
||||
return 'Custom backend.';
|
||||
};
|
||||
|
||||
export const isWindowsROCmBuild = (assetName: string) => {
|
||||
|
|
@ -78,5 +86,13 @@ export const pretifyBinName = (binName: string) => {
|
|||
return 'No CUDA';
|
||||
}
|
||||
|
||||
return 'Standard';
|
||||
if (
|
||||
cleanName === 'koboldcpp-linux-x64' ||
|
||||
cleanName === 'koboldcpp-mac-arm64' ||
|
||||
cleanName === 'koboldcpp'
|
||||
) {
|
||||
return 'Standard';
|
||||
}
|
||||
|
||||
return cleanName;
|
||||
};
|
||||
|
|
|
|||
30
yarn.lock
30
yarn.lock
|
|
@ -3358,9 +3358,9 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"execa@npm:^9.6.0":
|
||||
version: 9.6.0
|
||||
resolution: "execa@npm:9.6.0"
|
||||
"execa@npm:^9.6.1":
|
||||
version: 9.6.1
|
||||
resolution: "execa@npm:9.6.1"
|
||||
dependencies:
|
||||
"@sindresorhus/merge-streams": "npm:^4.0.0"
|
||||
cross-spawn: "npm:^7.0.6"
|
||||
|
|
@ -3374,7 +3374,7 @@ __metadata:
|
|||
signal-exit: "npm:^4.1.0"
|
||||
strip-final-newline: "npm:^4.0.0"
|
||||
yoctocolors: "npm:^2.1.1"
|
||||
checksum: 10c0/2c44a33142f77d3a6a590a3b769b49b27029a76768593bac1f26fed4dd1330e9c189ee61eba6a8c990fb77e37286c68c7445472ebf24c22b31e9ff320e73d7ac
|
||||
checksum: 10c0/636b36585306a3c8bc3a9d7b25d2d915fb06d8c9b9b02a804280d62562de3b34535affc1b7702b039320e0953daa6545a073f3c4b63fe974c1fe11336c56b467
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -3731,12 +3731,12 @@ __metadata:
|
|||
eslint-plugin-react: "npm:^7.37.5"
|
||||
eslint-plugin-react-hooks: "npm:^7.0.1"
|
||||
eslint-plugin-sonarjs: "npm:^3.0.5"
|
||||
execa: "npm:^9.6.0"
|
||||
execa: "npm:^9.6.1"
|
||||
globals: "npm:^16.5.0"
|
||||
jiti: "npm:^2.6.1"
|
||||
lucide-react: "npm:^0.555.0"
|
||||
mime-types: "npm:^3.0.2"
|
||||
prettier: "npm:^3.7.2"
|
||||
prettier: "npm:^3.7.3"
|
||||
react: "npm:^19.2.0"
|
||||
react-dom: "npm:^19.2.0"
|
||||
react-error-boundary: "npm:^6.0.0"
|
||||
|
|
@ -3747,7 +3747,7 @@ __metadata:
|
|||
winston: "npm:^3.18.3"
|
||||
winston-daily-rotate-file: "npm:^5.0.0"
|
||||
yauzl: "npm:^3.2.0"
|
||||
zustand: "npm:^5.0.8"
|
||||
zustand: "npm:^5.0.9"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
|
|
@ -5506,12 +5506,12 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prettier@npm:^3.7.2":
|
||||
version: 3.7.2
|
||||
resolution: "prettier@npm:3.7.2"
|
||||
"prettier@npm:^3.7.3":
|
||||
version: 3.7.3
|
||||
resolution: "prettier@npm:3.7.3"
|
||||
bin:
|
||||
prettier: bin/prettier.cjs
|
||||
checksum: 10c0/df3d658df301face0918f8ecbd4354f32e1151d83a3a4720c7f252342baf631466568f708e0e57beea55bbc56415c40208adc76a91d5f1a88f3e743d0d775dc0
|
||||
checksum: 10c0/ee86bb06121c74dadc54f30b6f99aff6288966d9b842ce501d6991e20d20c6ce2d45028651b3b0955ca6e5fa89c1bee1e72b6f810243a93cef8bc69737972ef7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -7308,9 +7308,9 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zustand@npm:^5.0.8":
|
||||
version: 5.0.8
|
||||
resolution: "zustand@npm:5.0.8"
|
||||
"zustand@npm:^5.0.9":
|
||||
version: 5.0.9
|
||||
resolution: "zustand@npm:5.0.9"
|
||||
peerDependencies:
|
||||
"@types/react": ">=18.0.0"
|
||||
immer: ">=9.0.6"
|
||||
|
|
@ -7325,6 +7325,6 @@ __metadata:
|
|||
optional: true
|
||||
use-sync-external-store:
|
||||
optional: true
|
||||
checksum: 10c0/e865a6f7f1c0e03571701db5904151aaa7acefd6f85541a117085e129bf16e8f60b11f8cc82f277472de5c7ad5dcb201e1b0a89035ea0b40c9d9aab3cd24c8ad
|
||||
checksum: 10c0/552849e4546c7760704d6509a5c412d57c62a1fa9e53169c939ba5e3d75f8cb3df50a64c3a22e6c3f1c8cc00de7543e4edd61ab5ae0c9169ba9a98e28303aba6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue