diff --git a/app/src/components/chat/ChatComposer.tsx b/app/src/components/chat/ChatComposer.tsx
index 180233ebe..f944f7310 100644
--- a/app/src/components/chat/ChatComposer.tsx
+++ b/app/src/components/chat/ChatComposer.tsx
@@ -81,7 +81,12 @@ export default function ChatComposer({
{
diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx
index 6247a46bb..c5b5d14f9 100644
--- a/app/src/components/settings/panels/AIPanel.tsx
+++ b/app/src/components/settings/panels/AIPanel.tsx
@@ -26,11 +26,14 @@ import {
loadLocalProviderSnapshot,
type LocalProviderSnapshot,
type ModelInfo,
+ type ModelRegistryEntry,
+ modelRegistryVision,
OPENAI_CODEX_OAUTH_MISSING_AUTH_URL,
OPENAI_CODEX_OAUTH_MISSING_CALLBACK_URL,
saveAISettings,
setCloudProviderKey,
testProviderModel,
+ upsertModelRegistryVision,
} from '../../../services/api/aiSettingsApi';
import {
creditsApi,
@@ -144,12 +147,17 @@ const BUILTIN_PROVIDER_META: Record = {
};
const WORKLOADS: Workload[] = [
- { id: 'chat', group: 'chat', label: 'Chat', description: 'Direct conversational back-and-forth' },
+ {
+ id: 'chat',
+ group: 'chat',
+ label: 'Chat',
+ description: 'Direct conversational back-and-forth — “Quick” mode in Conversations',
+ },
{
id: 'reasoning',
group: 'chat',
label: 'Reasoning',
- description: 'Main chat agent, meeting summarizer',
+ description: 'Main chat agent, meeting summarizer — “Reasoning” mode in Conversations',
},
{
id: 'agentic',
@@ -217,7 +225,11 @@ const WORKLOAD_MODEL_HINTS: Record = {
// just derives the `maskedKey` display string from `has_api_key`.
// ─────────────────────────────────────────────────────────────────────────────
-type AISettings = { cloudProviders: CloudProvider[]; routing: RoutingMap };
+type AISettings = {
+ cloudProviders: CloudProvider[];
+ routing: RoutingMap;
+ modelRegistry: ModelRegistryEntry[];
+};
const EMPTY_ROUTING: RoutingMap = {
chat: { kind: 'default' },
@@ -230,7 +242,11 @@ const EMPTY_ROUTING: RoutingMap = {
subconscious: { kind: 'default' },
};
-const EMPTY_SETTINGS: AISettings = { cloudProviders: [], routing: EMPTY_ROUTING };
+const EMPTY_SETTINGS: AISettings = {
+ cloudProviders: [],
+ routing: EMPTY_ROUTING,
+ modelRegistry: [],
+};
function maskKeyLabel(hasKey: boolean): string {
return hasKey ? '•••• configured' : 'Not configured';
@@ -280,7 +296,7 @@ function toPanelRoutingFromApi(api: ApiAISettings): { panel: AISettings } {
learning: liftRef(api.routing.learning),
subconscious: liftRef(api.routing.subconscious),
};
- return { panel: { cloudProviders, routing } };
+ return { panel: { cloudProviders, routing, modelRegistry: api.modelRegistry } };
}
function toApiSettings(panel: AISettings): ApiAISettings {
@@ -303,6 +319,7 @@ function toApiSettings(panel: AISettings): ApiAISettings {
learning: panel.routing.learning,
subconscious: panel.routing.subconscious,
},
+ modelRegistry: panel.modelRegistry,
};
}
@@ -1725,8 +1742,11 @@ interface CustomRoutingDialogProps {
cloudProviders: CloudProvider[];
localModels: OllamaModel[];
ollamaRunning: boolean;
+ /** Current per-model vision registry, used to prefill the vision checkbox. */
+ modelRegistry: ModelRegistryEntry[];
onClose: () => void;
- onSubmit: (next: ProviderRef) => void;
+ /** Emits the chosen provider ref plus the user's vision flag for that model. */
+ onSubmit: (next: ProviderRef, vision: boolean) => void;
}
type CustomDialogSource =
@@ -1811,6 +1831,7 @@ const CustomRoutingDialog = ({
cloudProviders,
localModels,
ollamaRunning,
+ modelRegistry,
onClose,
onSubmit,
}: CustomRoutingDialogProps) => {
@@ -1862,6 +1883,35 @@ const CustomRoutingDialog = ({
: null
);
+ // Registry slug for the selected source — keys the per-model vision flag.
+ // Cloud uses the provider slug; local → `ollama`; claude-code → `claude-code`.
+ const registrySlug =
+ source?.kind === 'cloud'
+ ? source.providerSlug
+ : source?.kind === 'local'
+ ? 'ollama'
+ : source?.kind === 'claude-code'
+ ? 'claude-code'
+ : null;
+
+ // User-set vision flag for this (provider, model). Prefilled from the registry,
+ // re-prefilled whenever the selected provider/model changes.
+ const [vision, setVision] = useState(() =>
+ registrySlug && model.trim()
+ ? modelRegistryVision(modelRegistry, registrySlug, model.trim())
+ : false
+ );
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setVision(
+ registrySlug && model.trim()
+ ? modelRegistryVision(modelRegistry, registrySlug, model.trim())
+ : false
+ );
+ // modelRegistry is stable for the dialog's lifetime (prop doesn't change mid-open).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [registrySlug, model]);
+
const selectedCloud =
source?.kind === 'cloud' ? customCloud.find(c => c.slug === source.providerSlug) : undefined;
@@ -1935,16 +1985,19 @@ const CustomRoutingDialog = ({
if (!source || !canSave) return;
const temp = temperature == null || !Number.isFinite(temperature) ? null : temperature;
if (source.kind === 'cloud') {
- onSubmit({
- kind: 'cloud',
- providerSlug: source.providerSlug,
- model: model.trim(),
- temperature: temp,
- });
+ onSubmit(
+ {
+ kind: 'cloud',
+ providerSlug: source.providerSlug,
+ model: model.trim(),
+ temperature: temp,
+ },
+ vision
+ );
} else if (source.kind === 'claude-code') {
- onSubmit({ kind: 'claude-code', model: model.trim(), temperature: temp });
+ onSubmit({ kind: 'claude-code', model: model.trim(), temperature: temp }, vision);
} else {
- onSubmit({ kind: 'local', model: model.trim(), temperature: temp });
+ onSubmit({ kind: 'local', model: model.trim(), temperature: temp }, vision);
}
};
@@ -2220,6 +2273,26 @@ const CustomRoutingDialog = ({
+ {/* Vision capability (optional). Marks a custom/BYOK model as
+ accepting image input so the chat composer offers image
+ attachments for it. Only shown once a concrete model is chosen. */}
+ {registrySlug && model.trim().length > 0 && (
+
+
+ setVision(e.target.checked)}
+ className="h-3.5 w-3.5 rounded border-stone-300 dark:border-neutral-700 text-primary-500 focus:ring-primary-500"
+ />
+ {t('settings.ai.modelVision')}
+
+
+ {t('settings.ai.modelVisionDesc')}
+
+
+ )}
+
{(testBusy || testReply || testError || testStartedAt) && (
Promise;
+ modelRegistry: ModelRegistryEntry[];
+ onApply: (next: ProviderRef, vision: boolean) => Promise;
}) => {
const { t } = useT();
const customCloud = cloudProviders.filter(p => p.slug !== 'openhuman');
@@ -2385,6 +2460,29 @@ const GlobalOwnModelSelector = ({
const [model, setModel] = useState(
current?.kind === 'cloud' || current?.kind === 'local' ? current.model : ''
);
+ // Registry slug for the selected source — keys the per-model vision flag.
+ const registrySlug =
+ source?.kind === 'cloud'
+ ? source.providerSlug
+ : source?.kind === 'local'
+ ? 'ollama'
+ : source?.kind === 'claude-code'
+ ? 'claude-code'
+ : null;
+ const [vision, setVision] = useState(() =>
+ registrySlug && model.trim()
+ ? modelRegistryVision(modelRegistry, registrySlug, model.trim())
+ : false
+ );
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setVision(
+ registrySlug && model.trim()
+ ? modelRegistryVision(modelRegistry, registrySlug, model.trim())
+ : false
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [registrySlug, model]);
const [cloudModels, setCloudModels] = useState([]);
const [cloudModelsLoading, setCloudModelsLoading] = useState(false);
const [cloudModelsError, setCloudModelsError] = useState(null);
@@ -2453,15 +2551,14 @@ const GlobalOwnModelSelector = ({
setSaving(true);
try {
if (nextSource.kind === 'local') {
- await onApply({ kind: 'local', model: nextModel.trim() });
+ await onApply({ kind: 'local', model: nextModel.trim() }, vision);
} else if (nextSource.kind === 'claude-code') {
- await onApply({ kind: 'claude-code', model: nextModel.trim() });
+ await onApply({ kind: 'claude-code', model: nextModel.trim() }, vision);
} else {
- await onApply({
- kind: 'cloud',
- providerSlug: nextSource.providerSlug,
- model: nextModel.trim(),
- });
+ await onApply(
+ { kind: 'cloud', providerSlug: nextSource.providerSlug, model: nextModel.trim() },
+ vision
+ );
}
} finally {
setSaving(false);
@@ -2566,6 +2663,23 @@ const GlobalOwnModelSelector = ({
) : null}
+ {registrySlug && model.trim().length > 0 && (
+
+ setVision(e.target.checked)}
+ className="mt-0.5 h-3.5 w-3.5 rounded border-stone-300 dark:border-neutral-700 text-primary-500 focus:ring-primary-500"
+ />
+
+ {t('settings.ai.modelVision')}
+
+ {t('settings.ai.modelVisionDesc')}
+
+
+
+ )}
+
{t('settings.ai.globalModel.appliesToAll')}
@@ -3103,8 +3217,23 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
cloudProviders={draft.cloudProviders}
localModels={installed}
ollamaRunning={ollama.state === 'running'}
- onApply={async next => {
- await persist({ ...draft, routing: routingWithAllWorkloads(next) });
+ modelRegistry={draft.modelRegistry}
+ onApply={async (next, vision) => {
+ const reg =
+ next.kind === 'cloud'
+ ? { slug: next.providerSlug, model: next.model }
+ : next.kind === 'local'
+ ? { slug: 'ollama', model: next.model }
+ : next.kind === 'claude-code'
+ ? { slug: 'claude-code', model: next.model }
+ : null;
+ await persist({
+ ...draft,
+ routing: routingWithAllWorkloads(next),
+ modelRegistry: reg
+ ? upsertModelRegistryVision(draft.modelRegistry, reg.slug, reg.model, vision)
+ : draft.modelRegistry,
+ });
}}
/>
) : null}
@@ -3290,11 +3419,24 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
cloudProviders={draft.cloudProviders}
localModels={installed}
ollamaRunning={ollama.state === 'running'}
+ modelRegistry={draft.modelRegistry}
onClose={() => setCustomDialogFor(null)}
- onSubmit={async next => {
+ onSubmit={async (next, vision) => {
+ // (provider slug, model id) the vision flag keys on.
+ const reg =
+ next.kind === 'cloud'
+ ? { slug: next.providerSlug, model: next.model }
+ : next.kind === 'local'
+ ? { slug: 'ollama', model: next.model }
+ : next.kind === 'claude-code'
+ ? { slug: 'claude-code', model: next.model }
+ : null;
const nextDraft = {
...draft,
routing: { ...draft.routing, [customDialogFor]: next },
+ modelRegistry: reg
+ ? upsertModelRegistryVision(draft.modelRegistry, reg.slug, reg.model, vision)
+ : draft.modelRegistry,
};
await persist(nextDraft);
setCustomDialogFor(null);
diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx
index 484fd48c8..40cc7741d 100644
--- a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx
+++ b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx
@@ -15,6 +15,7 @@ import {
setCloudProviderKey,
startOpenAiCodexOAuth,
testProviderModel,
+ upsertModelRegistryVision,
} from '../../../../services/api/aiSettingsApi';
import { creditsApi } from '../../../../services/api/creditsApi';
import { renderWithProviders } from '../../../../test/test-utils';
@@ -45,6 +46,8 @@ vi.mock('../../../../services/api/aiSettingsApi', () => ({
saveAISettings: vi.fn(),
loadLocalProviderSnapshot: vi.fn(),
testProviderModel: vi.fn(),
+ modelRegistryVision: vi.fn(() => false),
+ upsertModelRegistryVision: vi.fn((registry: unknown[]) => registry),
setCloudProviderKey: vi.fn().mockResolvedValue(undefined),
clearCloudProviderKey: vi.fn().mockResolvedValue(undefined),
serializeProviderRef: vi.fn((r: { kind: string; providerSlug?: string; model?: string }) =>
@@ -123,6 +126,7 @@ const baseSettings = {
learning: { kind: 'openhuman' as const },
subconscious: { kind: 'openhuman' as const },
},
+ modelRegistry: [],
};
const baseLocalSnapshot = { status: null, diagnostics: null, presets: null, installedModels: [] };
@@ -304,6 +308,52 @@ describe('AIPanel', () => {
}
});
+ // ─── per-model vision flag (BYOK) ───────────────────────────────────────────
+
+ it('flags a custom BYOK model as vision-capable via the Own-model selector', async () => {
+ vi.mocked(loadAISettings).mockResolvedValue({
+ ...baseSettings,
+ cloudProviders: [
+ ...baseSettings.cloudProviders,
+ {
+ id: 'p_custom_openai',
+ slug: 'openai',
+ label: 'OpenAI',
+ endpoint: 'https://api.openai.com/v1',
+ auth_style: 'bearer' as const,
+ has_api_key: true,
+ },
+ ],
+ });
+ renderWithProviders( );
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /Use Your Own Models/i })).toBeInTheDocument()
+ );
+ fireEvent.click(screen.getByRole('button', { name: /Use Your Own Models/i }));
+
+ // Enter a model id → the per-model "Supports vision" checkbox appears.
+ const modelInput = await screen.findByPlaceholderText('Enter model id');
+ fireEvent.change(modelInput, { target: { value: 'gpt-4o' } });
+
+ const visionCheckbox = await screen.findByRole('checkbox', { name: /Supports vision/i });
+ expect(visionCheckbox).not.toBeChecked();
+ fireEvent.click(visionCheckbox);
+ expect(visionCheckbox).toBeChecked();
+
+ fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
+
+ // The vision flag is threaded through to the registry upsert + persisted.
+ await waitFor(() =>
+ expect(vi.mocked(upsertModelRegistryVision)).toHaveBeenCalledWith(
+ expect.anything(),
+ 'openai',
+ 'gpt-4o',
+ true
+ )
+ );
+ expect(saveAISettings).toHaveBeenCalled();
+ });
+
// ─── auth_style preservation ────────────────────────────────────────────────
it('preserves auth_style: "anthropic" through save when Anthropic provider is configured', async () => {
@@ -333,6 +383,7 @@ describe('AIPanel', () => {
learning: { kind: 'openhuman' as const },
subconscious: { kind: 'openhuman' as const },
},
+ modelRegistry: [],
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithAnthropic);
@@ -557,6 +608,7 @@ describe('AIPanel', () => {
learning: { kind: 'openhuman' as const },
subconscious: { kind: 'openhuman' as const },
},
+ modelRegistry: [],
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
vi.mocked(saveAISettings).mockResolvedValue(undefined);
@@ -612,6 +664,7 @@ describe('AIPanel', () => {
learning: { kind: 'openhuman' as const },
subconscious: { kind: 'openhuman' as const },
},
+ modelRegistry: [],
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOllama);
vi.mocked(saveAISettings).mockResolvedValue(undefined);
@@ -986,6 +1039,7 @@ describe('AIPanel', () => {
has_api_key: false,
},
],
+ modelRegistry: [],
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOllama);
renderWithProviders( );
@@ -1008,6 +1062,7 @@ describe('AIPanel', () => {
has_api_key: false,
},
],
+ modelRegistry: [],
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOllama);
renderWithProviders( );
@@ -1047,6 +1102,7 @@ describe('AIPanel', () => {
...baseSettings.routing,
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
},
+ modelRegistry: [],
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
vi.mocked(saveAISettings).mockResolvedValue(undefined);
@@ -1103,6 +1159,7 @@ describe('AIPanel', () => {
...baseSettings.routing,
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
},
+ modelRegistry: [],
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }]);
@@ -1146,6 +1203,7 @@ describe('AIPanel', () => {
...baseSettings.routing,
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
},
+ modelRegistry: [],
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
@@ -1190,6 +1248,7 @@ describe('AIPanel', () => {
...baseSettings.routing,
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
},
+ modelRegistry: [],
};
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
diff --git a/app/src/lib/attachments.test.ts b/app/src/lib/attachments.test.ts
index 7b10a3a4c..4323ac9a1 100644
--- a/app/src/lib/attachments.test.ts
+++ b/app/src/lib/attachments.test.ts
@@ -85,6 +85,40 @@ describe('validateAndReadFile', () => {
}
});
+ it('rejects non-extractable documents (docx/pptx/xlsx) the agent cannot read', async () => {
+ for (const mime of [
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/zip',
+ ]) {
+ const result = await validateAndReadFile(makeFile('f', mime, 8), 0);
+ expect('error' in result && result.error.code).toBe('unsupported_type');
+ }
+ });
+
+ it('accepts the text-extractable document formats (pdf/txt/markdown)', async () => {
+ for (const mime of ['application/pdf', 'text/plain', 'text/markdown']) {
+ const result = await validateAndReadFile(makeFile('f', mime, 8), 0);
+ expect('attachment' in result && result.attachment.kind === 'file').toBe(true);
+ }
+ });
+
+ it('rejects an image when allowImages is false (non-vision model)', async () => {
+ const result = await validateAndReadFile(makeFile('p.png', 'image/png', 8), 0, 0, false);
+ expect('error' in result && result.error.code).toBe('image_not_supported');
+ });
+
+ it('still accepts a document when allowImages is false', async () => {
+ const result = await validateAndReadFile(makeFile('d.pdf', 'application/pdf', 8), 0, 0, false);
+ expect('attachment' in result && result.attachment.kind === 'file').toBe(true);
+ });
+
+ it('accepts an image when allowImages is true (vision model)', async () => {
+ const result = await validateAndReadFile(makeFile('p.png', 'image/png', 8), 0, 0, true);
+ expect('attachment' in result && result.attachment.kind === 'image').toBe(true);
+ });
+
it('rejects files that exceed the file size limit', async () => {
const oversizedFile = makeFile(
'big.pdf',
diff --git a/app/src/lib/attachments.ts b/app/src/lib/attachments.ts
index 60c5a9f6d..4b6a92382 100644
--- a/app/src/lib/attachments.ts
+++ b/app/src/lib/attachments.ts
@@ -41,6 +41,17 @@ export const ALLOWED_ATTACHMENT_MIME_TYPES = [
...ALLOWED_FILE_MIME_TYPES,
] as const;
+// Document formats the backend actually text-extracts (PDF via pdf_extract;
+// TXT/Markdown via UTF-8). DOCX/PPTX/XLSX/ZIP are intentionally excluded — the
+// agent would only see a reference stub, not their content. `text/csv` is also
+// deliberately left out: the backend *can* extract it, but the chat composer is
+// scoped to PDF/TXT/Markdown by product decision (revisit here if CSV is wanted).
+// Used by the ingest validator below, not by a native `accept` filter:
+// Chromium/CEF on macOS greys valid files at the open panel regardless of the
+// filter shape, so selection is gated in `validateAndReadFile` after the user
+// picks, not at the dialog.
+const EXTRACTABLE_FILE_MIME_TYPES = ['application/pdf', 'text/plain', 'text/markdown'] as const;
+
export const ATTACHMENT_MAX_IMAGES = 4;
export const ATTACHMENT_MAX_FILES = 4;
export const ATTACHMENT_MAX_IMAGE_SIZE_BYTES = 8 * 1024 * 1024; // 8 MB
@@ -63,6 +74,7 @@ export type AttachmentError =
| { code: 'unsupported_type'; mimeType: string }
| { code: 'too_large'; sizeBytes: number; maxBytes: number }
| { code: 'too_many'; kind: AttachmentKind; max: number }
+ | { code: 'image_not_supported' }
| { code: 'read_failed'; reason: string };
export function isAllowedMimeType(mime: string): mime is AllowedImageMimeType {
@@ -73,6 +85,29 @@ export function isAllowedAttachmentMimeType(mime: string): mime is AllowedAttach
return (ALLOWED_ATTACHMENT_MIME_TYPES as readonly string[]).includes(mime);
}
+/**
+ * The exact MIME set the ingest validator accepts — images plus the
+ * text-extractable documents. A strict subset of {@link AllowedAttachmentMimeType}
+ * (which also lists reference-only types like CSV/DOCX/ZIP that we reject).
+ */
+export type SupportedAttachmentMimeType =
+ | AllowedImageMimeType
+ | (typeof EXTRACTABLE_FILE_MIME_TYPES)[number];
+
+/**
+ * Stricter gate than {@link isAllowedAttachmentMimeType}: only the formats the
+ * backend actually reads — images, plus the text-extractable documents (PDF via
+ * pdf_extract; TXT/Markdown via UTF-8). DOCX/PPTX/XLSX/ZIP are excluded so they
+ * can't be attached as content-less reference stubs. Applied on every ingest
+ * path (picker, drag-drop, paste).
+ */
+export function isSupportedAttachmentMimeType(mime: string): mime is SupportedAttachmentMimeType {
+ return (
+ (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(mime) ||
+ (EXTRACTABLE_FILE_MIME_TYPES as readonly string[]).includes(mime)
+ );
+}
+
export function attachmentKindForMime(mime: AllowedAttachmentMimeType): AttachmentKind {
return isAllowedMimeType(mime) ? 'image' : 'file';
}
@@ -148,13 +183,20 @@ async function buildAttachmentDataUri(
export async function validateAndReadFile(
file: File,
existingCount: number,
- existingFileCount = 0
+ existingFileCount = 0,
+ // When `false` (the active chat model isn't vision-capable), image files are
+ // rejected; documents (PDF/Word/etc.) still flow. Defaults `true` so non-chat
+ // callers are unaffected.
+ allowImages = true
): Promise<{ attachment: Attachment } | { error: AttachmentError }> {
- if (!isAllowedAttachmentMimeType(file.type)) {
+ if (!isSupportedAttachmentMimeType(file.type)) {
return { error: { code: 'unsupported_type', mimeType: file.type || 'unknown' } };
}
const kind = attachmentKindForMime(file.type);
+ if (!allowImages && kind === 'image') {
+ return { error: { code: 'image_not_supported' } };
+ }
const maxCount = kind === 'image' ? ATTACHMENT_MAX_IMAGES : ATTACHMENT_MAX_FILES;
const count = kind === 'image' ? existingCount : existingFileCount;
if (count >= maxCount) {
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 703e7df66..de9dfb86e 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -1822,7 +1822,10 @@ const messages: TranslationMap = {
'chat.attachment.tooMany': 'الحد الأقصى {max} صور لكل رسالة',
'chat.attachment.tooManyFiles': 'الحد الأقصى {max} ملفات لكل رسالة',
'chat.attachment.tooLarge': 'حجم الصورة يتجاوز الحد المسموح {max}',
- 'chat.attachment.unsupportedType': 'نوع ملف غير مدعوم. استخدم PNG أو JPEG أو WebP أو GIF أو BMP.',
+ 'chat.attachment.unsupportedType':
+ 'نوع ملف غير مدعوم. استخدم صورة (PNG أو JPEG أو WebP أو GIF أو BMP) أو ملف PDF أو TXT أو Markdown.',
+ 'chat.attachment.imageNotSupported':
+ 'لا يمكن لهذا النموذج قراءة الصور. أرفق ملف PDF أو TXT أو Markdown بدلاً من ذلك.',
'chat.attachment.readFailed': 'تعذر قراءة الملف',
'memory.searchAria': 'البحث في الذاكرة',
'memory.searchPlaceholder': 'البحث في إدخالات الذاكرة...',
@@ -3046,6 +3049,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'تجاوز درجة الحرارة (القيمة)',
'settings.ai.temperatureOverrideDesc':
'أقل = أكثر تحديدا. إتركْ غير مُمْكَنَّلَ لإسْتِعْمال قَبْل المُقدّمِ.',
+ 'settings.ai.modelVision': 'يدعم الرؤية (إدخال الصور)',
+ 'settings.ai.modelVisionDesc':
+ 'فعّل هذا إذا كان النموذج يقبل الصور. يتيح لمربع الدردشة إرفاق الصور عند اختيار هذا النموذج.',
'settings.ai.testFailed': 'فشل الاختبار',
'settings.ai.testingModel': 'نموذج الاختبار...',
'settings.ai.modelResponse': 'استجابة النموذج',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index c4d4bd524..3c6b69e1e 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -1865,7 +1865,9 @@ const messages: TranslationMap = {
'chat.attachment.tooManyFiles': 'প্রতি বার্তায় সর্বোচ্চ {max}টি ফাইল',
'chat.attachment.tooLarge': 'ছবি {max} আকারের সীমা অতিক্রম করেছে',
'chat.attachment.unsupportedType':
- 'অসমর্থিত ফাইল প্রকার। PNG, JPEG, WebP, GIF, বা BMP ব্যবহার করুন।',
+ 'অসমর্থিত ফাইল প্রকার। একটি ছবি (PNG, JPEG, WebP, GIF, BMP) অথবা একটি PDF, TXT, বা Markdown ফাইল ব্যবহার করুন।',
+ 'chat.attachment.imageNotSupported':
+ 'এই মডেলটি ছবি পড়তে পারে না। পরিবর্তে একটি PDF, TXT, বা Markdown ফাইল সংযুক্ত করুন।',
'chat.attachment.readFailed': 'ফাইল পড়া যায়নি',
'memory.searchAria': 'মেমোরি খুঁজুন',
'memory.searchPlaceholder': 'মেমোরি এন্ট্রি খুঁজুন...',
@@ -3112,6 +3114,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'তাপমাত্রা ওভাররাইড (মান)',
'settings.ai.temperatureOverrideDesc':
'নিচে = আরও বুদ্ধিমান. পরিসেবা উপলব্ধকারীর ডিফল্ট ব্যবহারের জন্য সীমা ধার্য না করা হলে, বন্ধ করুন ।',
+ 'settings.ai.modelVision': 'ভিশন সমর্থন করে (ছবি ইনপুট)',
+ 'settings.ai.modelVisionDesc':
+ 'এই মডেল ছবি গ্রহণ করলে সক্ষম করুন। এই মডেল নির্বাচিত থাকলে চ্যাটে ছবি সংযুক্ত করতে দেয়।',
'settings.ai.testFailed': 'পরীক্ষা ব্যর্থ হয়েছে',
'settings.ai.testingModel': 'পরীক্ষার মডেল...',
'settings.ai.modelResponse': 'মডেল প্রতিক্রিয়া',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index c2dcd5702..79d1c59a5 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -1912,7 +1912,9 @@ const messages: TranslationMap = {
'chat.attachment.tooManyFiles': 'Maximal {max} Dateien pro Nachricht',
'chat.attachment.tooLarge': 'Bild überschreitet die Größenbeschränkung von {max}',
'chat.attachment.unsupportedType':
- 'Nicht unterstützter Dateityp. Verwenden Sie PNG, JPEG, WebP, GIF oder BMP.',
+ 'Nicht unterstützter Dateityp. Verwenden Sie ein Bild (PNG, JPEG, WebP, GIF, BMP) oder eine PDF-, TXT- oder Markdown-Datei.',
+ 'chat.attachment.imageNotSupported':
+ 'Dieses Modell kann keine Bilder lesen. Hängen Sie stattdessen eine PDF-, TXT- oder Markdown-Datei an.',
'chat.attachment.readFailed': 'Datei konnte nicht gelesen werden',
'memory.searchAria': 'Speicher durchsuchen',
'memory.searchPlaceholder': 'Speichereinträge durchsuchen...',
@@ -3191,6 +3193,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'Temperatur-Override (Wert)',
'settings.ai.temperatureOverrideDesc':
'Niedriger = deterministischer. Deaktivieren Sie diese Option, um den Anbieterstandard zu verwenden.',
+ 'settings.ai.modelVision': 'Unterstützt Bildeingabe (Vision)',
+ 'settings.ai.modelVisionDesc':
+ 'Aktivieren, wenn dieses Modell Bilder akzeptiert. Ermöglicht das Anhängen von Bildern im Chat, wenn dieses Modell ausgewählt ist.',
'settings.ai.testFailed': 'Test fehlgeschlagen.',
'settings.ai.testingModel': 'Modell wird getestet...',
'settings.ai.modelResponse': 'Modellantwort',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index e31fe8f87..d3b294ca5 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -2238,7 +2238,10 @@ const en: TranslationMap = {
'chat.attachment.tooMany': 'Maximum {max} images per message',
'chat.attachment.tooManyFiles': 'Maximum {max} files per message',
'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
- 'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
+ 'chat.attachment.unsupportedType':
+ 'Unsupported file type. Use an image (PNG, JPEG, WebP, GIF, BMP) or a PDF, TXT, or Markdown file.',
+ 'chat.attachment.imageNotSupported':
+ 'This model can’t read images. You can attach a pdf, txt, or md file.',
'chat.attachment.readFailed': 'Could not read file',
// Memory (additional)
@@ -3633,6 +3636,9 @@ const en: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'Temperature override (value)',
'settings.ai.temperatureOverrideDesc':
'Lower = more deterministic. Leave unchecked to use the provider default.',
+ 'settings.ai.modelVision': 'Supports vision (image input)',
+ 'settings.ai.modelVisionDesc':
+ 'Enable if this model accepts images. Lets the chat composer attach images when this model is selected.',
'settings.ai.testFailed': 'Test failed',
'settings.ai.testingModel': 'Testing model...',
'settings.ai.modelResponse': 'Model response',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 14ea2959f..fc9e1e279 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -1904,7 +1904,9 @@ const messages: TranslationMap = {
'chat.attachment.tooManyFiles': 'Máximo {max} archivos por mensaje',
'chat.attachment.tooLarge': 'La imagen supera el límite de tamaño de {max}',
'chat.attachment.unsupportedType':
- 'Tipo de archivo no compatible. Use PNG, JPEG, WebP, GIF o BMP.',
+ 'Tipo de archivo no compatible. Use una imagen (PNG, JPEG, WebP, GIF, BMP) o un archivo PDF, TXT o Markdown.',
+ 'chat.attachment.imageNotSupported':
+ 'Este modelo no puede leer imágenes. Adjunta un archivo PDF, TXT o Markdown en su lugar.',
'chat.attachment.readFailed': 'No se pudo leer el archivo',
'memory.searchAria': 'Buscar en memoria',
'memory.searchPlaceholder': 'Buscar entradas de memoria...',
@@ -3173,6 +3175,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'Anulación de temperatura (valor)',
'settings.ai.temperatureOverrideDesc':
'Más bajo = más determinista. Déjelo sin marcar para usar el valor predeterminado del proveedor.',
+ 'settings.ai.modelVision': 'Admite visión (entrada de imágenes)',
+ 'settings.ai.modelVisionDesc':
+ 'Actívalo si este modelo acepta imágenes. Permite adjuntar imágenes en el chat cuando este modelo está seleccionado.',
'settings.ai.testFailed': 'Prueba fallida',
'settings.ai.testingModel': 'Modelo de prueba...',
'settings.ai.modelResponse': 'Respuesta modelo',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index a5a9a870d..2d744511b 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -1912,7 +1912,9 @@ const messages: TranslationMap = {
'chat.attachment.tooManyFiles': 'Maximum {max} fichiers par message',
'chat.attachment.tooLarge': "L'image dépasse la taille limite de {max}",
'chat.attachment.unsupportedType':
- 'Type de fichier non pris en charge. Utilisez PNG, JPEG, WebP, GIF ou BMP.',
+ 'Type de fichier non pris en charge. Utilise une image (PNG, JPEG, WebP, GIF, BMP) ou un fichier PDF, TXT ou Markdown.',
+ 'chat.attachment.imageNotSupported':
+ 'Ce modèle ne peut pas lire les images. Joins plutôt un fichier PDF, TXT ou Markdown.',
'chat.attachment.readFailed': 'Impossible de lire le fichier',
'memory.searchAria': 'Rechercher dans la mémoire',
'memory.searchPlaceholder': 'Rechercher des entrées de mémoire…',
@@ -3184,6 +3186,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'Override de température (valeur)',
'settings.ai.temperatureOverrideDesc':
'Plus bas = plus déterministe. Laissez décoché pour utiliser la valeur par défaut du fournisseur.',
+ 'settings.ai.modelVision': 'Prend en charge la vision (entrée d’images)',
+ 'settings.ai.modelVisionDesc':
+ 'Activez si ce modèle accepte les images. Permet de joindre des images dans le chat lorsque ce modèle est sélectionné.',
'settings.ai.testFailed': 'Test échoué',
'settings.ai.testingModel': 'Modèle de test...',
'settings.ai.modelResponse': 'Réponse du modèle',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 9ecaaf4ee..91c7369b1 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -1864,7 +1864,9 @@ const messages: TranslationMap = {
'chat.attachment.tooManyFiles': 'प्रति संदेश अधिकतम {max} फ़ाइलें',
'chat.attachment.tooLarge': 'छवि {max} आकार सीमा से अधिक है',
'chat.attachment.unsupportedType':
- 'असमर्थित फ़ाइल प्रकार। PNG, JPEG, WebP, GIF, या BMP का उपयोग करें।',
+ 'असमर्थित फ़ाइल प्रकार। कोई छवि (PNG, JPEG, WebP, GIF, BMP) या PDF, TXT, या Markdown फ़ाइल का उपयोग करें।',
+ 'chat.attachment.imageNotSupported':
+ 'यह मॉडल छवियाँ नहीं पढ़ सकता। इसके बजाय कोई PDF, TXT या Markdown फ़ाइल संलग्न करें।',
'chat.attachment.readFailed': 'फ़ाइल पढ़ नहीं सकी',
'memory.searchAria': 'मेमोरी सर्च करें',
'memory.searchPlaceholder': 'मेमोरी एंट्रीज़ सर्च करें...',
@@ -3120,6 +3122,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'तापमान ओवरराइड (मान)',
'settings.ai.temperatureOverrideDesc':
'लोअर = अधिक नियतिवादी। प्रदाता डिफ़ॉल्ट का उपयोग करने के लिए अनचेक छोड़ दें।',
+ 'settings.ai.modelVision': 'विज़न समर्थित है (छवि इनपुट)',
+ 'settings.ai.modelVisionDesc':
+ 'यदि यह मॉडल छवियाँ स्वीकार करता है तो सक्षम करें। यह मॉडल चुने जाने पर चैट में छवियाँ संलग्न करने देता है।',
'settings.ai.testFailed': 'परीक्षण विफल रहा',
'settings.ai.testingModel': 'परीक्षण मॉडल...',
'settings.ai.modelResponse': 'मॉडल प्रतिक्रिया',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index ba0b6aa03..f9b131c3e 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -1867,7 +1867,9 @@ const messages: TranslationMap = {
'chat.attachment.tooManyFiles': 'Maksimal {max} file per pesan',
'chat.attachment.tooLarge': 'Gambar melebihi batas ukuran {max}',
'chat.attachment.unsupportedType':
- 'Jenis file tidak didukung. Gunakan PNG, JPEG, WebP, GIF, atau BMP.',
+ 'Jenis file tidak didukung. Gunakan gambar (PNG, JPEG, WebP, GIF, BMP) atau file PDF, TXT, atau Markdown.',
+ 'chat.attachment.imageNotSupported':
+ 'Model ini tidak dapat membaca gambar. Lampirkan file PDF, TXT, atau Markdown sebagai gantinya.',
'chat.attachment.readFailed': 'Tidak dapat membaca file',
'memory.searchAria': 'Cari memori',
'memory.searchPlaceholder': 'Cari entri memori...',
@@ -3124,6 +3126,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'Penggantian suhu (nilai)',
'settings.ai.temperatureOverrideDesc':
'Turunkan = lebih deterministik. Biarkan tak diperiksa untuk memakai penyedia bawaan.',
+ 'settings.ai.modelVision': 'Mendukung visi (input gambar)',
+ 'settings.ai.modelVisionDesc':
+ 'Aktifkan jika model ini menerima gambar. Memungkinkan penyusun obrolan melampirkan gambar saat model ini dipilih.',
'settings.ai.testFailed': 'Pengujian gagal',
'settings.ai.testingModel': 'Pengujian model...',
'settings.ai.modelResponse': 'Respons model',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index a05ca1520..cb63022a2 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -1895,7 +1895,10 @@ const messages: TranslationMap = {
'chat.attachment.tooMany': 'Massimo {max} immagini per messaggio',
'chat.attachment.tooManyFiles': 'Massimo {max} file per messaggio',
'chat.attachment.tooLarge': "L'immagine supera il limite di dimensione di {max}",
- 'chat.attachment.unsupportedType': 'Tipo di file non supportato. Usa PNG, JPEG, WebP, GIF o BMP.',
+ 'chat.attachment.unsupportedType':
+ 'Tipo di file non supportato. Usa un file immagine (PNG, JPEG, WebP, GIF, BMP) oppure PDF, TXT o Markdown.',
+ 'chat.attachment.imageNotSupported':
+ 'Questo modello non può leggere le immagini. Allega invece un file PDF, TXT o Markdown.',
'chat.attachment.readFailed': 'Impossibile leggere il file',
'memory.searchAria': 'Cerca memoria',
'memory.searchPlaceholder': 'Cerca voci di memoria...',
@@ -3166,6 +3169,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'Ignora temperatura (valore)',
'settings.ai.temperatureOverrideDesc':
'Più basso = più deterministico. Lascia deselezionato per usare il valore predefinito del provider.',
+ 'settings.ai.modelVision': 'Supporta la visione (input immagini)',
+ 'settings.ai.modelVisionDesc':
+ 'Attiva se questo modello accetta immagini. Consente di allegare immagini nella chat quando è selezionato questo modello.',
'settings.ai.testFailed': 'Test non riuscito',
'settings.ai.testingModel': 'Test del modello...',
'settings.ai.modelResponse': 'Risposta del modello',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index c24a3311f..e47bb198d 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -1844,7 +1844,9 @@ const messages: TranslationMap = {
'chat.attachment.tooManyFiles': '메시지당 최대 {max}개 파일',
'chat.attachment.tooLarge': '이미지가 {max} 크기 제한을 초과합니다',
'chat.attachment.unsupportedType':
- '지원되지 않는 파일 형식입니다. PNG, JPEG, WebP, GIF 또는 BMP를 사용하세요.',
+ '지원되지 않는 파일 형식입니다. 이미지(PNG, JPEG, WebP, GIF, BMP) 또는 PDF, TXT, Markdown 파일을 사용하세요.',
+ 'chat.attachment.imageNotSupported':
+ '이 모델은 이미지를 읽을 수 없습니다. 대신 PDF, TXT 또는 Markdown 파일을 첨부하세요.',
'chat.attachment.readFailed': '파일을 읽을 수 없습니다',
'memory.searchAria': '메모리 검색',
'memory.searchPlaceholder': '메모리 항목 검색...',
@@ -3086,6 +3088,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': '온도 재정의(값)',
'settings.ai.temperatureOverrideDesc':
'낮을수록 더 결정적입니다. 제공업체 기본값을 사용하려면 선택하지 않은 상태로 두세요.',
+ 'settings.ai.modelVision': '비전 지원 (이미지 입력)',
+ 'settings.ai.modelVisionDesc':
+ '이 모델이 이미지를 지원하면 활성화하세요. 이 모델을 선택하면 채팅 작성기에서 이미지를 첨부할 수 있습니다.',
'settings.ai.testFailed': '테스트 실패',
'settings.ai.testingModel': '모델 테스트 중...',
'settings.ai.modelResponse': '모델 응답',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index ce859d42c..0f77be8aa 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -1884,7 +1884,10 @@ const messages: TranslationMap = {
'chat.attachment.tooMany': 'Maksymalnie {max} obrazów na wiadomość',
'chat.attachment.tooManyFiles': 'Maksymalnie {max} plików na wiadomość',
'chat.attachment.tooLarge': 'Obraz przekracza limit rozmiaru {max}',
- 'chat.attachment.unsupportedType': 'Nieobsługiwany typ pliku. Użyj PNG, JPEG, WebP, GIF lub BMP.',
+ 'chat.attachment.unsupportedType':
+ 'Nieobsługiwany typ pliku. Użyj obrazu (PNG, JPEG, WebP, GIF, BMP) lub pliku PDF, TXT albo Markdown.',
+ 'chat.attachment.imageNotSupported':
+ 'Ten model nie może odczytywać obrazów. Zamiast tego dołącz plik PDF, TXT lub Markdown.',
'chat.attachment.readFailed': 'Nie można odczytać pliku',
'memory.searchAria': 'Szukaj w pamięci',
'memory.searchPlaceholder': 'Szukaj wpisów pamięci...',
@@ -3166,6 +3169,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'Nadpisanie temperatury (wartość)',
'settings.ai.temperatureOverrideDesc':
'Niższa = bardziej deterministyczna. Pozostaw niezaznaczone, aby użyć domyślnej wartości dostawcy.',
+ 'settings.ai.modelVision': 'Obsługuje wizję (wejście obrazu)',
+ 'settings.ai.modelVisionDesc':
+ 'Włącz, jeśli ten model akceptuje obrazy. Pozwala dołączać obrazy w czacie, gdy ten model jest wybrany.',
'settings.ai.testFailed': 'Test nie powiódł się',
'settings.ai.testingModel': 'Testowanie modelu...',
'settings.ai.modelResponse': 'Odpowiedź modelu',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index eb9ee7f3c..8fe9443a1 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -1903,7 +1903,9 @@ const messages: TranslationMap = {
'chat.attachment.tooManyFiles': 'Máximo de {max} arquivos por mensagem',
'chat.attachment.tooLarge': 'A imagem excede o limite de tamanho de {max}',
'chat.attachment.unsupportedType':
- 'Tipo de arquivo não suportado. Use PNG, JPEG, WebP, GIF ou BMP.',
+ 'Tipo de arquivo não suportado. Use uma imagem (PNG, JPEG, WebP, GIF, BMP) ou um arquivo PDF, TXT ou Markdown.',
+ 'chat.attachment.imageNotSupported':
+ 'Este modelo não consegue ler imagens. Anexe um arquivo PDF, TXT ou Markdown.',
'chat.attachment.readFailed': 'Não foi possível ler o arquivo',
'memory.searchAria': 'Pesquisar memória',
'memory.searchPlaceholder': 'Pesquisar entradas de memória...',
@@ -3169,6 +3171,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'Substituição de temperatura (valor)',
'settings.ai.temperatureOverrideDesc':
'Mais baixo = mais determinístico. Deixe desmarcado para usar o padrão do provedor.',
+ 'settings.ai.modelVision': 'Suporta visão (entrada de imagem)',
+ 'settings.ai.modelVisionDesc':
+ 'Ative se este modelo aceitar imagens. Permite anexar imagens no chat quando este modelo está selecionado.',
'settings.ai.testFailed': 'Teste falhou',
'settings.ai.testingModel': 'Modelo de teste...',
'settings.ai.modelResponse': 'Resposta do modelo',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index d90e51271..085b6bd49 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -1879,7 +1879,9 @@ const messages: TranslationMap = {
'chat.attachment.tooManyFiles': 'Максимум {max} файлов на сообщение',
'chat.attachment.tooLarge': 'Изображение превышает ограничение размера {max}',
'chat.attachment.unsupportedType':
- 'Неподдерживаемый тип файла. Используйте PNG, JPEG, WebP, GIF или BMP.',
+ 'Неподдерживаемый тип файла. Используйте изображение (PNG, JPEG, WebP, GIF, BMP) или файл PDF, TXT либо Markdown.',
+ 'chat.attachment.imageNotSupported':
+ 'Эта модель не может читать изображения. Вместо этого прикрепите файл PDF, TXT или Markdown.',
'chat.attachment.readFailed': 'Не удалось прочитать файл',
'memory.searchAria': 'Поиск в памяти',
'memory.searchPlaceholder': 'Поиск записей памяти...',
@@ -3140,6 +3142,9 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideValue': 'Переопределение температуры (значение)',
'settings.ai.temperatureOverrideDesc':
'Ниже = более детерминированно. Оставьте флажок неактивным, чтобы использовать поставщика по умолчанию.',
+ 'settings.ai.modelVision': 'Поддерживает зрение (ввод изображений)',
+ 'settings.ai.modelVisionDesc':
+ 'Включите, если модель принимает изображения. Позволяет прикреплять изображения в чате, когда выбрана эта модель.',
'settings.ai.testFailed': 'Тест не пройден.',
'settings.ai.testingModel': 'Модель тестирования...',
'settings.ai.modelResponse': 'Ответ модели',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index 692dae6ed..cf60d7fd2 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -1760,7 +1760,9 @@ const messages: TranslationMap = {
'chat.attachment.tooMany': '每条消息最多 {max} 张图片',
'chat.attachment.tooManyFiles': '每条消息最多 {max} 个文件',
'chat.attachment.tooLarge': '图片超过 {max} 大小限制',
- 'chat.attachment.unsupportedType': '不支持的文件类型。请使用 PNG、JPEG、WebP、GIF 或 BMP。',
+ 'chat.attachment.unsupportedType':
+ '不支持的文件类型。请使用图片(PNG、JPEG、WebP、GIF、BMP)或 PDF、TXT、Markdown 文件。',
+ 'chat.attachment.imageNotSupported': '此模型无法读取图像。请改为附加 PDF、TXT 或 Markdown 文件。',
'chat.attachment.readFailed': '无法读取文件',
'memory.searchAria': '搜索记忆',
'memory.searchPlaceholder': '搜索记忆条目...',
@@ -2955,6 +2957,8 @@ const messages: TranslationMap = {
'settings.ai.temperatureOverrideSlider': '温度超控(滑块)',
'settings.ai.temperatureOverrideValue': '温度超控(值)',
'settings.ai.temperatureOverrideDesc': '值越低,结果越确定。保持未勾选则使用提供商默认值。',
+ 'settings.ai.modelVision': '支持视觉(图像输入)',
+ 'settings.ai.modelVisionDesc': '如果此模型接受图像,请启用。选择此模型后可在聊天框中附加图像。',
'settings.ai.testFailed': '测试失败',
'settings.ai.testingModel': '测试模型...',
'settings.ai.modelResponse': '模型响应',
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 7a5601030..8fb2e7547 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -17,7 +17,6 @@ import MicComposer from '../features/human/MicComposer';
import { useStickToBottom } from '../hooks/useStickToBottom';
import { useUsageState } from '../hooks/useUsageState';
import {
- ALLOWED_ATTACHMENT_MIME_TYPES,
type Attachment,
ATTACHMENT_MAX_FILES,
ATTACHMENT_MAX_IMAGES,
@@ -270,6 +269,11 @@ const Conversations = ({
onCancel: () => {},
});
const [resolvedModel, setResolvedModel] = useState(null);
+ // Whether the resolved model for the active profile accepts image input.
+ // Managed tiers do; custom/BYOK models only when the user flagged them. Gates
+ // the composer's image-attachment affordance (docs flow regardless). Resolved
+ // against the non-attachment hint so the affordance is stable as you attach.
+ const [modelSupportsVision, setModelSupportsVision] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -280,13 +284,19 @@ const Conversations = ({
attachments.length > 0
? MULTIMODAL_MODEL_HINT
: (profile?.modelOverride ?? CHAT_MODEL_HINT);
- const res = await callCoreRpc<{ model: string }>({
+ const res = await callCoreRpc<{ model: string; vision?: boolean }>({
method: 'openhuman.inference_resolve_model',
params: { hint },
});
- if (!cancelled) setResolvedModel(res.model);
+ if (!cancelled) {
+ setResolvedModel(res.model);
+ setModelSupportsVision(res.vision === true);
+ }
} catch {
- if (!cancelled) setResolvedModel(null);
+ if (!cancelled) {
+ setResolvedModel(null);
+ setModelSupportsVision(false);
+ }
}
})();
return () => {
@@ -680,10 +690,19 @@ const Conversations = ({
let acceptedImageCount = attachments.filter(attachment => attachment.kind === 'image').length;
let acceptedFileCount = attachments.filter(attachment => attachment.kind === 'file').length;
for (const file of Array.from(files)) {
- const result = await validateAndReadFile(file, acceptedImageCount, acceptedFileCount);
+ const result = await validateAndReadFile(
+ file,
+ acceptedImageCount,
+ acceptedFileCount,
+ modelSupportsVision
+ );
if ('error' in result) {
const { error } = result;
- if (error.code === 'too_many') {
+ if (error.code === 'image_not_supported') {
+ setAttachError(
+ chatSendError('attachment_invalid', t('chat.attachment.imageNotSupported'))
+ );
+ } else if (error.code === 'too_many') {
const key =
error.kind === 'image' ? 'chat.attachment.tooMany' : 'chat.attachment.tooManyFiles';
setAttachError(
@@ -1724,6 +1743,18 @@ const Conversations = ({
? (msg.extraMetadata.attachmentDataUris as string[])
: parseMessageImages(msg.content ?? '').dataUris;
const hasImages = dataUris.length > 0;
+ // Document attachments carry no image data-URI (only
+ // images do); surface them as filename chips from the
+ // persisted attachmentKinds/attachmentNames metadata.
+ const kinds = Array.isArray(msg.extraMetadata?.attachmentKinds)
+ ? (msg.extraMetadata.attachmentKinds as string[])
+ : [];
+ const names = Array.isArray(msg.extraMetadata?.attachmentNames)
+ ? (msg.extraMetadata.attachmentNames as string[])
+ : [];
+ const fileNames = kinds
+ .map((k, i) => (k === 'file' ? names[i] : null))
+ .filter((n): n is string => Boolean(n));
const showTime = latestVisibleMessage?.id === msg.id;
return (
<>
@@ -1739,6 +1770,35 @@ const Conversations = ({
))}
)}
+ {fileNames.length > 0 && (
+
+ {fileNames.map((name, i) => (
+
+ ))}
+
+ )}
{(msg.content || showTime) && (
{msg.content && (
@@ -2213,7 +2273,10 @@ const Conversations = ({
inlineCompletionSuffix={inlineCompletionSuffix}
isComposingTextRef={isComposingTextRef}
maxAttachments={ATTACHMENT_MAX_IMAGES + ATTACHMENT_MAX_FILES}
- allowedMimeTypes={ALLOWED_ATTACHMENT_MIME_TYPES}
+ // Empty → no native `accept` filter (it greys valid files on
+ // macOS/CEF). Type enforcement happens in handleAttachFiles via
+ // validateAndReadFile, which honors modelSupportsVision.
+ allowedMimeTypes={[]}
attachmentsEnabled={CHAT_ATTACHMENTS_ENABLED}
/>
) : (
diff --git a/app/src/pages/__tests__/Conversations.attachments.test.tsx b/app/src/pages/__tests__/Conversations.attachments.test.tsx
index 38923907d..9052b16b4 100644
--- a/app/src/pages/__tests__/Conversations.attachments.test.tsx
+++ b/app/src/pages/__tests__/Conversations.attachments.test.tsx
@@ -140,6 +140,18 @@ vi.mock('../../features/autocomplete/useAutocompleteSkillStatus', () => ({
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
+// The composer gates image attachments on the resolved model's vision capability
+// (inference_resolve_model). These tests exercise image attachments, so resolve
+// the active model as vision-capable.
+vi.mock('../../services/coreRpcClient', () => ({
+ callCoreRpc: vi.fn(async ({ method }: { method: string }) =>
+ method === 'openhuman.inference_resolve_model'
+ ? { model: 'test-vision-model', vision: true }
+ : {}
+ ),
+ CoreRpcError: class CoreRpcError extends Error {},
+}));
+
vi.mock('../../lib/coreState/store', () => ({
getCoreStateSnapshot: vi.fn(() => ({
isBootstrapping: false,
@@ -506,4 +518,53 @@ describe('Conversations — attachment feature', () => {
expect(img).not.toBeNull();
});
});
+
+ it('renders a document filename chip in the user bubble from attachmentKinds/Names', async () => {
+ const thread = makeThread({ id: 'file-thread', title: 'File Thread' });
+ const message = {
+ id: 'msg-file-1',
+ content: 'whats in this file',
+ type: 'text' as const,
+ sender: 'user' as const,
+ createdAt: new Date().toISOString(),
+ extraMetadata: {
+ attachmentCount: 1,
+ attachmentKinds: ['file'],
+ attachmentNames: ['report.pdf'],
+ },
+ };
+
+ mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
+ mockGetThreadMessages.mockResolvedValue({ messages: [message], count: 1 });
+
+ const store = buildStore({
+ thread: {
+ threads: [thread],
+ selectedThreadId: thread.id,
+ activeThreadId: null,
+ welcomeThreadId: null,
+ messagesByThreadId: { [thread.id]: [message] },
+ messages: [message],
+ isLoadingThreads: false,
+ isLoadingMessages: false,
+ messagesError: null,
+ },
+ socket: socketState('connected'),
+ });
+
+ const { default: Conversations } = await import('../Conversations');
+
+ render(
+
+
+
+
+
+ );
+
+ // The document attachment surfaces as a filename chip (not an
).
+ await waitFor(() => {
+ expect(document.body.textContent).toContain('report.pdf');
+ });
+ });
});
diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts
index 9dbc8413c..7fed06f68 100644
--- a/app/src/services/api/__tests__/aiSettingsApi.test.ts
+++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts
@@ -18,6 +18,7 @@ import {
loadAISettings,
loadLocalProviderSnapshot,
localProvider,
+ modelRegistryVision,
OPENAI_CODEX_OAUTH_MISSING_AUTH_URL,
OPENAI_CODEX_OAUTH_MISSING_CALLBACK_URL,
parseProviderString,
@@ -28,6 +29,7 @@ import {
setLocalRuntimeEnabled,
startOpenAiCodexOAuth,
testProviderModel,
+ upsertModelRegistryVision,
} from '../aiSettingsApi';
// ─── Mock declarations (must be hoisted before imports) ───────────────────────
@@ -83,6 +85,7 @@ function makeClientConfigResult(overrides: Record
= {}) {
api_key_set: false,
model_routes: [],
cloud_providers: [],
+ model_registry: [],
primary_cloud: null,
reasoning_provider: null,
agentic_provider: null,
@@ -587,6 +590,7 @@ describe('saveAISettings', () => {
learning: { kind: 'openhuman' },
subconscious: { kind: 'openhuman' },
},
+ modelRegistry: [],
...overrides,
};
}
@@ -673,8 +677,13 @@ describe('saveAISettings', () => {
learning: { kind: 'openhuman' },
subconscious: { kind: 'openhuman' },
},
+ modelRegistry: [],
+ };
+ const next: AISettings = {
+ cloudProviders: [anthropicProvider],
+ routing: { ...prev.routing },
+ modelRegistry: [],
};
- const next: AISettings = { cloudProviders: [anthropicProvider], routing: { ...prev.routing } };
await saveAISettings(prev, next);
@@ -697,6 +706,34 @@ describe('saveAISettings', () => {
expect(patch.cloud_providers).toBeDefined();
expect(patch.coding_provider).toBe('openai:gpt-4o-mini');
});
+
+ it('sends model_registry when the vision flag changes', async () => {
+ const prev = makeSettings({ modelRegistry: [] });
+ const next = makeSettings({
+ modelRegistry: [{ id: 'my-llava', provider: 'openai', cost_per_1m_output: 0, vision: true }],
+ });
+ await saveAISettings(prev, next);
+ const patch = mockOpenhumanUpdateModelSettings.mock.calls[0][0];
+ expect(patch.model_registry).toEqual([
+ { id: 'my-llava', provider: 'openai', cost_per_1m_output: 0, vision: true },
+ ]);
+ });
+
+ it('omits model_registry when unchanged', async () => {
+ const registry = [{ id: 'my-llava', provider: 'openai', cost_per_1m_output: 0, vision: true }];
+ const prev = makeSettings({ modelRegistry: registry });
+ const next = makeSettings({
+ modelRegistry: [...registry],
+ routing: {
+ ...makeSettings().routing,
+ coding: { kind: 'cloud', providerSlug: 'openai', model: 'gpt-4o-mini' },
+ },
+ });
+ await saveAISettings(prev, next);
+ const patch = mockOpenhumanUpdateModelSettings.mock.calls[0][0];
+ expect(patch.model_registry).toBeUndefined();
+ expect(patch.coding_provider).toBe('openai:gpt-4o-mini');
+ });
});
// ─── setCloudProviderKey ──────────────────────────────────────────────────────
@@ -918,3 +955,32 @@ describe('flushCloudProviders', () => {
expect(mockOpenhumanUpdateModelSettings).not.toHaveBeenCalled();
});
});
+
+describe('model registry vision helpers', () => {
+ const reg = [
+ { id: 'gpt-4o', provider: 'openai', cost_per_1m_output: 0, vision: true },
+ { id: 'text-only', provider: 'openai', cost_per_1m_output: 0, vision: false },
+ ];
+
+ it('modelRegistryVision matches by (provider, id)', () => {
+ expect(modelRegistryVision(reg, 'openai', 'gpt-4o')).toBe(true);
+ expect(modelRegistryVision(reg, 'openai', 'text-only')).toBe(false);
+ expect(modelRegistryVision(reg, 'openai', 'unlisted')).toBe(false);
+ expect(modelRegistryVision(reg, 'azure', 'gpt-4o')).toBe(false);
+ });
+
+ it('upsertModelRegistryVision adds, flips, and removes entries', () => {
+ const added = upsertModelRegistryVision([], 'openai', 'my-llava', true);
+ expect(added).toEqual([
+ { id: 'my-llava', provider: 'openai', cost_per_1m_output: 0, vision: true },
+ ]);
+ // vision:false removes the entry (absence ⇒ no vision).
+ const removed = upsertModelRegistryVision(reg, 'openai', 'gpt-4o', false);
+ expect(removed.find(e => e.id === 'gpt-4o')).toBeUndefined();
+ expect(removed.find(e => e.id === 'text-only')).toBeDefined();
+ // Flipping an existing entry on stays idempotent on the key.
+ const flipped = upsertModelRegistryVision(reg, 'openai', 'text-only', true);
+ expect(flipped.filter(e => e.id === 'text-only')).toHaveLength(1);
+ expect(modelRegistryVision(flipped, 'openai', 'text-only')).toBe(true);
+ });
+});
diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts
index 89c89eba6..ed2ba173d 100644
--- a/app/src/services/api/aiSettingsApi.ts
+++ b/app/src/services/api/aiSettingsApi.ts
@@ -26,6 +26,7 @@ import { isTauri } from '../../utils/tauriCommands/common';
import {
type ClientConfig,
type CloudProviderCreds,
+ type ModelRegistryEntry,
type ModelSettingsUpdate,
openhumanGetClientConfig,
openhumanUpdateLocalAiSettings,
@@ -132,6 +133,47 @@ const PROVIDER_MODEL_TEST_TIMEOUT_MS = 120_000;
export interface AISettings {
cloudProviders: CloudProviderView[];
routing: Record;
+ /**
+ * Per-model registry carrying each model's user-set `vision` flag, keyed by
+ * `(provider slug, model id)`. Surfaced in the custom-model dialog; gates chat
+ * image attachments for custom/BYOK models.
+ */
+ modelRegistry: ModelRegistryEntry[];
+}
+
+/** Re-export so callers (e.g. the AI panel) can reference the entry type. */
+export type { ModelRegistryEntry };
+
+/** Find a model's vision flag in the registry, matching by (provider, id).
+ * Tolerates an undefined registry (older snapshots / transient load state). */
+export function modelRegistryVision(
+ registry: ModelRegistryEntry[] | undefined,
+ provider: string,
+ id: string
+): boolean {
+ return (registry ?? []).some(e => e.provider === provider && e.id === id && e.vision);
+}
+
+/**
+ * Upsert a model's vision flag, returning a new array. Matches by
+ * `(provider, id)`; a `vision: false` entry is removed (absence ⇒ no vision).
+ */
+export function upsertModelRegistryVision(
+ registry: ModelRegistryEntry[] | undefined,
+ provider: string,
+ id: string,
+ vision: boolean
+): ModelRegistryEntry[] {
+ const base = registry ?? [];
+ const without = base.filter(e => !(e.provider === provider && e.id === id));
+ if (!vision) {
+ return without;
+ }
+ const existing = base.find(e => e.provider === provider && e.id === id);
+ return [
+ ...without,
+ { id, provider, cost_per_1m_output: existing?.cost_per_1m_output ?? 0, vision: true },
+ ];
}
// ─── Read path: load + parse ───────────────────────────────────────────────
@@ -264,7 +306,10 @@ export async function loadAISettings(): Promise {
);
}
- return { cloudProviders, routing };
+ // Per-model registry (vision flags). Defensive default for older snapshots.
+ const modelRegistry: ModelRegistryEntry[] = config.model_registry ?? [];
+
+ return { cloudProviders, routing, modelRegistry };
}
// ─── Write path: diff + save ───────────────────────────────────────────────
@@ -310,12 +355,37 @@ export async function saveAISettings(prev: AISettings, next: AISettings): Promis
}
}
+ // Per-model registry (vision flags): any change → send the full list.
+ if (!modelRegistriesEqual(prev.modelRegistry, next.modelRegistry)) {
+ patch.model_registry = next.modelRegistry.map(
+ ({ id, provider, cost_per_1m_output, vision }) => ({
+ id,
+ provider,
+ cost_per_1m_output,
+ vision,
+ })
+ );
+ }
+
if (Object.keys(patch).length === 0) {
return;
}
await openhumanUpdateModelSettings(patch);
}
+/** Order-insensitive structural equality for two model registries. */
+function modelRegistriesEqual(a: ModelRegistryEntry[], b: ModelRegistryEntry[]): boolean {
+ if (a.length !== b.length) {
+ return false;
+ }
+ const key = (e: ModelRegistryEntry) => `${e.provider} ${e.id}`;
+ const bByKey = new Map(b.map(e => [key(e), e]));
+ return a.every(e => {
+ const m = bByKey.get(key(e));
+ return !!m && m.vision === e.vision && m.cost_per_1m_output === e.cost_per_1m_output;
+ });
+}
+
// ─── API key management (per cloud provider slug) ──────────────────────────
/**
diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts
index db1cc3e77..976f2b12b 100644
--- a/app/src/utils/tauriCommands/config.ts
+++ b/app/src/utils/tauriCommands/config.ts
@@ -49,6 +49,18 @@ export interface CloudProviderCreds {
auth_style: AuthStyle;
}
+/**
+ * Per-model registry entry. Mirrors the Rust `ModelRegistryEntry`
+ * (`config/schema/types.rs`). Carries the user-set `vision` flag that lets a
+ * custom/BYOK model accept chat image attachments.
+ */
+export interface ModelRegistryEntry {
+ id: string;
+ provider: string;
+ cost_per_1m_output: number;
+ vision: boolean;
+}
+
export interface ModelSettingsUpdate {
/**
* OpenHuman product backend URL. Almost always left untouched; the
@@ -77,6 +89,12 @@ export interface ModelSettingsUpdate {
* Each entry: { id?, slug, label?, endpoint, auth_style? }
*/
cloud_providers?: CloudProviderCreds[] | null;
+ /**
+ * When present, REPLACES `config.model_registry` wholesale. Carries each
+ * model's `vision` flag (Settings → Advanced LLM → custom model → "Supports
+ * vision"). Send `[]` to clear; omit to leave untouched.
+ */
+ model_registry?: ModelRegistryEntry[] | null;
/** @deprecated No longer used — slug-based routing replaces primary_cloud. */
primary_cloud?: string | null;
/** Per-workload provider strings — see Rust `providers::factory` grammar. */
@@ -215,6 +233,8 @@ export interface ClientConfig {
model_routes: ModelRoute[];
/** Configured cloud providers (no API keys — those live in auth-profiles.json). */
cloud_providers: CloudProviderCreds[];
+ /** Per-model registry carrying each model's `vision` flag. */
+ model_registry: ModelRegistryEntry[];
/** Id of the `cloud_providers` entry resolved by the `"cloud"` sentinel. */
primary_cloud: string | null;
/** Per-workload provider strings (e.g. `"cloud"`, `"ollama:llama3.1:8b"`, `"openai:gpt-4o"`). */
diff --git a/app/test/playwright/specs/chat-management-functional.spec.ts b/app/test/playwright/specs/chat-management-functional.spec.ts
index 62f902270..c4c5a6a62 100644
--- a/app/test/playwright/specs/chat-management-functional.spec.ts
+++ b/app/test/playwright/specs/chat-management-functional.spec.ts
@@ -2,7 +2,6 @@ import { expect, type Page, test } from '@playwright/test';
import {
bootAuthenticatedPage,
- callCoreRpc,
dismissWalkthroughIfPresent,
waitForAppReady,
} from '../helpers/core-rpc';
@@ -73,7 +72,7 @@ async function newThread(page: Page): Promise {
}
test.describe('Chat management functional coverage', () => {
- test('attachment preview, remove, and multimodal send path remain interactive', async ({
+ test('attachment preview, remove, and document send path remain interactive', async ({
page,
}) => {
await resetMock();
@@ -89,34 +88,24 @@ test.describe('Chat management functional coverage', () => {
const fileInput = page.locator('input[type="file"]');
await expect(fileInput).toHaveCount(1);
- await fileInput.setInputFiles({
- name: 'pixel.png',
- mimeType: 'image/png',
- buffer: Buffer.from(
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
- 'base64'
- ),
- });
- await expect(page.getByText('pixel.png')).toBeVisible();
- await page.getByRole('button', { name: /Remove pixel\.png/ }).click();
- await expect(page.getByText('pixel.png')).toHaveCount(0);
+ // Documents work on every model; images require a vision-capable model.
+ // Managed OpenHuman tiers are text-only (see `oh_tier_supports_vision`), so
+ // the attachment mechanics here are exercised with a text document. Image
+ // upload is gated to vision-flagged models and validated in unit tests.
+ const txtBuffer = Buffer.from('renderer uploaded text document', 'utf8');
+ const attachTxt = () =>
+ fileInput.setInputFiles({ name: 'notes.txt', mimeType: 'text/plain', buffer: txtBuffer });
- const pngBuffer = Buffer.from(
- 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
- 'base64'
- );
- await fileInput.setInputFiles({ name: 'pixel.png', mimeType: 'image/png', buffer: pngBuffer });
- await expect(page.getByText('pixel.png')).toBeVisible();
+ await attachTxt();
+ await expect(page.getByText('notes.txt')).toBeVisible();
+ await page.getByRole('button', { name: /Remove notes\.txt/ }).click();
+ await expect(page.getByText('notes.txt')).toHaveCount(0);
- await fileInput.setInputFiles({
- name: 'notes.txt',
- mimeType: 'text/plain',
- buffer: Buffer.from('renderer uploaded text document', 'utf8'),
- });
+ await attachTxt();
await expect(page.getByText('notes.txt')).toBeVisible();
- await page.getByPlaceholder('How can I help you today?').fill('Describe this image');
+ await page.getByPlaceholder('How can I help you today?').fill('Summarize this file');
await page.getByTestId('send-message-button').click();
await expect(page.getByText('Attachment received by the assistant.')).toBeVisible({
timeout: 30_000,
@@ -132,13 +121,13 @@ test.describe('Chat management functional coverage', () => {
request.method === 'POST' &&
request.url?.includes('/chat/completions') &&
typeof request.body === 'string' &&
- request.body.includes('Describe this image')
+ request.body.includes('Summarize this file')
);
return completion?.body ?? '';
},
{ timeout: 10_000 }
)
- .toContain('image_url');
+ .toContain('[FILE-EXTRACTED:');
const log = await requests();
const completionBody =
@@ -147,13 +136,21 @@ test.describe('Chat management functional coverage', () => {
request.method === 'POST' &&
request.url?.includes('/chat/completions') &&
typeof request.body === 'string' &&
- request.body.includes('Describe this image')
+ request.body.includes('Summarize this file')
)?.body ?? '';
expect(completionBody).toContain('reasoning');
expect(completionBody).toContain('[FILE-EXTRACTED:');
expect(completionBody).toContain('renderer uploaded text document');
});
+ // NOTE: image attachments require a vision-capable model. Managed tiers are
+ // text-only by default (`oh_tier_supports_vision`); a model is flagged
+ // vision-capable via `model_registry` (Settings → "Supports vision"). The
+ // `image_url` promotion path is covered by Rust unit tests
+ // (`inference::provider::compatible` MessageContent). An E2E that toggles the
+ // vision flag at runtime is a follow-up (needs a page re-mount so the
+ // composer's resolve picks up the flag — out of scope here).
+
test('thread rename and delete remain usable from the conversation UI', async ({ page }) => {
await resetMock();
await openChat(page, 'pw-chat-rename-delete');
diff --git a/src/openhuman/agent/harness/engine/core.rs b/src/openhuman/agent/harness/engine/core.rs
index f130ce3ee..d0f12efe1 100644
--- a/src/openhuman/agent/harness/engine/core.rs
+++ b/src/openhuman/agent/harness/engine/core.rs
@@ -248,7 +248,14 @@ pub(crate) async fn run_turn_engine(
tracing::debug!(iteration, "[agent_loop] sending LLM request");
let image_marker_count = multimodal::count_image_markers(history);
- if image_marker_count > 0 && !provider.supports_vision() {
+ // A model accepts images when its provider advertises vision (managed
+ // backend) OR the user marked it vision-capable in `model_registry`
+ // (custom/BYOK) — surfaced via the `current_model_vision` task-local set
+ // at session build. Unset (CLI/tests) falls back to the provider flag.
+ let has_vision = provider.supports_vision()
+ || crate::openhuman::agent::harness::model_vision_context::current_model_vision()
+ .unwrap_or(false);
+ if image_marker_count > 0 && !has_vision {
let cap_err = ProviderCapabilityError {
provider: provider_name.to_string(),
capability: "vision".to_string(),
diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs
index d134842da..379eb189c 100644
--- a/src/openhuman/agent/harness/mod.rs
+++ b/src/openhuman/agent/harness/mod.rs
@@ -30,6 +30,7 @@ mod instructions;
pub mod interrupt;
pub(crate) mod memory_context;
pub(crate) mod memory_context_safety;
+pub mod model_vision_context;
mod parse;
pub(crate) mod payload_summarizer;
pub mod run_queue;
@@ -50,6 +51,7 @@ pub use definition::{
};
pub use fork_context::{current_parent, with_parent_context, ParentExecutionContext};
pub use interrupt::{check_interrupt, InterruptFence, InterruptedError};
+pub use model_vision_context::{current_model_vision, with_current_model_vision};
pub use sandbox_context::{current_sandbox_mode, with_current_sandbox_mode};
pub(crate) use spawn_depth_context::{current_spawn_depth, with_spawn_depth, MAX_SPAWN_DEPTH};
pub use subagent_runner::{run_subagent, SubagentRunError, SubagentRunOptions};
diff --git a/src/openhuman/agent/harness/model_vision_context.rs b/src/openhuman/agent/harness/model_vision_context.rs
new file mode 100644
index 000000000..402ecd426
--- /dev/null
+++ b/src/openhuman/agent/harness/model_vision_context.rs
@@ -0,0 +1,75 @@
+//! Task-local carrier for the **current session/sub-agent's user-configured
+//! vision capability** so the deep turn engine's image gate can honor a custom
+//! (BYOK) model's `model_registry.vision` flag without widening
+//! [`crate::openhuman::agent::harness::engine::run_turn_engine`]'s signature.
+//!
+//! Managed-backend models advertise vision via `Provider::supports_vision()`, so
+//! the gate accepts their image turns already. Custom OpenAI-compatible providers
+//! report `supports_vision() == false` (a provider endpoint can't know a per-model
+//! property), so without this the gate would reject image turns for a model the
+//! user explicitly marked vision-capable. This task-local surfaces that per-model
+//! flag — computed once at session build (where the full `Config` / `model_registry`
+//! and the resolved model id coexist) — to the gate.
+//!
+//! Mirrors [`super::sandbox_context`]. When unset (CLI / direct invocation / tests
+//! that never wrapped the call) [`current_model_vision`] returns `None` and the
+//! gate falls back to the provider capability only — strictly additive.
+
+tokio::task_local! {
+ /// User-configured vision capability for the currently-executing
+ /// session/sub-agent's model. Scoped per turn by the turn loop + subagent
+ /// runner. `None` when unset.
+ pub static CURRENT_MODEL_VISION: bool;
+}
+
+/// Returns the current model's user-configured vision flag, if scope is active.
+pub fn current_model_vision() -> Option {
+ CURRENT_MODEL_VISION.try_with(|v| *v).ok()
+}
+
+/// Run `future` with `vision` installed as the current model's vision flag.
+/// Intended call site is around each `run_turn_engine` invocation.
+pub async fn with_current_model_vision(vision: bool, future: F) -> R
+where
+ F: std::future::Future,
+{
+ CURRENT_MODEL_VISION.scope(vision, future).await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn current_model_vision_returns_none_outside_scope() {
+ assert_eq!(current_model_vision(), None);
+ }
+
+ #[tokio::test]
+ async fn with_current_model_vision_installs_value() {
+ let observed = with_current_model_vision(true, async { current_model_vision() }).await;
+ assert_eq!(observed, Some(true));
+ }
+
+ #[tokio::test]
+ async fn with_current_model_vision_does_not_leak_across_scopes() {
+ with_current_model_vision(true, async {
+ assert_eq!(current_model_vision(), Some(true));
+ })
+ .await;
+ assert_eq!(current_model_vision(), None);
+ }
+
+ #[tokio::test]
+ async fn nested_scope_overrides_outer() {
+ with_current_model_vision(false, async {
+ assert_eq!(current_model_vision(), Some(false));
+ with_current_model_vision(true, async {
+ assert_eq!(current_model_vision(), Some(true));
+ })
+ .await;
+ assert_eq!(current_model_vision(), Some(false));
+ })
+ .await;
+ }
+}
diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs
index 9f5ca83e7..622f5f144 100644
--- a/src/openhuman/agent/harness/session/builder/factory.rs
+++ b/src/openhuman/agent/harness/session/builder/factory.rs
@@ -439,6 +439,15 @@ impl Agent {
model_name = pinned_model.to_string();
}
+ // Resolve the user-configured vision flag for the (now-final) model while
+ // the full `Config` / `model_registry` is in scope — the turn engine only
+ // sees `MultimodalConfig`. Stored on the session and surfaced to the image
+ // gate via the `current_model_vision` task-local (covers custom/BYOK models
+ // the provider can't introspect). Computed with `&model_name` since it's
+ // moved into the builder below.
+ let model_vision =
+ crate::openhuman::inference::model_context::model_supports_vision(&model_name, config);
+
// Dispatcher selection is deferred until after the tool list is
// finalised (orchestrator tools are appended below). We capture
// the choice string now so the provider borrow doesn't conflict
@@ -1047,6 +1056,7 @@ impl Agent {
.config(config.agent.clone())
.context_config(config.context.clone())
.model_name(model_name)
+ .model_vision(model_vision)
.temperature(effective_temperature)
.workspace_dir(config.workspace_dir.clone())
.action_dir(config.action_dir.clone())
diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs
index de65e73ec..2d9eb5743 100644
--- a/src/openhuman/agent/harness/session/builder/setters.rs
+++ b/src/openhuman/agent/harness/session/builder/setters.rs
@@ -29,6 +29,7 @@ impl AgentBuilder {
config: None,
context_config: None,
model_name: None,
+ model_vision: None,
temperature: None,
workspace_dir: None,
action_dir: None,
@@ -142,6 +143,14 @@ impl AgentBuilder {
self
}
+ /// Sets the user-configured vision capability for the resolved model.
+ /// Surfaced to the turn engine's image gate via the `current_model_vision`
+ /// task-local. Defaults to `false` when unset.
+ pub fn model_vision(mut self, model_vision: bool) -> Self {
+ self.model_vision = Some(model_vision);
+ self
+ }
+
/// Sets the temperature for chat requests.
pub fn temperature(mut self, temperature: f64) -> Self {
self.temperature = Some(temperature);
@@ -514,6 +523,7 @@ impl AgentBuilder {
.unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())),
config,
model_name,
+ model_vision: self.model_vision.unwrap_or(false),
temperature: self.temperature.unwrap_or(0.7),
workspace_dir,
action_dir,
diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs
index 049315fee..dd89d8aa2 100644
--- a/src/openhuman/agent/harness/session/turn/core.rs
+++ b/src/openhuman/agent/harness/session/turn/core.rs
@@ -323,6 +323,10 @@ impl Agent {
// model prompt, not in the Rust-side classifier. Sub-agents pick
// their own tier via `ModelSpec::Hint(...)` in their definition.
let effective_model = self.model_name.clone();
+ // Capture before `self` is borrowed by the turn observer below, so it can
+ // be installed as the `current_model_vision` task-local around the engine
+ // call (read by the image gate for custom/BYOK vision models).
+ let model_vision = self.model_vision;
log::info!(
"[agent_loop] model pinned model={} (per-turn classification disabled for KV cache stability)",
effective_model
@@ -436,25 +440,28 @@ impl Agent {
// overflow happens during the parent's poll on the way in
// — verified against the `chat-harness-subagent` Playwright
// lane crash on PR #3151.
- let outcome = Box::pin(super::super::super::engine::run_turn_engine(
- provider.as_ref(),
- &mut buf,
- &mut tool_source,
- &progress,
- &mut observer,
- &checkpoint,
- &parser,
- &provider_name,
- &effective_model,
- temperature,
- true, // silent — the channel/UI renders via progress + the return value
- &multimodal,
- &multimodal_files,
- max_iterations,
- None, // the web bridge streams via on_progress deltas, not on_delta
- &[],
- turn_run_queue,
- ))
+ let outcome = super::super::super::model_vision_context::with_current_model_vision(
+ model_vision,
+ Box::pin(super::super::super::engine::run_turn_engine(
+ provider.as_ref(),
+ &mut buf,
+ &mut tool_source,
+ &progress,
+ &mut observer,
+ &checkpoint,
+ &parser,
+ &provider_name,
+ &effective_model,
+ temperature,
+ true, // silent — the channel/UI renders via progress + the return value
+ &multimodal,
+ &multimodal_files,
+ max_iterations,
+ None, // the web bridge streams via on_progress deltas, not on_delta
+ &[],
+ turn_run_queue,
+ )),
+ )
.await?;
// Pull the observer's accounting out, then drop it to release the
diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs
index 4610eaae5..c30bdab11 100644
--- a/src/openhuman/agent/harness/session/types.rs
+++ b/src/openhuman/agent/harness/session/types.rs
@@ -53,6 +53,11 @@ pub struct Agent {
pub(super) memory_loader: Box,
pub(super) config: crate::openhuman::config::AgentConfig,
pub(super) model_name: String,
+ /// User-configured vision capability for [`Self::model_name`], evaluated at
+ /// session build from `model_vision_enabled(&model, config)`. Surfaced to the
+ /// turn engine's image gate via the `current_model_vision` task-local so a
+ /// custom/BYOK model the user flagged can forward images. Defaults to `false`.
+ pub(super) model_vision: bool,
pub(super) temperature: f64,
pub(super) workspace_dir: std::path::PathBuf,
pub(super) action_dir: std::path::PathBuf,
@@ -272,6 +277,8 @@ pub struct AgentBuilder {
/// [`crate::openhuman::config::ContextConfig::default`].
pub(super) context_config: Option,
pub(super) model_name: Option,
+ /// User vision flag for the resolved model; `None` → `false` in `build()`.
+ pub(super) model_vision: Option,
pub(super) temperature: Option,
pub(super) workspace_dir: Option,
pub(super) action_dir: Option,
diff --git a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs b/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs
index 944182b7d..0b38235ed 100644
--- a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs
+++ b/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs
@@ -48,6 +48,10 @@ pub(super) async fn run_inner_loop(
allowed_names: HashSet,
lazy_resolver: Option,
model: &str,
+ // User-configured vision flag for `model` (computed at the call site), set as
+ // the `current_model_vision` task-local around the engine call so a flagged
+ // custom/BYOK sub-agent model can forward images.
+ model_vision: bool,
temperature: f64,
max_iterations: usize,
task_id: &str,
@@ -176,25 +180,28 @@ pub(super) async fn run_inner_loop(
// `nested_subagent_dispatch_runs_on_a_constrained_worker_stack`;
// the deep end-to-end catcher is the `chat-harness-subagent`
// Playwright spec.
- let outcome = Box::pin(super::super::super::engine::run_turn_engine(
- provider,
- history,
- &mut tool_source,
- &progress,
- &mut observer,
- &checkpoint,
- &parser,
- "subagent",
- model,
- temperature,
- true, // silent — sub-agents never echo to stdout
- &crate::openhuman::config::MultimodalConfig::default(),
- &crate::openhuman::config::MultimodalFileConfig::default(),
- max_iterations,
- None, // sub-agents don't stream a draft
- &["ask_user_clarification"],
- None, // sub-agents don't support run-queue steering
- ))
+ let outcome = super::super::super::model_vision_context::with_current_model_vision(
+ model_vision,
+ Box::pin(super::super::super::engine::run_turn_engine(
+ provider,
+ history,
+ &mut tool_source,
+ &progress,
+ &mut observer,
+ &checkpoint,
+ &parser,
+ "subagent",
+ model,
+ temperature,
+ true, // silent — sub-agents never echo to stdout
+ &crate::openhuman::config::MultimodalConfig::default(),
+ &crate::openhuman::config::MultimodalFileConfig::default(),
+ max_iterations,
+ None, // sub-agents don't stream a draft
+ &["ask_user_clarification"],
+ None, // sub-agents don't support run-queue steering
+ )),
+ )
.await?;
Ok((
diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs
index 28bf252e7..72ca9401b 100644
--- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs
+++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs
@@ -645,6 +645,20 @@ async fn run_typed_mode(
};
// ── Run the inner tool-call loop ───────────────────────────────────
+ // Resolve the sub-agent model's user-configured vision flag; defaults to
+ // `false` when config can't be loaded. Combined with the provider capability
+ // at the gate, this lets a flagged custom/BYOK sub-agent model forward images.
+ let model_vision = crate::openhuman::config::Config::load_or_init()
+ .await
+ .ok()
+ .map(|cfg| crate::openhuman::inference::model_context::model_supports_vision(&model, &cfg))
+ .unwrap_or(false);
+ tracing::debug!(
+ target: "subagent_runner",
+ model = %model,
+ model_vision,
+ "[subagent_runner] resolved sub-agent model vision capability"
+ );
let (output, iterations, _agg_usage, early_exit_tool) = Box::pin(run_inner_loop(
subagent_provider.as_ref(),
&mut history,
@@ -654,6 +668,7 @@ async fn run_typed_mode(
allowed_names,
lazy_resolver,
&model,
+ model_vision,
temperature,
definition.effective_max_iterations(),
task_id,
diff --git a/src/openhuman/channels/providers/web/session.rs b/src/openhuman/channels/providers/web/session.rs
index 3c2d08def..919506d8d 100644
--- a/src/openhuman/channels/providers/web/session.rs
+++ b/src/openhuman/channels/providers/web/session.rs
@@ -11,6 +11,14 @@ pub(super) fn autonomy_signature(config: &Config) -> String {
serde_json::to_string(&config.autonomy).unwrap_or_default()
}
+/// Signature of `config.model_registry` for the session-cache fingerprint.
+/// Captures every per-model `vision` flag so toggling one in Settings forces a
+/// rebuild (picking up the new build-time `model_vision`). Mirrors
+/// [`autonomy_signature`].
+pub(super) fn model_registry_signature(config: &Config) -> String {
+ serde_json::to_string(&config.model_registry).unwrap_or_default()
+}
+
pub(super) fn pick_target_agent_id(_config: &Config, profile: &AgentProfile) -> String {
if profile.id == DEFAULT_PROFILE_ID {
"orchestrator".to_string()
@@ -186,5 +194,6 @@ pub(super) fn build_session_fingerprint(
),
target_agent_id,
autonomy_signature: autonomy_signature(config),
+ model_registry_signature: model_registry_signature(config),
}
}
diff --git a/src/openhuman/channels/providers/web/types.rs b/src/openhuman/channels/providers/web/types.rs
index c708c1780..13c01d10b 100644
--- a/src/openhuman/channels/providers/web/types.rs
+++ b/src/openhuman/channels/providers/web/types.rs
@@ -19,6 +19,12 @@ pub(crate) struct SessionCacheFingerprint {
pub(super) target_agent_id: String,
pub(super) provider_binding: String,
pub(super) autonomy_signature: String,
+ /// Signature of `config.model_registry`. The cached `Agent` stores a
+ /// build-time `model_vision` bool; toggling a model's "Supports vision" flag
+ /// keeps the same model id (so neither `model_override` nor `provider_binding`
+ /// change) — without this the stale session would be reused. Mirrors
+ /// [`Self::autonomy_signature`].
+ pub(super) model_registry_signature: String,
}
pub(super) struct SessionEntry {
diff --git a/src/openhuman/channels/providers/web_tests.rs b/src/openhuman/channels/providers/web_tests.rs
index 82423cc50..230df9c79 100644
--- a/src/openhuman/channels/providers/web_tests.rs
+++ b/src/openhuman/channels/providers/web_tests.rs
@@ -1208,6 +1208,7 @@ fn fp(
target_agent_id: target.to_string(),
provider_binding: provider_binding.to_string(),
autonomy_signature: "sig-default".to_string(),
+ model_registry_signature: "registry-default".to_string(),
}
}
@@ -1225,6 +1226,20 @@ fn fingerprint_autonomy_change_is_cache_miss() {
);
}
+#[test]
+fn fingerprint_model_registry_change_is_cache_miss() {
+ // Toggling a model's "Supports vision" flag keeps the same model id, so it
+ // changes neither model_override nor provider_binding. Without the registry
+ // signature the stale Agent (old build-time model_vision) would be reused.
+ let base = fp(None, None, "orchestrator", "openai:my-llava");
+ let mut changed = fp(None, None, "orchestrator", "openai:my-llava");
+ changed.model_registry_signature = "registry-after-vision-toggle".to_string();
+ assert_ne!(
+ base, changed,
+ "a model_registry change (vision toggle) must produce a cache miss → rebuild"
+ );
+}
+
#[test]
fn fingerprint_identical_inputs_are_cache_hit() {
let a = fp(None, None, "orchestrator", "anthropic:claude-sonnet-4-6");
diff --git a/src/openhuman/config/ops/loader.rs b/src/openhuman/config/ops/loader.rs
index e7b9eb5ef..57d445332 100644
--- a/src/openhuman/config/ops/loader.rs
+++ b/src/openhuman/config/ops/loader.rs
@@ -254,6 +254,18 @@ pub fn client_config_json(config: &Config) -> serde_json::Value {
})
})
.collect();
+ let model_registry: Vec = config
+ .model_registry
+ .iter()
+ .map(|m| {
+ serde_json::json!({
+ "id": m.id,
+ "provider": m.provider,
+ "cost_per_1m_output": m.cost_per_1m_output,
+ "vision": m.vision,
+ })
+ })
+ .collect();
serde_json::json!({
"api_url": config.api_url,
@@ -263,6 +275,7 @@ pub fn client_config_json(config: &Config) -> serde_json::Value {
"api_key_set": api_key_set,
"model_routes": model_routes,
"cloud_providers": cloud_providers,
+ "model_registry": model_registry,
"primary_cloud": config.primary_cloud,
"chat_provider": config.chat_provider,
"reasoning_provider": config.reasoning_provider,
diff --git a/src/openhuman/config/ops/model.rs b/src/openhuman/config/ops/model.rs
index d1b0db1e1..a6df38f01 100644
--- a/src/openhuman/config/ops/model.rs
+++ b/src/openhuman/config/ops/model.rs
@@ -26,6 +26,10 @@ pub struct ModelSettingsPatch {
/// Pass `Some(vec![])` to clear all third-party cloud providers.
pub cloud_providers:
Option>,
+ /// When `Some`, REPLACES the entire `config.model_registry` array. Carries
+ /// each model's user-set `vision` flag (Settings → Advanced LLM → custom
+ /// model → "Supports vision"). Pass `Some(vec![])` to clear; `None` keeps it.
+ pub model_registry: Option>,
/// Id of the `cloud_providers` entry used when a workload routes to
/// `"cloud"`. Empty string clears (factory falls back to OpenHuman).
pub primary_cloud: Option,
@@ -139,6 +143,24 @@ pub async fn apply_model_settings(
if let Some(routes) = update.model_routes {
config.model_routes = routes;
}
+ if let Some(registry) = update.model_registry {
+ // Full replacement — the UI sends the canonical per-model registry
+ // (carrying each model's `vision` flag). Empty vec clears it.
+ log::debug!(
+ "[config] apply_model_settings: replacing model_registry ({} entries)",
+ registry.len()
+ );
+ // Normalize ids: `model_vision_enabled` matches the resolved model id
+ // exactly, so stray surrounding whitespace would silently disable vision
+ // for an otherwise valid model.
+ config.model_registry = registry
+ .into_iter()
+ .map(|mut entry| {
+ entry.id = entry.id.trim().to_string();
+ entry
+ })
+ .collect();
+ }
if let Some(providers) = update.cloud_providers {
use crate::openhuman::config::schema::cloud_providers::is_slug_reserved;
let preserved: Vec<_> = config
diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs
index 466ea9ef2..e7b7d6d3d 100644
--- a/src/openhuman/config/ops_tests.rs
+++ b/src/openhuman/config/ops_tests.rs
@@ -588,6 +588,77 @@ async fn apply_model_settings_replaces_model_routes_when_some_and_keeps_when_non
assert!(cfg.model_routes.is_empty());
}
+#[tokio::test]
+async fn apply_model_settings_replaces_model_registry_when_some_and_keeps_when_none() {
+ // Per-model vision registry follows Some=replace / None=keep / empty=clear —
+ // this persists the "Supports vision" flag set in Settings → Advanced LLM.
+ use crate::openhuman::config::schema::ModelRegistryEntry;
+ let tmp = tempdir().unwrap();
+ let mut cfg = tmp_config(&tmp);
+
+ let set = ModelSettingsPatch {
+ model_registry: Some(vec![ModelRegistryEntry {
+ id: "my-llava".into(),
+ provider: "openai".into(),
+ cost_per_1m_output: 0.0,
+ vision: true,
+ }]),
+ ..Default::default()
+ };
+ let _ = apply_model_settings(&mut cfg, set).await.expect("set");
+ assert_eq!(cfg.model_registry.len(), 1);
+ assert!(cfg
+ .model_registry
+ .iter()
+ .any(|e| e.id == "my-llava" && e.vision));
+
+ // None — leave registry alone.
+ let _ = apply_model_settings(
+ &mut cfg,
+ ModelSettingsPatch {
+ api_url: Some("https://x.test/v1".into()),
+ ..Default::default()
+ },
+ )
+ .await
+ .expect("touch");
+ assert_eq!(cfg.model_registry.len(), 1);
+
+ // Empty vec — clear.
+ let _ = apply_model_settings(
+ &mut cfg,
+ ModelSettingsPatch {
+ model_registry: Some(vec![]),
+ ..Default::default()
+ },
+ )
+ .await
+ .expect("clear");
+ assert!(cfg.model_registry.is_empty());
+}
+
+#[tokio::test]
+async fn apply_model_settings_trims_model_registry_ids() {
+ // `model_vision_enabled` matches the resolved id exactly, so persisted ids
+ // must be trimmed or stray whitespace would silently disable vision.
+ use crate::openhuman::config::schema::ModelRegistryEntry;
+ let tmp = tempdir().unwrap();
+ let mut cfg = tmp_config(&tmp);
+
+ let set = ModelSettingsPatch {
+ model_registry: Some(vec![ModelRegistryEntry {
+ id: " spaced-model ".into(),
+ provider: "openai".into(),
+ cost_per_1m_output: 0.0,
+ vision: true,
+ }]),
+ ..Default::default()
+ };
+ let _ = apply_model_settings(&mut cfg, set).await.expect("set");
+ assert_eq!(cfg.model_registry.len(), 1);
+ assert_eq!(cfg.model_registry[0].id, "spaced-model");
+}
+
#[tokio::test]
async fn apply_model_settings_empty_strings_clear_optional_fields() {
let tmp = tempdir().unwrap();
diff --git a/src/openhuman/config/schemas/controllers.rs b/src/openhuman/config/schemas/controllers.rs
index 333da9762..bb399f007 100644
--- a/src/openhuman/config/schemas/controllers.rs
+++ b/src/openhuman/config/schemas/controllers.rs
@@ -360,6 +360,10 @@ fn handle_update_model_settings(params: Map) -> ControllerFuture
.collect::, String>>()
})
.transpose()?,
+ // The config-domain RPC doesn't carry a model-registry payload — the
+ // per-model vision registry is updated via the inference-domain
+ // `inference_update_model_settings` path.
+ model_registry: None,
primary_cloud: update.primary_cloud,
chat_provider: update.chat_provider,
reasoning_provider: update.reasoning_provider,
diff --git a/src/openhuman/inference/model_context.rs b/src/openhuman/inference/model_context.rs
index 3b467295f..93adf06b0 100644
--- a/src/openhuman/inference/model_context.rs
+++ b/src/openhuman/inference/model_context.rs
@@ -137,6 +137,46 @@ pub fn context_window_for_model_with_local_fallback(
None
}
+/// Whether the model resolved for a chat hint/agent/profile accepts image input
+/// according to the **user-configured** vision flag in `config.model_registry`.
+///
+/// This is the per-model override that lets a user mark a **custom / BYOK** model
+/// as vision-capable (Settings → Advanced LLM → custom model → "Supports
+/// vision"). Managed-backend models already advertise vision via
+/// [`crate::openhuman::inference::provider::Provider::supports_vision`]; this flag
+/// covers OpenAI-compatible providers the backend can't introspect per-model.
+/// Returns `false` for models the user has not flagged.
+pub fn model_vision_enabled(model: &str, config: &crate::openhuman::config::Config) -> bool {
+ let normalized = model.trim();
+ if normalized.is_empty() {
+ return false;
+ }
+ let enabled = config
+ .model_registry
+ .iter()
+ .any(|entry| entry.id == normalized && entry.vision);
+ tracing::debug!(
+ model = normalized,
+ vision_enabled = enabled,
+ "[model_context] resolved user-configured vision flag"
+ );
+ enabled
+}
+
+/// Whether a resolved model accepts image input. The single predicate shared by
+/// the chat UI resolve and the server-side session/sub-agent gates.
+///
+/// - **Managed OpenHuman tiers** consult the hardcoded per-tier map
+/// ([`crate::openhuman::inference::provider::factory::oh_tier_supports_vision`]) —
+/// the remote backend does not advertise per-tier capability, so the core owns
+/// it. Currently every managed tier is `false`.
+/// - **Custom/BYOK models** consult the user-set `model_registry.vision` flag
+/// ([`model_vision_enabled`]).
+pub fn model_supports_vision(model: &str, config: &crate::openhuman::config::Config) -> bool {
+ crate::openhuman::inference::provider::factory::oh_tier_supports_vision(model)
+ || model_vision_enabled(model, config)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -210,6 +250,50 @@ mod tests {
assert_eq!(context_window_for_model(" "), None);
}
+ #[test]
+ fn model_vision_enabled_reads_registry_only() {
+ use crate::openhuman::config::schema::ModelRegistryEntry;
+ let mut config = crate::openhuman::config::Config::default();
+ config.model_registry = vec![
+ ModelRegistryEntry {
+ id: "my-llava".into(),
+ provider: "openai".into(),
+ cost_per_1m_output: 0.0,
+ vision: true,
+ },
+ ModelRegistryEntry {
+ id: "text-only".into(),
+ provider: "openai".into(),
+ cost_per_1m_output: 0.0,
+ vision: false,
+ },
+ ];
+ assert!(model_vision_enabled("my-llava", &config));
+ assert!(!model_vision_enabled("text-only", &config));
+ assert!(!model_vision_enabled("unlisted", &config));
+ assert!(!model_vision_enabled(" ", &config));
+ }
+
+ #[test]
+ fn model_supports_vision_combines_tier_map_and_registry() {
+ use crate::openhuman::config::schema::ModelRegistryEntry;
+ let mut config = crate::openhuman::config::Config::default();
+ config.model_registry = vec![ModelRegistryEntry {
+ id: "my-llava".into(),
+ provider: "openai".into(),
+ cost_per_1m_output: 0.0,
+ vision: true,
+ }];
+ // Managed tiers are non-vision (the per-tier map is all `false` today).
+ assert!(!model_supports_vision("chat-v1", &config));
+ assert!(!model_supports_vision("reasoning-v1", &config));
+ assert!(!model_supports_vision("hint:chat", &config));
+ // BYOK model flagged in the registry is vision-capable.
+ assert!(model_supports_vision("my-llava", &config));
+ // Unlisted custom model is not.
+ assert!(!model_supports_vision("gpt-5", &config));
+ }
+
#[test]
fn o1_o3_segment_match_does_not_overmatch() {
// Real OpenAI o1/o3 model ids must still resolve.
diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs
index 561722419..5690e6586 100644
--- a/src/openhuman/inference/provider/factory.rs
+++ b/src/openhuman/inference/provider/factory.rs
@@ -172,6 +172,34 @@ pub(crate) fn is_known_openhuman_tier(model: &str) -> bool {
)
}
+/// Per-tier vision (image-input) capability for the managed OpenHuman backend.
+///
+/// The remote managed backend (`api.tinyhumans.ai`) does not advertise per-tier
+/// capabilities, so the core maintains this map itself. Accepts both the tier
+/// constants and their `hint:*` forms (callers may pass either pre- or
+/// post-resolution).
+///
+/// Every managed tier currently returns `false` — flip an individual arm to
+/// `true` once that tier is confirmed multimodal on the backend. This is the
+/// **only** place to change managed-model vision; BYOK/custom models are handled
+/// separately by the user-set `model_registry.vision` flag
+/// ([`crate::openhuman::inference::model_context::model_vision_enabled`]).
+pub(crate) fn oh_tier_supports_vision(model: &str) -> bool {
+ use crate::openhuman::config::{
+ MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1,
+ MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1,
+ };
+ match model {
+ MODEL_CHAT_V1 | "hint:chat" => false,
+ MODEL_REASONING_V1 | "hint:reasoning" => false,
+ MODEL_REASONING_QUICK_V1 => false,
+ MODEL_AGENTIC_V1 | "hint:agentic" => false,
+ MODEL_CODING_V1 | "hint:coding" => false,
+ MODEL_SUMMARIZATION_V1 | "hint:summarization" => false,
+ _ => false,
+ }
+}
+
/// Return the configured provider string for a named workload role.
///
/// Empty / `"cloud"` resolves through BYOK fallback first for the three
diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs
index 4d08e9662..a7c0b8895 100644
--- a/src/openhuman/inference/provider/factory_tests.rs
+++ b/src/openhuman/inference/provider/factory_tests.rs
@@ -867,6 +867,40 @@ fn invalid_models_fail() {
assert!(!is_known_openhuman_tier("hint:"));
}
+// ── oh_tier_supports_vision ──────────────────────────────────────────────────────
+
+#[test]
+fn no_managed_tier_is_vision_capable_yet() {
+ // Every managed tier (and its hint form) is non-vision until confirmed
+ // multimodal on the backend. Flip the corresponding arm in
+ // `oh_tier_supports_vision` to enable one.
+ for model in [
+ "reasoning-v1",
+ "chat-v1",
+ "agentic-v1",
+ "coding-v1",
+ "reasoning-quick-v1",
+ "summarization-v1",
+ "hint:reasoning",
+ "hint:chat",
+ "hint:agentic",
+ "hint:coding",
+ "hint:summarization",
+ ] {
+ assert!(
+ !oh_tier_supports_vision(model),
+ "expected managed tier '{model}' to be non-vision"
+ );
+ }
+}
+
+#[test]
+fn unknown_models_are_not_vision_capable() {
+ assert!(!oh_tier_supports_vision("gpt-5"));
+ assert!(!oh_tier_supports_vision("claude-opus-4-7"));
+ assert!(!oh_tier_supports_vision(""));
+}
+
#[test]
fn make_openhuman_backend_forwards_unknown_hint_verbatim() {
// Unrecognised hint:* strings (e.g. hint:reaction for lightweight models)
diff --git a/src/openhuman/inference/schemas.rs b/src/openhuman/inference/schemas.rs
index 7f8ddc5ad..6eadfaa82 100644
--- a/src/openhuman/inference/schemas.rs
+++ b/src/openhuman/inference/schemas.rs
@@ -80,6 +80,8 @@ struct InferenceUpdateModelSettingsParams {
default_temperature: Option,
model_routes: Option>,
cloud_providers: Option>,
+ #[serde(default)]
+ model_registry: Option>,
primary_cloud: Option,
chat_provider: Option,
reasoning_provider: Option,
@@ -255,7 +257,13 @@ pub fn schemas(function: &str) -> ControllerSchema {
function: "resolve_model",
description: "Resolve a model hint or tier name to the concrete model the provider router would use.",
inputs: vec![required_string("hint", "Model hint (e.g. hint:reasoning) or tier name (e.g. reasoning-v1).")],
- outputs: vec![json_output("model", "Resolved concrete model id.")],
+ outputs: vec![
+ json_output("model", "Resolved concrete model id."),
+ json_output(
+ "vision",
+ "Whether the resolved model accepts image input (vision-capable).",
+ ),
+ ],
},
"status" => ControllerSchema {
namespace: "inference",
@@ -283,6 +291,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
optional_f64("default_temperature", "Optional default temperature override."),
optional_json("model_routes", "Optional full replacement for legacy model routes."),
optional_json("cloud_providers", "Optional full replacement for configured cloud providers."),
+ optional_json("model_registry", "Optional full replacement for the per-model registry (carries each model's `vision` flag)."),
optional_string("primary_cloud", "Optional primary cloud provider id."),
optional_string("chat_provider", "Optional chat workload provider string."),
optional_string("reasoning_provider", "Optional reasoning workload provider string."),
@@ -557,8 +566,14 @@ fn handle_inference_resolve_model(params: Map) -> ControllerFutur
let resolved = crate::openhuman::inference::provider::factory::resolve_model_for_hint(
&p.hint, &config,
);
+ // Whether the resolved model accepts image input — drives the chat UI's
+ // image-attachment affordance. Managed OpenHuman tiers consult the
+ // core-owned per-tier map (currently all `false`); custom/BYOK models are
+ // covered by the user's per-model `model_registry.vision` flag.
+ let vision =
+ crate::openhuman::inference::model_context::model_supports_vision(&resolved, &config);
to_json(RpcOutcome::new(
- serde_json::json!({ "model": resolved }),
+ serde_json::json!({ "model": resolved, "vision": vision }),
vec![],
))
})
@@ -676,6 +691,7 @@ fn handle_inference_update_model_settings(params: Map) -> Control
.collect::, String>>()
})
.transpose()?,
+ model_registry: update.model_registry,
primary_cloud: update.primary_cloud,
chat_provider: update.chat_provider,
reasoning_provider: update.reasoning_provider,