mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-03 19:54:44 -07:00
display disabled backend options for available but currently hardware-unsupported backends, allow updatable binaries to be available to make current, persist skipping kcpp updates to app config
This commit is contained in:
parent
253403d45f
commit
4f49f0bad3
10 changed files with 244 additions and 153 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "friendly-kobold",
|
||||
"productName": "Friendly Kobold",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"description": "A desktop app for running Large Language Models locally",
|
||||
"main": "out/main/index.js",
|
||||
"homepage": "./",
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
"@eslint/js": "^9.34.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.11",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-dom": "^19.1.8",
|
||||
"@types/strip-ansi": "^5.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||
"@typescript-eslint/parser": "^8.41.0",
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
"@mantine/hooks": "^8.2.7",
|
||||
"execa": "^9.6.0",
|
||||
"got": "^14.4.7",
|
||||
"lucide-react": "^0.541.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"strip-ansi": "^7.1.0",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ interface DownloadCardProps {
|
|||
disabled?: boolean;
|
||||
hasUpdate?: boolean;
|
||||
newerVersion?: string;
|
||||
onDownload?: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
onDownload: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
onMakeCurrent?: () => void;
|
||||
onUpdate?: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
|
@ -51,32 +51,13 @@ export const DownloadCard = ({
|
|||
getInitialValueInEffect: false,
|
||||
});
|
||||
const isDark = computedColorScheme === 'dark';
|
||||
const renderActionButton = () => {
|
||||
if (hasUpdate && onUpdate) {
|
||||
return (
|
||||
<Button
|
||||
variant="filled"
|
||||
size="xs"
|
||||
onClick={onUpdate}
|
||||
loading={isDownloading}
|
||||
disabled={disabled}
|
||||
color="orange"
|
||||
leftSection={
|
||||
isDownloading ? (
|
||||
<Loader size="1rem" />
|
||||
) : (
|
||||
<Download style={{ width: rem(14), height: rem(14) }} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDownloading ? 'Updating...' : `Update to ${newerVersion}`}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const renderActionButtons = () => {
|
||||
const buttons = [];
|
||||
|
||||
if (!isInstalled && onDownload) {
|
||||
return (
|
||||
if (!isInstalled) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key="download"
|
||||
variant="filled"
|
||||
size="xs"
|
||||
onClick={onDownload}
|
||||
|
|
@ -96,14 +77,42 @@ export const DownloadCard = ({
|
|||
}
|
||||
|
||||
if (isInstalled && !isCurrent && onMakeCurrent) {
|
||||
return (
|
||||
<Button variant="light" size="xs" onClick={onMakeCurrent}>
|
||||
buttons.push(
|
||||
<Button
|
||||
key="makeCurrent"
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={onMakeCurrent}
|
||||
>
|
||||
Make Current
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
if (hasUpdate && onUpdate) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key="update"
|
||||
variant="filled"
|
||||
size="xs"
|
||||
onClick={onUpdate}
|
||||
loading={isDownloading}
|
||||
disabled={disabled}
|
||||
color="orange"
|
||||
leftSection={
|
||||
isDownloading ? (
|
||||
<Loader size="1rem" />
|
||||
) : (
|
||||
<Download style={{ width: rem(14), height: rem(14) }} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDownloading ? 'Updating...' : `Update to ${newerVersion}`}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return buttons.length > 0 ? <Stack gap="xs">{buttons}</Stack> : null;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -157,7 +166,7 @@ export const DownloadCard = ({
|
|||
</Group>
|
||||
</div>
|
||||
|
||||
{renderActionButton()}
|
||||
{renderActionButtons()}
|
||||
</Group>
|
||||
|
||||
{isDownloading && downloadProgress !== undefined && (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { Stack, Text, Group, Button, Select, Badge } from '@mantine/core';
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Save, File, Plus, Check } from 'lucide-react';
|
||||
import type { ConfigFile } from '@/types';
|
||||
import styles from '@/styles/layout.module.css';
|
||||
|
|
@ -19,7 +14,7 @@ interface ConfigFileManagerProps {
|
|||
onLoadConfigFiles: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface SelectItemProps extends ComponentPropsWithoutRef<'div'> {
|
||||
interface SelectItemProps {
|
||||
label: string;
|
||||
extension: string;
|
||||
}
|
||||
|
|
@ -35,9 +30,7 @@ const getBadgeColor = (extension: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
|
||||
({ label, extension, ...others }, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
const SelectItem = ({ label, extension }: SelectItemProps) => (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" truncate>
|
||||
{label}
|
||||
|
|
@ -46,12 +39,8 @@ const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
|
|||
{extension}
|
||||
</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
SelectItem.displayName = 'SelectItem';
|
||||
|
||||
export const ConfigFileManager = ({
|
||||
configFiles,
|
||||
selectedFile,
|
||||
|
|
|
|||
|
|
@ -1,20 +1,24 @@
|
|||
import { Text, Group, Badge } from '@mantine/core';
|
||||
import { forwardRef } from 'react';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
interface BackendSelectItemProps extends ComponentPropsWithoutRef<'div'> {
|
||||
interface BackendSelectItemProps {
|
||||
label: string;
|
||||
devices?: string[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const BackendSelectItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
BackendSelectItemProps
|
||||
>(({ label, devices, ...others }, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
export const BackendSelectItem = ({
|
||||
label,
|
||||
devices,
|
||||
disabled = false,
|
||||
}: BackendSelectItemProps) => (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" truncate>
|
||||
{label}
|
||||
{disabled && (
|
||||
<Text component="span" size="xs" ml="xs">
|
||||
(Compatible device(s) not found)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
{devices && devices.length > 0 && (
|
||||
<Group gap={4}>
|
||||
|
|
@ -31,7 +35,4 @@ export const BackendSelectItem = forwardRef<
|
|||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
));
|
||||
|
||||
BackendSelectItem.displayName = 'BackendSelectItem';
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,11 +5,7 @@ import { BackendSelectItem } from '@/components/screens/Launch/GeneralTab/Backen
|
|||
import { GpuDeviceSelector } from '@/components/screens/Launch/GeneralTab/GpuDeviceSelector';
|
||||
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
|
||||
|
||||
interface BackendSelectorProps {
|
||||
onBackendsReady?: () => void;
|
||||
}
|
||||
|
||||
export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
|
||||
export const BackendSelector = () => {
|
||||
const {
|
||||
backend,
|
||||
gpuLayers,
|
||||
|
|
@ -20,39 +16,95 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
|
|||
} = useLaunchConfig();
|
||||
|
||||
const [availableBackends, setAvailableBackends] = useState<
|
||||
Array<{ value: string; label: string; devices?: string[] }>
|
||||
Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
devices?: string[];
|
||||
disabled?: boolean;
|
||||
}>
|
||||
>([]);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBackends = async () => {
|
||||
try {
|
||||
const [cpuCapabilitiesResult, backends] = await Promise.all([
|
||||
const [cpuCapabilitiesResult, binarySupport, gpuCapabilities] =
|
||||
await Promise.all([
|
||||
window.electronAPI.kobold.detectCPU(),
|
||||
window.electronAPI.kobold.getAvailableBackends(),
|
||||
window.electronAPI.kobold.detectBackendSupport(),
|
||||
window.electronAPI.kobold.detectGPUCapabilities(),
|
||||
]);
|
||||
|
||||
const cpuBackend = backends.find((b) => b.value === 'cpu');
|
||||
if (cpuBackend) {
|
||||
cpuBackend.devices = cpuCapabilitiesResult.devices;
|
||||
if (!binarySupport) {
|
||||
setAvailableBackends([]);
|
||||
hasInitialized.current = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const backends: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
devices?: string[];
|
||||
disabled?: boolean;
|
||||
}> = [];
|
||||
|
||||
if (binarySupport.cuda) {
|
||||
backends.push({
|
||||
value: 'cuda',
|
||||
label: 'CUDA',
|
||||
devices: gpuCapabilities.cuda.devices,
|
||||
disabled: !gpuCapabilities.cuda.supported,
|
||||
});
|
||||
}
|
||||
|
||||
if (binarySupport.rocm) {
|
||||
backends.push({
|
||||
value: 'rocm',
|
||||
label: 'ROCm',
|
||||
devices: gpuCapabilities.rocm.devices,
|
||||
disabled: !gpuCapabilities.rocm.supported,
|
||||
});
|
||||
}
|
||||
|
||||
if (binarySupport.vulkan) {
|
||||
backends.push({
|
||||
value: 'vulkan',
|
||||
label: 'Vulkan',
|
||||
devices: gpuCapabilities.vulkan.devices,
|
||||
disabled: !gpuCapabilities.vulkan.supported,
|
||||
});
|
||||
}
|
||||
|
||||
if (binarySupport.clblast) {
|
||||
backends.push({
|
||||
value: 'clblast',
|
||||
label: 'CLBlast',
|
||||
devices: gpuCapabilities.clblast.devices,
|
||||
disabled: !gpuCapabilities.clblast.supported,
|
||||
});
|
||||
}
|
||||
|
||||
backends.push({
|
||||
value: 'cpu',
|
||||
label: 'CPU',
|
||||
devices: cpuCapabilitiesResult.devices,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
backends.sort((a, b) => {
|
||||
if (a.disabled === b.disabled) return 0;
|
||||
return a.disabled ? 1 : -1;
|
||||
});
|
||||
|
||||
setAvailableBackends(backends);
|
||||
hasInitialized.current = true;
|
||||
|
||||
if (onBackendsReady) {
|
||||
onBackendsReady();
|
||||
}
|
||||
} catch (error) {
|
||||
window.electronAPI.logs.logError(
|
||||
'Failed to detect available backends:',
|
||||
error as Error
|
||||
);
|
||||
setAvailableBackends([]);
|
||||
|
||||
if (onBackendsReady) {
|
||||
onBackendsReady();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -66,7 +118,7 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
|
|||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onBackendsReady]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -89,6 +141,7 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
|
|||
data={availableBackends.map((b) => ({
|
||||
value: b.value,
|
||||
label: b.label,
|
||||
disabled: b.disabled,
|
||||
}))}
|
||||
disabled={availableBackends.length === 0}
|
||||
renderOption={({ option }) => {
|
||||
|
|
@ -99,6 +152,7 @@ export const BackendSelector = ({ onBackendsReady }: BackendSelectorProps) => {
|
|||
<BackendSelectItem
|
||||
label={backendData?.label || option.label.split(' (')[0]}
|
||||
devices={backendData?.devices}
|
||||
disabled={backendData?.disabled}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@ import { BackendSelector } from '@/components/screens/Launch/GeneralTab/BackendS
|
|||
import { ModelFileField } from '@/components/ModelFileField';
|
||||
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
|
||||
|
||||
interface GeneralTabProps {
|
||||
onBackendsReady?: () => void;
|
||||
}
|
||||
|
||||
export const GeneralTab = ({ onBackendsReady }: GeneralTabProps) => {
|
||||
export const GeneralTab = () => {
|
||||
const {
|
||||
modelPath,
|
||||
contextSize,
|
||||
|
|
@ -19,7 +15,7 @@ export const GeneralTab = ({ onBackendsReady }: GeneralTabProps) => {
|
|||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<BackendSelector onBackendsReady={onBackendsReady} />
|
||||
<BackendSelector />
|
||||
|
||||
<ModelFileField
|
||||
label="Text Model File"
|
||||
|
|
|
|||
|
|
@ -366,27 +366,15 @@ export const VersionsTab = () => {
|
|||
disabled={downloading !== null}
|
||||
hasUpdate={version.hasUpdate}
|
||||
newerVersion={version.newerVersion}
|
||||
onDownload={
|
||||
!version.isInstalled
|
||||
? (e) => {
|
||||
onDownload={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(version);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdate={
|
||||
version.hasUpdate
|
||||
? (e) => {
|
||||
}}
|
||||
onUpdate={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(version);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMakeCurrent={
|
||||
version.isInstalled && !version.isCurrent && !version.hasUpdate
|
||||
? () => makeCurrent(version)
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
onMakeCurrent={() => makeCurrent(version)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { getDisplayNameFromPath } from '@/utils/versionUtils';
|
||||
import { compareVersions } from '@/utils';
|
||||
import type { InstalledVersion, DownloadItem } from '@/types/electron';
|
||||
|
|
@ -12,8 +12,52 @@ export const useUpdateChecker = () => {
|
|||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
||||
const [dismissedUpdates, setDismissedUpdates] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [dismissedUpdatesLoaded, setDismissedUpdatesLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadDismissedUpdates = async () => {
|
||||
try {
|
||||
const dismissed = (await window.electronAPI.config.get(
|
||||
'dismissedUpdates'
|
||||
)) as string[] | undefined;
|
||||
if (dismissed) {
|
||||
setDismissedUpdates(new Set(dismissed));
|
||||
}
|
||||
setDismissedUpdatesLoaded(true);
|
||||
} catch (error) {
|
||||
window.electronAPI.logs.logError(
|
||||
'Failed to load dismissed updates:',
|
||||
error as Error
|
||||
);
|
||||
setDismissedUpdatesLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadDismissedUpdates();
|
||||
}, []);
|
||||
|
||||
const saveDismissedUpdates = useCallback(async (updates: Set<string>) => {
|
||||
try {
|
||||
await window.electronAPI.config.set(
|
||||
'dismissedUpdates',
|
||||
Array.from(updates)
|
||||
);
|
||||
} catch (error) {
|
||||
window.electronAPI.logs.logError(
|
||||
'Failed to save dismissed updates:',
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
if (!dismissedUpdatesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChecking(true);
|
||||
|
||||
try {
|
||||
|
|
@ -59,6 +103,9 @@ export const useUpdateChecker = () => {
|
|||
compareVersions(matchingDownload.version, currentVersion.version) > 0;
|
||||
|
||||
if (hasUpdate) {
|
||||
const updateKey = `${currentVersion.path}-${matchingDownload.version}`;
|
||||
|
||||
if (!dismissedUpdates.has(updateKey)) {
|
||||
setUpdateInfo({
|
||||
currentVersion,
|
||||
availableUpdate: matchingDownload,
|
||||
|
|
@ -66,6 +113,7 @@ export const useUpdateChecker = () => {
|
|||
setShowUpdateModal(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
window.electronAPI.logs.logError(
|
||||
'Failed to check for updates:',
|
||||
|
|
@ -74,12 +122,18 @@ export const useUpdateChecker = () => {
|
|||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, []);
|
||||
}, [dismissedUpdates, dismissedUpdatesLoaded]);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
const dismissUpdate = useCallback(async () => {
|
||||
if (updateInfo) {
|
||||
const updateKey = `${updateInfo.currentVersion.path}-${updateInfo.availableUpdate.version}`;
|
||||
const newDismissedUpdates = new Set([...dismissedUpdates, updateKey]);
|
||||
setDismissedUpdates(newDismissedUpdates);
|
||||
await saveDismissedUpdates(newDismissedUpdates);
|
||||
}
|
||||
setShowUpdateModal(false);
|
||||
setUpdateInfo(null);
|
||||
}, []);
|
||||
}, [updateInfo, dismissedUpdates, saveDismissedUpdates]);
|
||||
|
||||
return {
|
||||
updateInfo,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const getAssetDescription = (assetName: string): 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.';
|
||||
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.';
|
||||
}
|
||||
|
||||
if (name.endsWith(ASSET_SUFFIXES.NOCUDA)) {
|
||||
|
|
|
|||
20
yarn.lock
20
yarn.lock
|
|
@ -1324,12 +1324,12 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:^19.1.7":
|
||||
version: 19.1.7
|
||||
resolution: "@types/react-dom@npm:19.1.7"
|
||||
"@types/react-dom@npm:^19.1.8":
|
||||
version: 19.1.8
|
||||
resolution: "@types/react-dom@npm:19.1.8"
|
||||
peerDependencies:
|
||||
"@types/react": ^19.0.0
|
||||
checksum: 10c0/8db5751c1567552fe4e1ece9f5823b682f2994ec8d30ed34ba0ef984e3c8ace1435f8be93d02f55c350147e78ac8c4dbcd8ed2c3b6a60f575bc5374f588c51c9
|
||||
checksum: 10c0/561f9679c99e93adba8ecdf7d5ad69cc9d5e35837fa996246a83713f0ce498fc5b871f9a2a3342c7d440fc02159abe10f29c4a4004527d5d38b2e84f21840793
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -3612,7 +3612,7 @@ __metadata:
|
|||
"@mantine/hooks": "npm:^8.2.7"
|
||||
"@types/node": "npm:^24.3.0"
|
||||
"@types/react": "npm:^19.1.11"
|
||||
"@types/react-dom": "npm:^19.1.7"
|
||||
"@types/react-dom": "npm:^19.1.8"
|
||||
"@types/strip-ansi": "npm:^5.2.1"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^8.41.0"
|
||||
"@typescript-eslint/parser": "npm:^8.41.0"
|
||||
|
|
@ -3634,7 +3634,7 @@ __metadata:
|
|||
husky: "npm:^9.1.7"
|
||||
jiti: "npm:^2.5.1"
|
||||
lint-staged: "npm:^16.1.5"
|
||||
lucide-react: "npm:^0.541.0"
|
||||
lucide-react: "npm:^0.542.0"
|
||||
prettier: "npm:^3.6.2"
|
||||
react: "npm:^19.1.1"
|
||||
react-dom: "npm:^19.1.1"
|
||||
|
|
@ -5005,12 +5005,12 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lucide-react@npm:^0.541.0":
|
||||
version: 0.541.0
|
||||
resolution: "lucide-react@npm:0.541.0"
|
||||
"lucide-react@npm:^0.542.0":
|
||||
version: 0.542.0
|
||||
resolution: "lucide-react@npm:0.542.0"
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/ffa23a9c9ead0832c7e0bb1eb7ed44e9f38bbf083d94b4d36a66eb23fa2099d4a52e1677c91eb7e8ea922621fa98e730f5d513bc56b3111b14b835bd8ab823ab
|
||||
checksum: 10c0/3ccdb898a480f0194f93abef0b6f0347bc2647db24051e65a17c1dbd22ca0d10a7636d9af68c92665b42b6c9e761567a0d65944fe8568fb0e30a7ea57281ccec
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue