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
+16
View File
@@ -612,12 +612,14 @@ export type LoadedProvider = {
export type LocalModelResponse = {
filename: string;
id: string;
mmproj_status?: ModelDownloadStatus | null;
quantization: string;
recommended: boolean;
repo_id: string;
settings: ModelSettings;
size_bytes: number;
status: ModelDownloadStatus;
vision_capable: boolean;
};
/**
@@ -821,7 +823,16 @@ export type ModelSettings = {
enable_thinking?: boolean;
flash_attention?: boolean | null;
frequency_penalty?: number;
/**
* Estimated tokens per image for budget planning before mtmd tokenization.
* The actual count is determined after tokenization via `chunks.total_tokens()`.
*/
image_token_estimate?: number;
max_output_tokens?: number | null;
/**
* Size of the mmproj file in bytes, used for memory accounting.
*/
mmproj_size_bytes?: number;
n_batch?: number | null;
n_gpu_layers?: number | null;
n_threads?: number | null;
@@ -832,6 +843,11 @@ export type ModelSettings = {
sampling?: SamplingConfig;
use_jinja?: boolean;
use_mlock?: boolean;
/**
* Whether this model architecture supports vision input.
* Derived from the featured model table, not user-configurable.
*/
vision_capable?: boolean;
};
export type ModelTemplate = {
@@ -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
+9
View File
@@ -1838,6 +1838,15 @@
"localInferenceSettings.title": {
"defaultMessage": "Local Inference Models"
},
"localInferenceSettings.vision": {
"defaultMessage": "Vision"
},
"localInferenceSettings.visionEncoderDownloading": {
"defaultMessage": "Vision encoder downloading\u2026"
},
"localInferenceSettings.visionEncoderNotDownloaded": {
"defaultMessage": "Vision encoder not downloaded"
},
"localModelManager.active": {
"defaultMessage": "Active"
},