Add vision/image support for local inference models (#8442)

Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
jh-block
2026-04-13 10:17:04 +02:00
committed by GitHub
parent 5fa2a8b821
commit de317d5445
15 changed files with 1181 additions and 88 deletions
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Download, Trash2, X, ChevronDown, ChevronUp, Settings2 } from 'lucide-react';
import { Download, Trash2, X, ChevronDown, ChevronUp, Settings2, Eye } from 'lucide-react';
import { Button } from '../../ui/button';
import { useModelAndProvider } from '../../ModelAndProviderContext';
import { defineMessages, useIntl } from '../../../i18n';
@@ -83,8 +83,57 @@ const i18n = defineMessages({
id: 'localInferenceSettings.modelSettingsTitle',
defaultMessage: 'Model settings',
},
vision: {
id: 'localInferenceSettings.vision',
defaultMessage: 'Vision',
},
visionEncoderDownloading: {
id: 'localInferenceSettings.visionEncoderDownloading',
defaultMessage: 'Vision encoder downloading…',
},
visionEncoderNotDownloaded: {
id: 'localInferenceSettings.visionEncoderNotDownloaded',
defaultMessage: 'Vision encoder not downloaded',
},
});
const VisionBadge = ({ model, intl }: { model: LocalModelResponse; intl: ReturnType<typeof useIntl> }) => {
if (!model.vision_capable) return null;
const mmproj = model.mmproj_status;
const isDownloaded = mmproj?.state === 'Downloaded';
const isDownloading = mmproj?.state === 'Downloading';
if (isDownloaded) {
return (
<span className="inline-flex items-center gap-1 text-xs text-green-400 bg-green-500/10 px-2 py-0.5 rounded">
<Eye className="w-3 h-3" />
{intl.formatMessage(i18n.vision)}
</span>
);
}
if (isDownloading) {
const percent = mmproj && 'progress_percent' in mmproj
? Math.round(mmproj.progress_percent)
: null;
return (
<span className="inline-flex items-center gap-1 text-xs text-yellow-400 bg-yellow-500/10 px-2 py-0.5 rounded">
<Eye className="w-3 h-3" />
{intl.formatMessage(i18n.visionEncoderDownloading)}
{percent != null && ` ${percent}%`}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-xs text-text-muted bg-background-subtle px-2 py-0.5 rounded">
<Eye className="w-3 h-3" />
{intl.formatMessage(i18n.vision)}
</span>
);
};
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
@@ -128,6 +177,19 @@ export const LocalInferenceSettings = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Poll model list while any vision encoder is downloading
useEffect(() => {
const hasDownloadingMmproj = models.some(
(m) => m.vision_capable && m.mmproj_status?.state === 'Downloading'
);
if (!hasDownloadingMmproj) return;
const interval = setInterval(() => {
loadModels();
}, 2000);
return () => clearInterval(interval);
}, [models, loadModels]);
const selectModel = async (modelId: string) => {
try {
await setConfigProvider({
@@ -365,6 +427,7 @@ export const LocalInferenceSettings = () => {
{intl.formatMessage(i18n.recommended)}
</span>
)}
<VisionBadge model={model} intl={intl} />
</div>
<div className="flex items-center gap-1">
<Button
@@ -414,6 +477,7 @@ export const LocalInferenceSettings = () => {
{intl.formatMessage(i18n.recommended)}
</span>
)}
<VisionBadge model={model} intl={intl} />
</div>
</div>
<Button