diff --git a/README.md b/README.md index 5841ac4b..c10672d9 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ The dashboard provides visual management for all account types: > **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, provision `ccs-websearch.WebSearch` when the managed runtime is available, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed. > Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly. -> **Image-analysis backend visibility:** `ccs config image-analysis --set-fallback ` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend ` and `--clear-profile-backend ` for explicit per-profile mappings. In the dashboard raw JSON editor, CCS now shows a derived `Image-analysis backend` status block below the JSON viewer so you can see the configured backend, whether the hook is persisted, and whether the result is runtime-derived rather than saved in the profile JSON. +> **Image backend visibility:** `ccs config image-analysis --set-fallback ` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend ` and `--clear-profile-backend ` for explicit per-profile mappings. In the dashboard, the global `Settings -> Image` section now shows the shared backend routing state, while each profile editor keeps a compact `Image` status card that links back to those global controls. > **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration. diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index b16e0a96..83a0294b 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -9,6 +9,7 @@ * - WebSearch and ImageAnalysis hook integration */ +import * as fs from 'fs'; import { getEffectiveEnvVars, getRemoteEnvVars, @@ -29,6 +30,7 @@ import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer'; import type { ProxyTarget } from '../proxy-target-resolver'; +import { isSettings, type Settings } from '../../types/config'; export interface RemoteProxyConfig { host: string; @@ -129,17 +131,36 @@ function normalizeCodexEnvForDirectUpstream(envVars: NodeJS.ProcessEnv): NodeJS. return nextEnv ?? envVars; } +function loadImageAnalysisSettings(settingsPath?: string): Settings | undefined { + if (!settingsPath) { + return undefined; + } + + try { + if (!fs.existsSync(settingsPath)) { + return undefined; + } + + const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as unknown; + return isSettings(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + export async function resolveCliproxyImageAnalysisEnv( options: ResolveCliproxyImageAnalysisEnvOptions, deps: Partial = {} ): Promise { const resolvedDeps = { ...defaultCliproxyImageAnalysisDeps, ...deps }; + const settings = loadImageAnalysisSettings(options.profileSettingsPath); const context = { profileName: options.profileName, profileType: 'cliproxy' as const, cliproxyProvider: options.provider, isComposite: options.isComposite, settingsPath: options.profileSettingsPath, + settings, hookInstalled: resolvedDeps.hasImageAnalysisProfileHook( options.profileName, options.profileSettingsPath diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 67a0812f..241c3b4e 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -59,6 +59,8 @@ export interface ModelEntry { thinking?: ThinkingSupport; /** Whether model supports 1M extended context window (appends [1m] suffix) */ extendedContext?: boolean; + /** Whether model can read image inputs natively without the Image transformer */ + nativeImageInput?: boolean; } /** @@ -86,6 +88,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-6-thinking', name: 'Claude Opus 4.6 Thinking', description: 'Latest flagship, extended thinking', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -101,6 +104,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Latest Sonnet with thinking budget support', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -113,6 +117,15 @@ export const MODEL_CATALOG: Partial> = id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', description: 'Google latest Gemini Pro model via Antigravity', + nativeImageInput: true, + thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, + extendedContext: true, + }, + { + id: 'gemini-3-1-flash-preview', + name: 'Gemini Flash', + description: 'Latest Gemini Flash model via Antigravity', + nativeImageInput: true, thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, extendedContext: true, }, @@ -128,6 +141,16 @@ export const MODEL_CATALOG: Partial> = name: 'Gemini 3.1 Pro', tier: 'pro', description: 'Latest Gemini Pro model, requires paid Google account', + nativeImageInput: true, + thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, + extendedContext: true, + }, + { + id: 'gemini-3-flash-preview', + name: 'Gemini Flash', + tier: 'pro', + description: 'Latest Gemini Flash model, requires paid Google account', + nativeImageInput: true, thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, extendedContext: true, }, @@ -135,6 +158,7 @@ export const MODEL_CATALOG: Partial> = id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Stable, works with free Google account', + nativeImageInput: true, thinking: { type: 'budget', min: 128, @@ -264,6 +288,7 @@ export const MODEL_CATALOG: Partial> = id: 'kimi-k2.5', name: 'Kimi K2.5', description: 'Latest multimodal model (262K context)', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -300,6 +325,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-6', name: 'Claude Opus 4.6', description: 'Latest flagship model', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -313,6 +339,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Balanced performance and speed', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -326,6 +353,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', description: 'Most capable Claude model', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -339,6 +367,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', description: 'Balanced performance and speed', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -352,6 +381,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', description: 'Previous generation Sonnet', + nativeImageInput: true, thinking: { type: 'budget', min: 1024, @@ -365,6 +395,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', description: 'Fast and efficient', + nativeImageInput: true, thinking: { type: 'none' }, }, ], @@ -514,6 +545,14 @@ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: str return model?.extendedContext === true; } +/** + * Check if a model can read image inputs natively. + */ +export function supportsNativeImageInput(provider: CLIProxyProvider, modelId: string): boolean { + const model = findModel(provider, modelId); + return model?.nativeImageInput === true; +} + /** * Check if model is a native Gemini model (not Claude via Antigravity). * Native Gemini models get extended context auto-enabled. diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index ba9f855d..b9ed3f6a 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -140,7 +140,9 @@ function showHelp(): void { if (IMAGE_ANALYSIS_PROVIDER_ALIASES.length > 0) { console.log(` ${dim(`Aliases accepted: ${IMAGE_ANALYSIS_PROVIDER_ALIASES.join(', ')}`)}`); } - console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`); + console.log( + ` ${dim('Defaults: agy -> gemini-3-1-flash-preview, gemini -> gemini-3-flash-preview')}` + ); console.log(''); console.log(subheader('Examples:')); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 5651d82d..dd72d114 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -773,8 +773,8 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { enabled: true, timeout: 60, provider_models: { - agy: 'gemini-2.5-flash', - gemini: 'gemini-2.5-flash', + agy: 'gemini-3-1-flash-preview', + gemini: 'gemini-3-flash-preview', codex: 'gpt-5.1-codex-mini', kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 39a0f7c5..34d35394 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -42,7 +42,7 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise results.errors.push({ name: 'Image Analysis', message: 'No provider models configured for image analysis', - fix: 'ccs config image-analysis --set-model agy gemini-2.5-flash', + fix: 'ccs config image-analysis --set-model agy gemini-3-1-flash-preview', }); console.log(` ${warn('Providers:')} None configured`); return; diff --git a/src/types/config.ts b/src/types/config.ts index f3c2541a..b84658e1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -75,6 +75,10 @@ export interface ModelPreset { haiku: string; } +export interface CcsImageSettings { + native_read?: boolean; +} + /** * Claude CLI settings.json structure * Located at: ~/.claude/settings.json or profile-specific @@ -83,6 +87,8 @@ export interface Settings { env?: EnvVars; /** Saved model presets for this provider */ presets?: ModelPreset[]; + /** CCS-only per-profile Image preferences */ + ccs_image?: CcsImageSettings; [key: string]: unknown; // Allow other settings } diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index 89ae2afe..ec7921e4 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -7,10 +7,12 @@ import { isCLIProxyProvider, mapExternalProviderName, } from '../../cliproxy/provider-capabilities'; +import { getProviderCatalog, supportsNativeImageInput } from '../../cliproxy/model-catalog'; import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer'; import type { CliproxyBridgeMetadata } from '../../api/services/profile-types'; import type { Settings } from '../../types/config'; import type { ProfileType } from '../../types/profile'; +import { stripModelConfigurationSuffixes } from '../../shared/extended-context-utils'; export type ImageAnalysisResolutionSource = | 'cliproxy-provider' @@ -20,6 +22,7 @@ export type ImageAnalysisResolutionSource = | 'cliproxy-bridge' | 'profile-backend' | 'fallback-backend' + | 'native-compatible' | 'disabled' | 'unsupported-profile' | 'unresolved' @@ -49,7 +52,7 @@ export interface ImageAnalysisResolutionContext { settingsPath?: string | null; cliproxyProvider?: string | null; isComposite?: boolean; - settings?: Pick | null; + settings?: Pick | null; cliproxyBridge?: CliproxyBridgeMetadata | null; hookInstalled?: boolean; sharedHookInstalled?: boolean; @@ -79,8 +82,26 @@ export interface ImageAnalysisStatus { proxyReason: string | null; effectiveRuntimeMode: ImageAnalysisEffectiveRuntimeMode; effectiveRuntimeReason: string | null; + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + nativeImageReason: string | null; } +interface NativeImageSupportResolution { + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + nativeImageReason: string | null; +} + +const PROFILE_MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; + function resolveProviderFromBaseUrl(baseUrl: unknown): string | null { if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) { return null; @@ -233,9 +254,102 @@ function getRuntimePath(backendId: string | null): string | null { return `/api/provider/${backendId}`; } -function resolveBackend( +function resolveNativeImageProvider( + context: ImageAnalysisResolutionContext, + knownBackends: string[] +): string | null { + return normalizeImageAnalysisBackendId( + context.cliproxyProvider ?? + context.cliproxyBridge?.provider ?? + resolveProviderFromBaseUrl(context.settings?.env?.ANTHROPIC_BASE_URL ?? undefined), + knownBackends + ); +} + +function resolveProfileModel( + context: ImageAnalysisResolutionContext, + provider: string | null +): string | null { + const env = context.settings?.env; + if (env && typeof env === 'object') { + for (const key of PROFILE_MODEL_ENV_KEYS) { + const value = env[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + } + + if (provider && isCLIProxyProvider(provider)) { + return getProviderCatalog(provider)?.defaultModel ?? null; + } + + return null; +} + +function verifyNativeImageCapability( + provider: string | null, + modelId: string | null +): boolean | null { + if (!modelId) { + return null; + } + + if (provider && isCLIProxyProvider(provider) && supportsNativeImageInput(provider, modelId)) { + return true; + } + + const normalizedModel = stripModelConfigurationSuffixes(modelId).trim().toLowerCase(); + if (!normalizedModel) { + return null; + } + + if ( + normalizedModel.startsWith('gemini-') || + normalizedModel.startsWith('claude-') || + normalizedModel.startsWith('gpt-4o') || + normalizedModel.includes('vision') || + normalizedModel.includes('multimodal') || + /(^|[-_.])vl([-. _]|$)/.test(normalizedModel) || + /^glm-[\d.]+v([-. _]|$)/.test(normalizedModel) + ) { + return true; + } + + return null; +} + +function resolveNativeImageSupport( context: ImageAnalysisResolutionContext, config: ImageAnalysisConfig +): NativeImageSupportResolution { + const knownBackends = Object.keys(config.provider_models); + const provider = resolveNativeImageProvider(context, knownBackends); + const profileModel = resolveProfileModel(context, provider); + const nativeReadPreference = context.settings?.ccs_image?.native_read === true; + const nativeImageCapable = verifyNativeImageCapability(provider, profileModel); + + let nativeImageReason: string | null = null; + if (!profileModel) { + nativeImageReason = 'No current model is configured for this profile yet.'; + } else if (nativeImageCapable) { + nativeImageReason = `${profileModel} can read images natively.`; + } else { + nativeImageReason = `CCS cannot verify native image support for ${profileModel} yet.`; + } + + return { + profileModel, + nativeReadPreference, + nativeImageCapable, + nativeImageReason, + }; +} + +function resolveBackend( + context: ImageAnalysisResolutionContext, + config: ImageAnalysisConfig, + nativeSupport: NativeImageSupportResolution ): Pick { const { profileName, profileType, cliproxyProvider, isComposite, cliproxyBridge, settings } = context; @@ -258,6 +372,30 @@ function resolveBackend( }; } + if (nativeSupport.nativeReadPreference) { + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'native-compatible', + reason: + nativeSupport.nativeImageCapable === true + ? 'This profile is set to use native image reading.' + : `${nativeSupport.nativeImageReason ?? 'Native image reading is enabled for this profile.'} CCS will bypass the transformer for this profile.`, + }; + } + + // Explicit profile mappings are the only user-authored override and must + // win before provider/bridge inference. + const mappedBackend = resolveConfiguredProfileBackend(profileName, config); + if (mappedBackend) { + return { + backendId: mappedBackend, + backendDisplayName: getBackendDisplayName(mappedBackend), + resolutionSource: 'profile-backend', + reason: null, + }; + } + if (profileType === 'copilot' || profileName === 'copilot') { const backendId = normalizeImageAnalysisBackendId('ghcp', Object.keys(config.provider_models)); return { @@ -300,16 +438,6 @@ function resolveBackend( }; } - const mappedBackend = resolveConfiguredProfileBackend(profileName, config); - if (mappedBackend) { - return { - backendId: mappedBackend, - backendDisplayName: getBackendDisplayName(mappedBackend), - resolutionSource: 'profile-backend', - reason: null, - }; - } - const hasDirectAnthropicApiKey = Boolean(settings?.env?.ANTHROPIC_API_KEY?.trim()); const hasBaseUrl = Boolean(settings?.env?.ANTHROPIC_BASE_URL?.trim()); if (hasDirectAnthropicApiKey && !hasBaseUrl) { @@ -347,7 +475,8 @@ export function resolveImageAnalysisStatus( rawConfig: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG ): ImageAnalysisStatus { const config = canonicalizeImageAnalysisConfig(rawConfig); - const resolution = resolveBackend(context, config); + const nativeSupport = resolveNativeImageSupport(context, config); + const resolution = resolveBackend(context, config, nativeSupport); const model = resolution.backendId ? (config.provider_models[resolution.backendId] ?? null) : null; @@ -444,5 +573,9 @@ export function resolveImageAnalysisStatus( status === 'hook-missing' || !config.enabled || !resolution.backendId || !model ? reason : null, + profileModel: nativeSupport.profileModel, + nativeReadPreference: nativeSupport.nativeReadPreference, + nativeImageCapable: nativeSupport.nativeImageCapable, + nativeImageReason: nativeSupport.nativeImageReason, }; } diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index f6f4d942..3b98337e 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -27,7 +27,14 @@ const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR = type DashboardTarget = 'claude' | 'droid' | 'codex'; type DashboardSummaryState = 'ready' | 'partial' | 'needs_setup' | 'disabled'; type BackendState = 'ready' | 'starts_on_launch' | 'needs_auth' | 'needs_proxy' | 'review'; -type CurrentTargetMode = 'active' | 'bypassed' | 'fallback' | 'setup' | 'disabled' | 'unresolved'; +type CurrentTargetMode = + | 'active' + | 'bypassed' + | 'fallback' + | 'setup' + | 'disabled' + | 'native' + | 'unresolved'; interface ImageAnalysisRouteBody { enabled?: boolean; @@ -74,8 +81,9 @@ function resolveCurrentTargetMode( status: Awaited> ): CurrentTargetMode { if (!status.enabled) return 'disabled'; - if (!status.backendId) return 'unresolved'; if (target !== 'claude') return 'bypassed'; + if (status.nativeReadPreference) return 'native'; + if (!status.backendId) return 'unresolved'; if (status.status === 'hook-missing') return 'setup'; if (status.effectiveRuntimeMode === 'native-read') return 'fallback'; return 'active'; @@ -140,6 +148,10 @@ async function buildDashboardPayload() { effectiveRuntimeMode: status.effectiveRuntimeMode, effectiveRuntimeReason: status.effectiveRuntimeReason, currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status), + profileModel: status.profileModel, + nativeReadPreference: status.nativeReadPreference, + nativeImageCapable: status.nativeImageCapable, + nativeImageReason: status.nativeImageReason, }; }) ); @@ -180,6 +192,10 @@ async function buildDashboardPayload() { effectiveRuntimeMode: status.effectiveRuntimeMode, effectiveRuntimeReason: status.effectiveRuntimeReason, currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status), + profileModel: status.profileModel, + nativeReadPreference: status.nativeReadPreference, + nativeImageCapable: status.nativeImageCapable, + nativeImageReason: status.nativeImageReason, }; }) ); @@ -212,7 +228,9 @@ async function buildDashboardPayload() { authReason: status.authReason, proxyReadiness: status.proxyReadiness, proxyReason: status.proxyReason, - profilesUsing: allProfileRows.filter((profile) => profile.backendId === backendId).length, + profilesUsing: allProfileRows.filter( + (profile) => profile.backendId === backendId && !profile.nativeReadPreference + ).length, }; }) ); @@ -226,23 +244,27 @@ async function buildDashboardPayload() { const mappedProfileCount = allProfileRows.filter( (row) => row.resolutionSource === 'profile-backend' ).length; + const nativeProfileCount = allProfileRows.filter((row) => row.nativeReadPreference).length; const blockerCount = backendRows.filter( (row) => row.state === 'needs_auth' || row.state === 'needs_proxy' || row.state === 'review' ).length; let summaryState: DashboardSummaryState = 'ready'; let title = 'Ready'; - let detail = `${activeProfileCount} profile${activeProfileCount === 1 ? '' : 's'} can use Image Analysis on the current Claude target path.`; + let detail = `${activeProfileCount} profile${activeProfileCount === 1 ? '' : 's'} route through Image on the current Claude target path.`; + + if (nativeProfileCount > 0) { + detail += ` ${nativeProfileCount} prefer native image reading.`; + } if (!config.enabled) { summaryState = 'disabled'; title = 'Disabled'; - detail = - 'Image Analysis is turned off globally. Images and PDFs fall back to native file access.'; + detail = 'Image is turned off globally. Images and PDFs fall back to native file access.'; } else if (backendRows.length === 0) { summaryState = 'needs_setup'; title = 'Needs provider models'; - detail = 'Add at least one provider model before turning Image Analysis on for profiles.'; + detail = 'Add at least one provider model before turning Image on for profiles.'; } else if (blockerCount > 0) { summaryState = activeProfileCount > 0 ? 'partial' : 'needs_setup'; title = activeProfileCount > 0 ? 'Partially ready' : 'Needs setup'; @@ -265,6 +287,7 @@ async function buildDashboardPayload() { mappedProfileCount, activeProfileCount, bypassedProfileCount, + nativeProfileCount, }, backends: backendRows, profiles: allProfileRows, diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index 86c5c95e..d9a3015b 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -33,7 +33,7 @@ const CLIPROXY_API_KEY = 'test-api-key-12345'; // Default provider models for testing (matches DEFAULT_IMAGE_ANALYSIS_CONFIG) const DEFAULT_PROVIDER_MODELS = - 'agy:gemini-2.5-flash,gemini:gemini-2.5-flash,codex:gpt-5.1-codex-mini,kiro:kiro-claude-haiku-4-5,ghcp:claude-haiku-4.5,claude:claude-haiku-4-5-20251001'; + 'agy:gemini-3-1-flash-preview,gemini:gemini-3-flash-preview,codex:gpt-5.1-codex-mini,kiro:kiro-claude-haiku-4-5,ghcp:claude-haiku-4.5,claude:claude-haiku-4-5-20251001'; const DEFAULT_PROVIDER = 'agy'; // Default test provider // ============================================================================ @@ -626,7 +626,7 @@ describe('Image Analyzer Hook', () => { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_CURRENT_PROVIDER: 'agy', - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview', } ); @@ -647,7 +647,7 @@ describe('Image Analyzer Hook', () => { }>; }>; }; - expect(body.model).toBe('gemini-2.5-flash'); + expect(body.model).toBe('gemini-3-1-flash-preview'); expect(body.max_tokens).toBe(4096); expect(body.messages).toHaveLength(1); expect(body.messages[0].role).toBe('user'); @@ -738,7 +738,8 @@ describe('Image Analyzer Hook', () => { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_CURRENT_PROVIDER: 'codex', - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: + 'codex:gpt-5.1-codex-mini,agy:gemini-3-1-flash-preview', } ); @@ -756,7 +757,7 @@ describe('Image Analyzer Hook', () => { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_CURRENT_PROVIDER: 'unknown-provider', - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview', } ); @@ -820,7 +821,9 @@ describe('Image Analyzer Hook', () => { ); const output = JSON.parse(result.stdout); - expect(output.hookSpecificOutput.permissionDecisionReason).toContain('gemini-2.5-flash'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain( + 'gemini-3-1-flash-preview' + ); }); it('should output valid JSON structure on file read error', () => { diff --git a/tests/integration/image-analyzer-hook.test.ts b/tests/integration/image-analyzer-hook.test.ts index b21c8b7a..288e1ce5 100644 --- a/tests/integration/image-analyzer-hook.test.ts +++ b/tests/integration/image-analyzer-hook.test.ts @@ -58,7 +58,8 @@ function invokeHook(env: Record = {}): Promise { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_CURRENT_PROVIDER: 'codex', - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: + 'codex:gpt-5.1-codex-mini,agy:gemini-3-1-flash-preview', ...env, }, stdio: ['pipe', 'pipe', 'pipe'], @@ -195,7 +196,7 @@ describe('image analyzer hook regression coverage', () => { it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => { const result = await invokeHook({ CCS_CURRENT_PROVIDER: 'unknown-provider', - CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview', }); expect(result.code).toBe(0); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 4d675641..b379f5ab 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -100,9 +100,17 @@ describe('Model Catalog', () => { assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); - it('has 3 models total', () => { + it('includes Gemini Flash via Antigravity', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.models.length, 3); + const flash = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-1-flash-preview'); + assert(flash, 'Should include Gemini Flash'); + assert.strictEqual(flash.name, 'Gemini Flash'); + assert.strictEqual(flash.tier, undefined, 'AGY models should not have paid tier'); + }); + + it('has 4 models total', () => { + const { MODEL_CATALOG } = modelCatalog; + assert.strictEqual(MODEL_CATALOG.agy.models.length, 4); }); }); @@ -155,9 +163,17 @@ describe('Model Catalog', () => { assert.strictEqual(gem25.tier, undefined); }); - it('has 2 models total', () => { + it('includes Gemini Flash with pro tier', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.gemini.models.length, 2); + const flash = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-flash-preview'); + assert(flash, 'Should include Gemini Flash'); + assert.strictEqual(flash.name, 'Gemini Flash'); + assert.strictEqual(flash.tier, 'pro'); + }); + + it('has 3 models total', () => { + const { MODEL_CATALOG } = modelCatalog; + assert.strictEqual(MODEL_CATALOG.gemini.models.length, 3); }); }); diff --git a/tests/unit/commands/config-image-analysis-command.test.ts b/tests/unit/commands/config-image-analysis-command.test.ts index 3215964e..4e9d44b6 100644 --- a/tests/unit/commands/config-image-analysis-command.test.ts +++ b/tests/unit/commands/config-image-analysis-command.test.ts @@ -49,13 +49,13 @@ image_analysis: enabled: true timeout: 60 provider_models: - agy: gemini-2.5-flash + agy: gemini-3-1-flash-preview `); const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); expect(content).toContain('enabled: true'); expect(content).toContain('timeout: 60'); - expect(content).toContain('agy: gemini-2.5-flash'); + expect(content).toContain('agy: gemini-3-1-flash-preview'); }); it('should parse disabled status from config.yaml', () => { @@ -79,14 +79,14 @@ image_analysis: enabled: true timeout: 60 provider_models: - agy: gemini-2.5-flash + agy: gemini-3-1-flash-preview gemini: gemini-2.5-pro codex: gpt-5.1-codex-mini kiro: kiro-claude-haiku-4-5 `); const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); - expect(content).toContain('agy: gemini-2.5-flash'); + expect(content).toContain('agy: gemini-3-1-flash-preview'); expect(content).toContain('gemini: gemini-2.5-pro'); expect(content).toContain('codex: gpt-5.1-codex-mini'); expect(content).toContain('kiro: kiro-claude-haiku-4-5'); @@ -218,8 +218,8 @@ image_analysis: enabled: true, timeout: 60, provider_models: { - agy: 'gemini-2.5-flash', - gemini: 'gemini-2.5-flash', + agy: 'gemini-3-1-flash-preview', + gemini: 'gemini-3-flash-preview', codex: 'gpt-5.1-codex-mini', kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', @@ -244,7 +244,7 @@ image_analysis: enabled: true timeout: 60 provider_models: - agy: gemini-2.5-flash + agy: gemini-3-1-flash-preview `); const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts index 0f49d6b6..84d1ffdb 100644 --- a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -62,7 +62,7 @@ describe('image-analysis-backend-resolver', () => { expect(status.supported).toBe(true); expect(status.backendId).toBe('gemini'); expect(status.resolutionSource).toBe('fallback-backend'); - expect(status.model).toBe('gemini-2.5-flash'); + expect(status.model).toBe('gemini-3-flash-preview'); }); it('keeps direct Anthropic settings profiles on native read unless explicitly mapped', () => { @@ -108,6 +108,29 @@ describe('image-analysis-backend-resolver', () => { expect(status.resolutionSource).toBe('profile-backend'); }); + it('lets explicit profile_backends overrides win over cliproxy provider inference', () => { + const config: ImageAnalysisConfig = { + ...DEFAULT_IMAGE_ANALYSIS_CONFIG, + profile_backends: { + glmv: 'ghcp', + }, + }; + + const status = resolveImageAnalysisStatus( + { + profileName: 'glmv', + profileType: 'cliproxy', + cliproxyProvider: 'gemini', + }, + config + ); + + expect(status.supported).toBe(true); + expect(status.status).toBe('mapped'); + expect(status.backendId).toBe('ghcp'); + expect(status.resolutionSource).toBe('profile-backend'); + }); + it('reports hook-missing when the profile should persist a hook but it is absent', () => { const status = resolveImageAnalysisStatus( { @@ -128,4 +151,32 @@ describe('image-analysis-backend-resolver', () => { expect(status.status).toBe('hook-missing'); expect(status.reason).toContain('Profile hook is missing'); }); + + it('prefers native image reading when the profile settings opt into it', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'glmv', + profileType: 'settings', + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_MODEL: 'glm-4.5v', + ANTHROPIC_AUTH_TOKEN: 'glm-test-key', + }, + ccs_image: { + native_read: true, + }, + }, + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.backendId).toBeNull(); + expect(status.resolutionSource).toBe('native-compatible'); + expect(status.nativeReadPreference).toBe(true); + expect(status.profileModel).toBe('glm-4.5v'); + expect(status.nativeImageCapable).toBe(true); + expect(status.shouldPersistHook).toBe(false); + expect(status.effectiveRuntimeMode).toBe('native-read'); + }); }); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts index 844046f0..dd099371 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts @@ -27,6 +27,10 @@ function createStatus(overrides: Partial = {}): ImageAnalys proxyReason: 'CLIProxy runtime readiness has not been verified yet.', effectiveRuntimeMode: 'native-read', effectiveRuntimeReason: null, + profileModel: 'claude-haiku-4.5', + nativeReadPreference: false, + nativeImageCapable: true, + nativeImageReason: 'claude-haiku-4.5 can read images natively.', ...overrides, }; } diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts index 3d9b399b..557a9916 100644 --- a/tests/unit/web-server/image-analysis-routes.test.ts +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -101,7 +101,7 @@ describe('image-analysis routes', () => { enabled: true, timeout: 60, provider_models: { - gemini: 'gemini-2.5-flash', + gemini: 'gemini-3-flash-preview', ghcp: 'claude-haiku-4.5', }, fallback_backend: 'gemini', @@ -158,7 +158,7 @@ describe('image-analysis routes', () => { expect.arrayContaining([ expect.objectContaining({ backendId: 'gemini', - model: 'gemini-2.5-flash', + model: 'gemini-3-flash-preview', }), expect.objectContaining({ backendId: 'ghcp', @@ -227,7 +227,7 @@ describe('image-analysis routes', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ providerModels: { - gemini: 'gemini-2.5-flash', + gemini: 'gemini-3-flash-preview', }, fallbackBackend: 'gemini', profileBackends: { diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts index dcfc575d..bb375874 100644 --- a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -111,7 +111,7 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.status).toBe('active'); expect(body.imageAnalysisStatus.backendId).toBe('gemini'); expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend'); - expect(body.imageAnalysisStatus.model).toBe('gemini-2.5-flash'); + expect(body.imageAnalysisStatus.model).toBe('gemini-3-flash-preview'); expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json'); expect(body.imageAnalysisStatus.authReadiness).toBe('missing'); expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read'); @@ -255,4 +255,38 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge'); expect(body.imageAnalysisStatus.authReadiness).toBe('missing'); }); + + it('respects per-profile native image preference stored in settings json', async () => { + writeJson(path.join(tempHome, '.ccs', 'glmv.settings.json'), { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_MODEL: 'glm-4.5v', + ANTHROPIC_AUTH_TOKEN: 'glmv-test-key', + }, + ccs_image: { + native_read: true, + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/glmv/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + backendId: string | null; + resolutionSource: string; + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + effectiveRuntimeMode: string; + }; + }; + + expect(body.imageAnalysisStatus.backendId).toBeNull(); + expect(body.imageAnalysisStatus.resolutionSource).toBe('native-compatible'); + expect(body.imageAnalysisStatus.profileModel).toBe('glm-4.5v'); + expect(body.imageAnalysisStatus.nativeReadPreference).toBe(true); + expect(body.imageAnalysisStatus.nativeImageCapable).toBe(true); + expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read'); + }); }); diff --git a/ui/src/components/profiles/editor/image-analysis-status-section.tsx b/ui/src/components/profiles/editor/image-analysis-status-section.tsx index 2008269b..c1100487 100644 --- a/ui/src/components/profiles/editor/image-analysis-status-section.tsx +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -1,7 +1,8 @@ -import { AlertTriangle, ArrowUpRight, Image as ImageIcon } from 'lucide-react'; +import { ArrowUpRight, Image as ImageIcon } from 'lucide-react'; import { Link } from 'react-router-dom'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client'; @@ -10,158 +11,122 @@ interface ImageAnalysisStatusSectionProps { target?: CliTarget; source?: 'saved' | 'editor'; previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; + nativeReadPreferenceOverride?: boolean; + onToggleNativeRead?: (enabled: boolean) => void; } -const SOURCE_LABELS: Record = { - 'cliproxy-provider': 'Direct provider route', - 'cliproxy-variant': 'Variant route', - 'cliproxy-composite': 'Composite route', - 'copilot-alias': 'Copilot alias', - 'cliproxy-bridge': 'Derived from profile route', - 'profile-backend': 'Explicit profile mapping', - 'fallback-backend': 'Fallback backend', - disabled: 'Disabled globally', - 'unsupported-profile': 'Unsupported profile type', - unresolved: 'No backend mapped', - 'missing-model': 'Missing model', -}; - const TARGET_LABELS: Record = { claude: 'Claude Code', droid: 'Factory Droid', codex: 'Codex CLI', }; -function getPreviewBadge( +function getPreviewLabel( source: 'saved' | 'editor', previewState: ImageAnalysisStatusSectionProps['previewState'] ) { - if (previewState === 'refreshing') { - return { label: 'Refreshing', variant: 'outline' as const }; - } - if (source === 'editor') { - return { label: 'Live Preview', variant: 'secondary' as const }; - } - return { label: 'Saved', variant: 'outline' as const }; + if (previewState === 'refreshing') return 'Refreshing preview'; + if (previewState === 'invalid') return 'Saved status'; + return source === 'editor' ? 'Live preview' : 'Saved status'; } -function getRuntimeBadge(status: ImageAnalysisStatus, target: CliTarget) { +function getHeaderLabel(status: ImageAnalysisStatus, target: CliTarget): string { + if (status.status === 'disabled') return 'Disabled globally'; + if (target !== 'claude') return `${TARGET_LABELS[target]} bypasses the hook`; + if (status.nativeReadPreference) return 'Native image reading'; + if (status.status === 'hook-missing') return 'Setup needed'; + if (status.authReadiness === 'missing') return 'Needs auth'; + if (status.proxyReadiness === 'unavailable') return 'Needs proxy'; + if (status.effectiveRuntimeMode === 'native-read') return 'Native fallback'; + return 'Transformer ready'; +} + +function getHeaderBadge( + status: ImageAnalysisStatus, + target: CliTarget +): { + label: string; + className: string; +} { if (status.status === 'disabled') { return { label: 'Disabled', className: 'border-border/80 bg-background/85 text-muted-foreground', }; } - if (status.status === 'hook-missing') { - return { - label: 'Setup needed', - className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', - }; - } - if (status.authReadiness === 'missing') { - return { - label: 'Needs auth', - className: 'border-rose-500/25 bg-rose-500/10 text-rose-800 dark:text-rose-200', - }; - } - if (status.proxyReadiness === 'unavailable') { - return { - label: 'Needs proxy', - className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', - }; - } if (target !== 'claude') { return { label: 'Bypassed', className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200', }; } - if (status.effectiveRuntimeMode === 'native-read') { + if (status.nativeReadPreference) { return { - label: 'Native fallback', - className: 'border-border/80 bg-background/85 text-muted-foreground', + label: 'Native', + className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', }; } - if (status.proxyReadiness === 'stopped') { + if (status.status === 'hook-missing' || status.authReadiness === 'missing') { return { - label: 'Starts on launch', - className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200', + label: status.status === 'hook-missing' ? 'Setup' : 'Auth', + className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', + }; + } + if (status.proxyReadiness === 'unavailable') { + return { + label: 'Proxy', + className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', }; } return { - label: 'CLIProxy active', + label: 'Ready', className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', }; } -function getStatusContext( - source: 'saved' | 'editor', - previewState: ImageAnalysisStatusSectionProps['previewState'] -) { - if (previewState === 'invalid') { - return 'Showing saved status until the JSON above is valid again.'; +function getToggleSummary(status: ImageAnalysisStatus, target: CliTarget): string { + if (status.nativeReadPreference) { + if (status.profileModel && status.nativeImageCapable) { + return `${status.profileModel} looks image-ready. CCS will bypass the transformer here.`; + } + if (status.profileModel) { + return `CCS will prefer native reading for ${status.profileModel}.`; + } + return 'CCS will prefer native image reading for this profile.'; } - if (previewState === 'refreshing') { - return 'Refreshing from the current editor state.'; + + if (!status.backendDisplayName && target === 'claude') { + return 'This profile currently stays on native file access.'; } - return source === 'editor' - ? 'Preview from the current editor JSON.' - : 'Saved status for this profile.'; + + if (!status.backendDisplayName) { + return `Saved Claude-side image routing is inactive while ${TARGET_LABELS[target]} is selected.`; + } + + const modelSuffix = status.model ? ` · ${status.model}` : ''; + return `Transformer route: ${status.backendDisplayName}${modelSuffix}.`; } -function getSummary(status: ImageAnalysisStatus, target: CliTarget): string { - const backendName = status.backendDisplayName || status.backendId || 'native file access'; +function getExceptionalNote(status: ImageAnalysisStatus, target: CliTarget): string | null { if (status.status === 'disabled') { - return 'Image Analysis is disabled globally for this profile.'; - } - if (!status.backendId) { - return target === 'claude' - ? 'This profile currently uses native file access for images and PDFs.' - : `Current target ${TARGET_LABELS[target]} bypasses the hook and no saved backend is mapped.`; + return 'Image is disabled globally in CCS settings.'; } if (target !== 'claude') { - return status.effectiveRuntimeMode === 'native-read' - ? `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side setup for ${backendName} currently falls back to native file access.` - : `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side backend: ${backendName}.`; + return `Current target ${TARGET_LABELS[target]} bypasses the Claude Read hook.`; } - if (status.effectiveRuntimeMode === 'native-read') { - return `Saved backend: ${backendName}. Current runtime falls back to native file access.`; - } - return `Saved backend: ${backendName}. Images and PDFs resolve through CLIProxy on this target.`; -} - -function getTargetDetail(status: ImageAnalysisStatus, target: CliTarget): string { - if (target !== 'claude') { - return 'Current launch path bypasses the Claude Read hook.'; + if (status.nativeReadPreference) { + return status.nativeImageCapable === true ? null : status.nativeImageReason; } if (status.status === 'hook-missing') { - return 'Hook must be persisted before this profile can use Image Analysis.'; + return 'Persist the profile hook before transformer routing can run here.'; } - if (status.effectiveRuntimeMode === 'native-read') { - return status.effectiveRuntimeReason || status.reason || 'Using native file access.'; + if (status.authReadiness === 'missing') { + return status.authReason; } - if (status.proxyReadiness === 'stopped') { - return 'Auth is ready. Local CLIProxy will start on demand.'; + if (status.proxyReadiness === 'unavailable') { + return status.proxyReason; } - return 'Current target can use the saved backend.'; -} - -function getPersistenceValue(status: ImageAnalysisStatus): string { - if (!status.shouldPersistHook || !status.persistencePath) { - return 'Not required'; - } - return status.hookInstalled ? 'Hook saved' : 'Hook missing'; -} - -function getPersistenceDetail(status: ImageAnalysisStatus): string { - if (!status.shouldPersistHook || !status.persistencePath) { - return 'No profile-level hook persistence required.'; - } - return status.persistencePath; -} - -function getModelValue(status: ImageAnalysisStatus): string { - return status.model || status.reason || 'Unavailable'; + return null; } export function ImageAnalysisStatusSection({ @@ -169,147 +134,88 @@ export function ImageAnalysisStatusSection({ target = 'claude', source = 'saved', previewState = 'saved', + nativeReadPreferenceOverride, + onToggleNativeRead, }: ImageAnalysisStatusSectionProps) { if (!status) { return ( -
-
-
+
+
+
); } - const previewBadge = getPreviewBadge(source, previewState); - const runtimeBadge = getRuntimeBadge(status, target); - const showNotice = - status.status === 'hook-missing' || - status.authReadiness === 'missing' || - status.proxyReadiness === 'unavailable'; + const nativeReadChecked = nativeReadPreferenceOverride ?? status.nativeReadPreference; + const effectiveStatus = { ...status, nativeReadPreference: nativeReadChecked }; + const headerBadge = getHeaderBadge(effectiveStatus, target); + const note = getExceptionalNote(effectiveStatus, target); + const capabilityLabel = status.nativeImageCapable + ? 'Verified' + : status.profileModel + ? 'Unknown' + : null; return ( -
-
-
+
+
+
- -

Image Analysis

+
+ +
+
+
+

Image

+ + {headerBadge.label} + +
+

+ {getPreviewLabel(source, previewState)} · {getHeaderLabel(effectiveStatus, target)} +

+
-

- {getStatusContext(source, previewState)} -

-
-
- - {previewBadge.label} - - - {runtimeBadge.label} - -
-
- -

{getSummary(status, target)}

- -
-
-
- Backend -
-
- {status.backendDisplayName || status.backendId || 'Native file access'} -
-

- {SOURCE_LABELS[status.resolutionSource]} -

-
-
- Current target -
-
{TARGET_LABELS[target]}
-

- {getTargetDetail(status, target)} -

-
- -
-
- Persistence -
-
- {getPersistenceValue(status)} -
-

- {getPersistenceDetail(status)} -

-
-
- -
-
-
- Auth -
-
- {status.authReadiness === 'ready' - ? `${status.authDisplayName || status.authProvider} ready` - : status.authReadiness === 'missing' - ? status.authReason - : status.authReadiness === 'not-needed' - ? 'Not required' - : 'Unknown'} -
-
-
-
- Proxy -
-
- {status.proxyReadiness === 'ready' - ? 'Local CLIProxy ready' - : status.proxyReadiness === 'remote' - ? 'Remote CLIProxy ready' - : status.proxyReadiness === 'stopped' - ? 'Local CLIProxy idle' - : status.proxyReadiness === 'not-needed' - ? 'Not required' - : status.proxyReason || 'Unknown'} -
-
-
-
- Model -
-
- {getModelValue(status)} -
-
-
- - {showNotice && ( -
- - - {status.effectiveRuntimeReason || status.reason || 'Review the saved configuration.'} - -
- )} - -
-

- CRUD and backend routing now live in global Settings. -

-
+ +
+
+
+
+
Use native image reading
+ {capabilityLabel && ( + + {capabilityLabel} + + )} +
+

+ {getToggleSummary(effectiveStatus, target)} +

+
+ + +
+
+ + {note && ( +
+ {note} +
+ )}
); } diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index c633ad81..a7daa66a 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -67,6 +67,31 @@ export function ProfileEditor({ setRawJsonEdits(value); }, []); + const updateNativeImageRead = useCallback( + (enabled: boolean) => { + const nextSettings = { ...(currentSettings ?? {}) } as Settings; + const currentCcsImage = + nextSettings.ccs_image && typeof nextSettings.ccs_image === 'object' + ? { ...nextSettings.ccs_image } + : {}; + + if (enabled) { + currentCcsImage.native_read = true; + } else { + delete currentCcsImage.native_read; + } + + if (Object.keys(currentCcsImage).length > 0) { + nextSettings.ccs_image = currentCcsImage; + } else { + delete nextSettings.ccs_image; + } + + setRawJsonEdits(JSON.stringify(nextSettings, null, 2)); + }, + [currentSettings] + ); + // Sync Visual Editor changes to Raw JSON const updateEnvValue = (key: string, value: string) => { const newEnv = { ...(currentSettings?.env || {}), [key]: value }; @@ -165,6 +190,7 @@ export function ProfileEditor({ (!previewStatusResponse?.imageAnalysisStatus || isPreviewStatusPlaceholderData) ? 'refreshing' : 'preview'; + const nativeReadPreferenceOverride = currentSettings?.ccs_image?.native_read === true; // Check for missing required fields (informational warning) const missingRequiredFields = useMemo(() => { @@ -318,6 +344,8 @@ export function ProfileEditor({ imageAnalysisStatus={imageAnalysisStatus} imageAnalysisStatusSource={imageAnalysisStatusSource} imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState} + nativeReadPreferenceOverride={nativeReadPreferenceOverride} + onToggleNativeRead={updateNativeImageRead} onChange={handleRawJsonChange} missingRequiredFields={missingRequiredFields} /> diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index a331d605..924e187b 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -24,6 +24,8 @@ interface RawEditorSectionProps { imageAnalysisStatus?: ImageAnalysisStatus | null; imageAnalysisStatusSource?: 'saved' | 'editor'; imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; + nativeReadPreferenceOverride?: boolean; + onToggleNativeRead?: (enabled: boolean) => void; onChange: (value: string) => void; missingRequiredFields?: string[]; } @@ -37,6 +39,8 @@ export function RawEditorSection({ imageAnalysisStatus, imageAnalysisStatusSource = 'saved', imageAnalysisStatusPreviewState = 'saved', + nativeReadPreferenceOverride, + onToggleNativeRead, onChange, missingRequiredFields = [], }: RawEditorSectionProps) { @@ -91,6 +95,8 @@ export function RawEditorSection({ target={profileTarget} source={imageAnalysisStatusSource} previewState={imageAnalysisStatusPreviewState} + nativeReadPreferenceOverride={nativeReadPreferenceOverride} + onToggleNativeRead={onToggleNativeRead} />
{/* Global Env Indicator */} diff --git a/ui/src/components/profiles/editor/types.ts b/ui/src/components/profiles/editor/types.ts index e38baa84..38453fa8 100644 --- a/ui/src/components/profiles/editor/types.ts +++ b/ui/src/components/profiles/editor/types.ts @@ -6,6 +6,9 @@ import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/l export interface Settings { env?: Record; + ccs_image?: { + native_read?: boolean; + }; } export interface SettingsResponse { diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 3d4ffeea..b092142a 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -126,6 +126,7 @@ export interface ImageAnalysisStatus { | 'cliproxy-bridge' | 'profile-backend' | 'fallback-backend' + | 'native-compatible' | 'disabled' | 'unsupported-profile' | 'unresolved' @@ -146,6 +147,10 @@ export interface ImageAnalysisStatus { proxyReason: string | null; effectiveRuntimeMode: 'cliproxy-image-analysis' | 'native-read'; effectiveRuntimeReason: string | null; + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + nativeImageReason: string | null; } export interface ImageAnalysisSettingsConfig { @@ -164,6 +169,7 @@ export interface ImageAnalysisDashboardSummary { mappedProfileCount: number; activeProfileCount: number; bypassedProfileCount: number; + nativeProfileCount: number; } export interface ImageAnalysisDashboardBackend { @@ -190,7 +196,18 @@ export interface ImageAnalysisDashboardProfile { status: ImageAnalysisStatus['status']; effectiveRuntimeMode: ImageAnalysisStatus['effectiveRuntimeMode']; effectiveRuntimeReason: string | null; - currentTargetMode: 'active' | 'bypassed' | 'fallback' | 'setup' | 'disabled' | 'unresolved'; + currentTargetMode: + | 'active' + | 'bypassed' + | 'fallback' + | 'setup' + | 'disabled' + | 'native' + | 'unresolved'; + profileModel: string | null; + nativeReadPreference: boolean; + nativeImageCapable: boolean | null; + nativeImageReason: string | null; } export interface ImageAnalysisDashboardCatalog { diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index b8a3484e..d1df019a 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -150,19 +150,19 @@ export const MODEL_CATALOGS: Record = { default: 'gemini-3.1-pro-preview', opus: 'gemini-3.1-pro-preview', sonnet: 'gemini-3.1-pro-preview', - haiku: 'gemini-3-flash-preview', + haiku: 'gemini-3-1-flash-preview', }, }, { - id: 'gemini-3-flash-preview', + id: 'gemini-3-1-flash-preview', name: 'Gemini Flash', description: 'Resolves to the best advertised Gemini Flash preview via Antigravity', extendedContext: true, presetMapping: { - default: 'gemini-3-flash-preview', + default: 'gemini-3-1-flash-preview', opus: 'gemini-3.1-pro-preview', sonnet: 'gemini-3.1-pro-preview', - haiku: 'gemini-3-flash-preview', + haiku: 'gemini-3-1-flash-preview', }, }, ], diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index 9e9f28ed..c3a59058 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -26,7 +26,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { const { t } = useTranslation(); const tabs = [ { value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe }, - { value: 'imageanalysis' as const, label: 'Image Analysis', icon: ImageIcon }, + { value: 'image' as const, label: 'Image', icon: ImageIcon }, { value: 'channels' as const, label: 'Channels', icon: MessageSquare }, { value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 }, { value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain }, @@ -39,7 +39,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { onTabChange(v as SettingsTab)}> {tabs.map(({ value, label, icon: Icon }) => ( - + {label} diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 66315458..e2ce37f5 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -11,8 +11,8 @@ export function useSettingsTab() { // Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups) const tabParam = searchParams.get('tab')?.toLowerCase(); const activeTab: SettingsTab = - tabParam === 'imageanalysis' - ? 'imageanalysis' + tabParam === 'imageanalysis' || tabParam === 'image' + ? 'image' : tabParam === 'channels' ? 'channels' : tabParam === 'globalenv' diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index fbc63798..9608eab5 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -132,7 +132,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } - {activeTab === 'imageanalysis' && } + {activeTab === 'image' && } {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } @@ -146,7 +146,7 @@ function SettingsPageInner() { {/* Desktop View - Side-by-side panels */} {/* Left Panel - Settings Controls */} - +
{/* Header with Tabs */}
@@ -157,7 +157,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } - {activeTab === 'imageanalysis' && } + {activeTab === 'image' && } {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } @@ -175,7 +175,7 @@ function SettingsPageInner() { {/* Right Panel - Config Viewer */} - +
{/* Header */}
diff --git a/ui/src/pages/settings/sections/image-analysis/index.tsx b/ui/src/pages/settings/sections/image-analysis/index.tsx index acb7796b..11915b6e 100644 --- a/ui/src/pages/settings/sections/image-analysis/index.tsx +++ b/ui/src/pages/settings/sections/image-analysis/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -13,12 +13,17 @@ import { SelectValue, } from '@/components/ui/select'; import { + Activity, AlertCircle, CheckCircle2, + ChevronDown, + ChevronUp, + GitBranch, Image as ImageIcon, Plus, RefreshCw, - Save, + SlidersHorizontal, + Sparkles, Trash2, } from 'lucide-react'; import { api, type ImageAnalysisDashboardData } from '@/lib/api-client'; @@ -31,6 +36,9 @@ interface MappingDraft { backendId: string; } +type ImageBackend = ImageAnalysisDashboardData['backends'][number]; +type ImageProfile = ImageAnalysisDashboardData['profiles'][number]; + const NO_BACKEND = '__no_backend__'; function isStringRecord(value: unknown): value is Record { @@ -64,7 +72,8 @@ function isImageAnalysisDashboardData(value: unknown): value is ImageAnalysisDas Array.isArray(candidate.profiles) && !!candidate.catalog && Array.isArray(candidate.catalog.knownBackends) && - Array.isArray(candidate.catalog.profileNames) + Array.isArray(candidate.catalog.profileNames) && + typeof candidate.summary.nativeProfileCount === 'number' ); } @@ -120,6 +129,8 @@ function currentTargetModeLabel( return 'Needs setup'; case 'disabled': return 'Disabled'; + case 'native': + return 'Native'; case 'unresolved': return 'Native only'; } @@ -136,12 +147,315 @@ function currentTargetModeClass( case 'fallback': case 'setup': return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200'; + case 'native': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200'; case 'disabled': case 'unresolved': return 'border-border/80 bg-background/85 text-muted-foreground'; } } +function backendStateLabel(state: ImageBackend['state']): string { + switch (state) { + case 'starts_on_launch': + return 'Starts on launch'; + case 'needs_auth': + return 'Needs auth'; + case 'needs_proxy': + return 'Needs proxy'; + case 'review': + return 'Review'; + case 'ready': + return 'Ready'; + } +} + +function backendStatusNote(backend: ImageBackend | undefined): string | null { + if (!backend) { + return 'No model configured.'; + } + + switch (backend.state) { + case 'needs_auth': + return backend.authReason || 'Authenticate to route here.'; + case 'needs_proxy': + return backend.proxyReason || 'Proxy unavailable.'; + case 'starts_on_launch': + return 'Auth ready. Launches locally on demand.'; + case 'review': + return 'Needs manual review.'; + case 'ready': + return null; + } +} + +function routeSourceLabel(source: ImageProfile['resolutionSource']): string { + switch (source) { + case 'profile-backend': + return 'Explicit mapping'; + case 'fallback-backend': + return 'Fallback backend'; + case 'cliproxy-provider': + return 'Provider match'; + case 'cliproxy-bridge': + return 'Bridge match'; + case 'native-compatible': + return 'Native path'; + case 'copilot-alias': + return 'Copilot alias'; + default: + return source.replace(/-/g, ' '); + } +} + +type SectionTone = 'sky' | 'amber' | 'emerald' | 'cyan' | 'slate'; + +function getSectionShellClass(tone: SectionTone): string { + switch (tone) { + case 'sky': + return 'border-sky-500/20 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_45%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(248,250,252,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.88))]'; + case 'amber': + return 'border-amber-500/20 bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.14),transparent_44%),linear-gradient(180deg,rgba(255,251,235,0.96),rgba(255,247,237,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.16),transparent_44%),linear-gradient(180deg,rgba(35,26,17,0.96),rgba(28,22,17,0.92))]'; + case 'emerald': + return 'border-emerald-500/20 bg-[radial-gradient(circle_at_top_left,rgba(16,185,129,0.14),transparent_46%),linear-gradient(180deg,rgba(236,253,245,0.96),rgba(240,253,250,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(16,185,129,0.18),transparent_46%),linear-gradient(180deg,rgba(16,32,28,0.96),rgba(15,28,25,0.92))]'; + case 'cyan': + return 'border-cyan-500/20 bg-[radial-gradient(circle_at_top_left,rgba(6,182,212,0.14),transparent_44%),linear-gradient(180deg,rgba(236,254,255,0.96),rgba(240,249,255,0.92))] dark:bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.16),transparent_44%),linear-gradient(180deg,rgba(13,28,34,0.96),rgba(15,25,32,0.92))]'; + case 'slate': + return 'border-slate-400/20 bg-[radial-gradient(circle_at_top_right,rgba(148,163,184,0.14),transparent_44%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.96))] dark:bg-[radial-gradient(circle_at_top_right,rgba(148,163,184,0.16),transparent_44%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(17,24,39,0.96))]'; + } +} + +function getSectionRuleClass(tone: SectionTone): string { + switch (tone) { + case 'sky': + return 'from-sky-500/60 via-sky-500/15'; + case 'amber': + return 'from-amber-500/60 via-amber-500/15'; + case 'emerald': + return 'from-emerald-500/60 via-emerald-500/15'; + case 'cyan': + return 'from-cyan-500/60 via-cyan-500/15'; + case 'slate': + return 'from-slate-400/55 via-slate-400/15'; + } +} + +function getSectionIconClass(tone: SectionTone): string { + switch (tone) { + case 'sky': + return 'border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300'; + case 'amber': + return 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300'; + case 'emerald': + return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; + case 'cyan': + return 'border-cyan-500/20 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300'; + case 'slate': + return 'border-slate-400/25 bg-slate-500/10 text-slate-700 dark:text-slate-300'; + } +} + +function getSectionEyebrowClass(tone: SectionTone): string { + switch (tone) { + case 'sky': + return 'text-sky-700/80 dark:text-sky-300/80'; + case 'amber': + return 'text-amber-700/80 dark:text-amber-300/80'; + case 'emerald': + return 'text-emerald-700/80 dark:text-emerald-300/80'; + case 'cyan': + return 'text-cyan-700/80 dark:text-cyan-300/80'; + case 'slate': + return 'text-slate-600/80 dark:text-slate-300/80'; + } +} + +function getInsetPanelClass(tone: SectionTone): string { + switch (tone) { + case 'sky': + return 'border-sky-500/15 bg-background/75'; + case 'amber': + return 'border-amber-500/15 bg-background/80'; + case 'emerald': + return 'border-emerald-500/15 bg-background/78'; + case 'cyan': + return 'border-cyan-500/15 bg-background/78'; + case 'slate': + return 'border-slate-400/15 bg-background/78'; + } +} + +function getBackendRowClass(state: ImageBackend['state'] | undefined): string { + switch (state) { + case 'ready': + return 'bg-[linear-gradient(90deg,rgba(16,185,129,0.08),transparent_18%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.46))] dark:bg-[linear-gradient(90deg,rgba(16,185,129,0.12),transparent_18%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(15,23,42,0.56))]'; + case 'starts_on_launch': + return 'bg-[linear-gradient(90deg,rgba(14,165,233,0.08),transparent_18%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.46))] dark:bg-[linear-gradient(90deg,rgba(14,165,233,0.12),transparent_18%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(15,23,42,0.56))]'; + case 'needs_auth': + return 'bg-[linear-gradient(90deg,rgba(244,63,94,0.08),transparent_18%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.46))] dark:bg-[linear-gradient(90deg,rgba(244,63,94,0.12),transparent_18%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(15,23,42,0.56))]'; + case 'needs_proxy': + return 'bg-[linear-gradient(90deg,rgba(245,158,11,0.08),transparent_18%),linear-gradient(180deg,rgba(255,255,255,0.72),rgba(255,255,255,0.46))] dark:bg-[linear-gradient(90deg,rgba(245,158,11,0.12),transparent_18%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(15,23,42,0.56))]'; + case 'review': + default: + return 'bg-[linear-gradient(180deg,rgba(255,255,255,0.74),rgba(255,255,255,0.5))] dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.8),rgba(15,23,42,0.58))]'; + } +} + +function getBackendRailClass(state: ImageBackend['state'] | undefined): string { + switch (state) { + case 'ready': + return 'from-emerald-500 to-emerald-400/30'; + case 'starts_on_launch': + return 'from-sky-500 to-sky-400/30'; + case 'needs_auth': + return 'from-rose-500 to-rose-400/30'; + case 'needs_proxy': + return 'from-amber-500 to-amber-400/30'; + case 'review': + default: + return 'from-slate-400 to-slate-300/20'; + } +} + +function getCoverageRowClass(index: number, profile: ImageProfile): string { + if (profile.nativeReadPreference) { + return index % 2 === 0 ? 'bg-emerald-500/[0.06]' : 'bg-emerald-500/[0.08]'; + } + + return index % 2 === 0 ? 'bg-background/75' : 'bg-muted/18'; +} + +function summaryCompactDetail(summary: ImageAnalysisDashboardData['summary']): string { + const parts = [`${summary.activeProfileCount} routed`, `${summary.nativeProfileCount} native`]; + + if (summary.mappedProfileCount > 0) { + parts.push( + `${summary.mappedProfileCount} override${summary.mappedProfileCount === 1 ? '' : 's'}` + ); + } + + return parts.join(' · '); +} + +function buildProviderModelsPayload( + providerModels: Record +): Record { + return Object.entries(providerModels).reduce( + (acc, [backendId, model]) => { + const normalizedModel = model.trim(); + acc[backendId] = normalizedModel || null; + return acc; + }, + {} as Record + ); +} + +function getConfiguredBackendIds(providerModels: Record): string[] { + return Object.entries(providerModels) + .filter(([, model]) => model.trim().length > 0) + .map(([backendId]) => backendId); +} + +function buildProfileBackends(mappingDrafts: MappingDraft[]): Record { + return mappingDrafts.reduce( + (acc, row) => { + const profileName = row.profileName.trim(); + if (!profileName || !row.backendId) { + return acc; + } + + acc[profileName] = row.backendId; + return acc; + }, + {} as Record + ); +} + +function normalizeTimeoutDraft(rawValue: string, fallbackValue: string): string { + const parsed = Number.parseInt(rawValue.trim(), 10); + if (!Number.isInteger(parsed)) { + return fallbackValue; + } + + return String(Math.min(600, Math.max(10, parsed))); +} + +interface ImageSectionPanelProps { + tone: SectionTone; + eyebrow: string; + title: string; + description: string; + icon: ReactNode; + meta?: ReactNode; + action?: ReactNode; + children: ReactNode; + className?: string; +} + +function ImageSectionPanel({ + tone, + eyebrow, + title, + description, + icon, + meta, + action, + children, + className, +}: ImageSectionPanelProps) { + return ( +
+
+
+ +
+
+
+
+ {icon} +
+
+
+ {eyebrow} +
+
+

{title}

+ {meta} +
+

{description}

+
+
+
+ + {action &&
{action}
} +
+ +
{children}
+
+ ); +} + export default function ImageAnalysisSection() { const { fetchRawConfig } = useRawConfig(); const [data, setData] = useState(null); @@ -149,6 +463,7 @@ export default function ImageAnalysisSection() { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [showProfileRouting, setShowProfileRouting] = useState(false); const [enabled, setEnabled] = useState(true); const [timeout, setTimeout] = useState('60'); @@ -179,13 +494,13 @@ export default function ImageAnalysisSection() { const payload = await api.imageAnalysis.get(); if (!isImageAnalysisDashboardData(payload)) { throw new Error( - 'Image Analysis settings returned an unexpected response. Restart the dashboard server so the new API route is available.' + 'Image settings returned an unexpected response. Restart the dashboard server so the new API route is available.' ); } setData(payload); hydrateDraft(payload); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load Image Analysis settings.'); + setError(err instanceof Error ? err.message : 'Failed to load image settings.'); } finally { setLoading(false); } @@ -202,14 +517,36 @@ export default function ImageAnalysisSection() { return () => window.clearTimeout(timer); }, [success]); + useEffect(() => { + if (!data) return; + if (Object.keys(data.config.profileBackends).length > 0) { + setShowProfileRouting(true); + } + }, [data]); + const configuredBackendIds = useMemo( - () => - Object.entries(providerModels) - .filter(([, model]) => model.trim().length > 0) - .map(([backendId]) => backendId), + () => getConfiguredBackendIds(providerModels), [providerModels] ); + const orderedBackendIds = useMemo(() => { + if (!data) return []; + + const configured = data.catalog.knownBackends.filter((backendId) => + configuredBackendIds.includes(backendId) + ); + const inactive = data.catalog.knownBackends.filter( + (backendId) => !configuredBackendIds.includes(backendId) + ); + + return [...configured, ...inactive]; + }, [configuredBackendIds, data]); + + const nativeReadProfiles = useMemo( + () => data?.profiles.filter((profile) => profile.nativeReadPreference) ?? [], + [data] + ); + useEffect(() => { if (configuredBackendIds.length === 0) { setFallbackBackend(''); @@ -220,42 +557,40 @@ export default function ImageAnalysisSection() { } }, [configuredBackendIds, fallbackBackend]); - const payloadPreview = useMemo(() => { - const nextProviderModels = Object.entries(providerModels).reduce( - (acc, [backendId, model]) => { - const normalizedModel = typeof model === 'string' ? model.trim() : ''; - acc[backendId] = normalizedModel || null; - return acc; - }, - {} as Record - ); + const persistSettings = useCallback( + async (overrides?: { + enabled?: boolean; + timeout?: string; + fallbackBackend?: string; + providerModels?: Record; + mappingDrafts?: MappingDraft[]; + }) => { + if (!data) return false; - const nextProfileBackends = mappingDrafts.reduce( - (acc, row) => { - const profileName = row.profileName.trim(); - if (!profileName || !row.backendId) { - return acc; - } - acc[profileName] = row.backendId; - return acc; - }, - {} as Record - ); + const nextEnabled = overrides?.enabled ?? enabled; + const nextProviderModels = overrides?.providerModels ?? providerModels; + const nextConfiguredBackendIds = getConfiguredBackendIds(nextProviderModels); + const nextTimeout = normalizeTimeoutDraft( + overrides?.timeout ?? timeout, + String(data.config.timeout) + ); + const requestedFallbackBackend = overrides?.fallbackBackend ?? fallbackBackend; + const nextFallbackBackend = + nextConfiguredBackendIds.length === 0 + ? '' + : nextConfiguredBackendIds.includes(requestedFallbackBackend) + ? requestedFallbackBackend + : nextConfiguredBackendIds[0]; + const nextMappingDrafts = overrides?.mappingDrafts ?? mappingDrafts; + const nextPayload = { + enabled: nextEnabled, + timeout: nextTimeout, + fallbackBackend: nextFallbackBackend, + providerModels: buildProviderModelsPayload(nextProviderModels), + profileBackends: buildProfileBackends(nextMappingDrafts), + }; - return { - enabled, - timeout, - fallbackBackend, - providerModels: nextProviderModels, - profileBackends: nextProfileBackends, - }; - }, [enabled, fallbackBackend, mappingDrafts, providerModels, timeout]); - - const hasChanges = useMemo(() => { - if (!data) return false; - return ( - JSON.stringify(payloadPreview) !== - JSON.stringify({ + const currentPayload = { enabled: data.config.enabled, timeout: String(data.config.timeout), fallbackBackend: data.config.fallbackBackend ?? '', @@ -267,11 +602,52 @@ export default function ImageAnalysisSection() { {} as Record ), profileBackends: data.config.profileBackends, - }) - ); - }, [data, payloadPreview]); + }; - const canSave = configuredBackendIds.length > 0 && Number.isInteger(Number(timeout)); + if (JSON.stringify(nextPayload) === JSON.stringify(currentPayload)) { + return true; + } + + if (nextEnabled && nextConfiguredBackendIds.length === 0) { + setError('Keep at least one provider model configured, or disable Image globally.'); + hydrateDraft(data); + return false; + } + + try { + setSaving(true); + setError(null); + const payload = await api.imageAnalysis.update({ + enabled: nextEnabled, + timeout: Number.parseInt(nextTimeout, 10), + fallbackBackend: nextFallbackBackend || null, + providerModels: nextPayload.providerModels, + profileBackends: nextPayload.profileBackends, + }); + setData(payload); + hydrateDraft(payload); + setSuccess('Image settings saved.'); + await fetchRawConfig(); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save image settings.'); + hydrateDraft(data); + return false; + } finally { + setSaving(false); + } + }, + [ + data, + enabled, + fallbackBackend, + fetchRawConfig, + hydrateDraft, + mappingDrafts, + providerModels, + timeout, + ] + ); const handleRefresh = async () => { if (loading || saving) return; @@ -279,47 +655,86 @@ export default function ImageAnalysisSection() { await Promise.all([fetchData(), fetchRawConfig()]); }; - const handleSave = async () => { - if (!data) return; - const parsedTimeout = Number.parseInt(timeout, 10); - - if (!Number.isInteger(parsedTimeout) || parsedTimeout < 10 || parsedTimeout > 600) { - setError('Timeout must be an integer between 10 and 600 seconds.'); + const handleEnabledChange = async (nextEnabled: boolean) => { + if (saving) return; + if (nextEnabled && configuredBackendIds.length === 0) { + setError('Keep at least one provider model configured, or disable Image globally.'); return; } - if (configuredBackendIds.length === 0) { - setError('Keep at least one provider model configured, or disable Image Analysis globally.'); - return; - } - - try { - setSaving(true); - setError(null); - const payload = await api.imageAnalysis.update({ - enabled, - timeout: parsedTimeout, - fallbackBackend: fallbackBackend || null, - providerModels: payloadPreview.providerModels, - profileBackends: payloadPreview.profileBackends, - }); - setData(payload); - hydrateDraft(payload); - setSuccess('Image Analysis settings saved.'); - await fetchRawConfig(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save Image Analysis settings.'); - } finally { - setSaving(false); - } + setEnabled(nextEnabled); + await persistSettings({ enabled: nextEnabled }); }; + const commitTimeout = async (nextValue: string) => { + if (!data || saving) return; + const normalizedTimeout = normalizeTimeoutDraft(nextValue, String(data.config.timeout)); + setTimeout(normalizedTimeout); + await persistSettings({ timeout: normalizedTimeout }); + }; + + const commitFallbackBackend = async (nextFallbackBackend: string) => { + if (saving) return; + setFallbackBackend(nextFallbackBackend); + await persistSettings({ fallbackBackend: nextFallbackBackend }); + }; + + const commitProviderModel = async (backendId: string, nextValue: string) => { + if (!data || saving) return; + + const normalizedValue = nextValue.trim(); + const nextProviderModels = { + ...providerModels, + [backendId]: normalizedValue, + }; + const nextConfiguredBackendIds = getConfiguredBackendIds(nextProviderModels); + + if (enabled && nextConfiguredBackendIds.length === 0) { + setError('Disable Image first or keep one backend configured.'); + setProviderModels((current) => ({ + ...current, + [backendId]: data.config.providerModels[backendId] ?? '', + })); + return; + } + + const nextFallbackBackend = + nextConfiguredBackendIds.length === 0 + ? '' + : nextConfiguredBackendIds.includes(fallbackBackend) + ? fallbackBackend + : nextConfiguredBackendIds[0]; + + setProviderModels(nextProviderModels); + setFallbackBackend(nextFallbackBackend); + await persistSettings({ + providerModels: nextProviderModels, + fallbackBackend: nextFallbackBackend, + }); + }; + + const updateMappingRow = (rowId: string, patch: Partial) => { + setMappingDrafts((current) => + current.map((entry) => (entry.id === rowId ? { ...entry, ...patch } : entry)) + ); + }; + + const commitMappingDrafts = async (nextMappingDrafts: MappingDraft[]) => { + if (saving) return; + setMappingDrafts(nextMappingDrafts); + await persistSettings({ mappingDrafts: nextMappingDrafts }); + }; + + const completeMappingCount = mappingDrafts.filter( + (row) => row.profileName.trim() && row.backendId + ).length; + if (loading) { return (
- Loading Image Analysis settings... + Loading image settings...
); @@ -330,7 +745,7 @@ export default function ImageAnalysisSection() {
- {error ?? 'Failed to load Image Analysis settings.'} + {error ?? 'Failed to load image settings.'}
-
-
-
-
-
- Enabled -
-
- {enabled ? 'On' : 'Off'} - -
-
-
-
- Timeout -
-
- setTimeout(event.target.value)} - inputMode="numeric" - className="h-9" - /> - sec -
-
-
-
- Fallback backend -
-
- -
-
-
-
- Coverage -
-
- {data.summary.backendCount} backends - {data.summary.mappedProfileCount} mapped - {data.summary.bypassedProfileCount} bypassed -
-
-
-
- -
-
-
-

Provider models

-

- One model per backend. Clear a model to remove that backend from Image Analysis. -

-
-
- -
- {data.catalog.knownBackends.map((backendId) => { - const backendStatus = data.backends.find((item) => item.backendId === backendId); - const displayName = backendStatus?.displayName || backendId; - - return ( -
-
-
-
{displayName}
-
{backendId}
-
- {backendStatus ? ( - - {backendStatus.state === 'starts_on_launch' - ? 'Starts on launch' - : backendStatus.state === 'needs_auth' - ? 'Needs auth' - : backendStatus.state === 'needs_proxy' - ? 'Needs proxy' - : backendStatus.state === 'review' - ? 'Review' - : 'Ready'} - - ) : ( - Inactive - )} -
- - - setProviderModels((current) => ({ - ...current, - [backendId]: event.target.value, - })) - } - /> - -
- - Auth:{' '} - {backendStatus?.authReadiness === 'ready' - ? 'ready' - : backendStatus?.authReadiness === 'missing' - ? 'missing' - : 'n/a'} - - - Proxy:{' '} - {backendStatus?.proxyReadiness === 'remote' - ? 'remote' - : backendStatus?.proxyReadiness === 'stopped' - ? 'idle' - : (backendStatus?.proxyReadiness ?? 'n/a')} - - {backendStatus?.profilesUsing ?? 0} profiles use this backend -
+
+
+
+ Active routes
- ); - })} -
-
- -
-
-
-

Profile mappings

-

- Use explicit mappings only when a profile should bypass the normal backend - resolution. -

-
- -
- - - {data.catalog.profileNames.map((profileName) => ( - - -
- {mappingDrafts.length === 0 ? ( -
- No explicit profile mappings saved. Profiles follow provider and fallback - resolution. +
+ {data.summary.activeProfileCount} +
+

Current target path

- ) : ( - mappingDrafts.map((row) => ( -
- - setMappingDrafts((current) => - current.map((entry) => - entry.id === row.id - ? { ...entry, profileName: event.target.value } - : entry - ) - ) - } +
+
+ Native path +
+
+ {data.summary.nativeProfileCount} +
+

Skip transformer

+
+
+
+ + } + meta={ + + {configuredBackendIds.length} configured + + } + > +
+
+
+ Enabled +
+
+
+
+ {enabled ? 'Transformer on' : 'Transformer off'} +
+

+ Profile flags stay untouched. +

+
+ { + void handleEnabledChange(checked); + }} + disabled={saving} /> +
+
+ +
+
+ Timeout +
+
+ setTimeout(event.target.value)} + inputMode="numeric" + className="h-10 border-amber-500/15 bg-background/90 text-base" + disabled={saving} + onBlur={(event) => { + void commitTimeout(event.currentTarget.value); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> + sec +
+

+ Keeps large reads from hanging. +

+
+ +
+
+ Fallback backend +
+
-
- )) - )} -
- - -
-
-

Profile coverage

-

- Quick read-only view of which saved profiles can use Image Analysis on their current - target. -

-
- -
-
-
- Profile - Target - Backend - Current path +

+ Used when no direct route exists. +

-
- {data.profiles.map((profile) => ( +
+ +
+
+ {completeMappingCount} overrides +
+
+ {nativeReadProfiles.length} native +
+
+ {fallbackBackend || 'No fallback'} fallback +
+
+ + + } + meta={ + + {orderedBackendIds.length} backends + + } + > +
+ {orderedBackendIds.map((backendId, index) => { + const backendStatus = data.backends.find((item) => item.backendId === backendId); + const displayName = backendStatus?.displayName || backendId; + const currentModel = providerModels[backendId] ?? ''; + const statusNote = backendStatusNote(backendStatus); + const usageLine = currentModel + ? [ + `${backendStatus?.profilesUsing ?? 0} active`, + backendStatus?.authReadiness === 'missing' + ? 'auth missing' + : backendStatus?.proxyReadiness === 'stopped' + ? 'starts on launch' + : null, + ] + .filter(Boolean) + .join(' · ') + : 'No model configured.'; + + return (
0 && 'border-t border-cyan-500/10', + getBackendRowClass(backendStatus?.state) + )} > -
-
{profile.name}
-
- {profile.kind === 'variant' ? 'CLIProxy variant' : 'Settings profile'} +
+ +
+
+
+
+

{displayName}

+ + {backendId} + + {backendStatus?.profilesUsing ? ( + + {backendStatus.profilesUsing} active + + ) : null} +
+

+ {usageLine} +

+
+ + {backendStatus ? backendStateLabel(backendStatus.state) : 'Inactive'} +
+ +
+ + setProviderModels((current) => ({ + ...current, + [backendId]: event.target.value, + })) + } + onBlur={(event) => { + void commitProviderModel(backendId, event.currentTarget.value); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> + {currentModel.trim().length > 0 && ( + + )} +
+ + {statusNote && ( +

+ {statusNote} +

+ )}
-
{profile.target}
-
-
- {profile.backendDisplayName || 'Native file access'} +
+ ); + })} +
+ + + } + meta={{nativeReadProfiles.length} profiles} + > + {nativeReadProfiles.length === 0 ? ( +
+ No profiles prefer native reading yet. +
+ ) : ( +
+ {nativeReadProfiles.map((profile) => ( +
+
+
+
+
+ {profile.name} +
+ + {profile.kind === 'variant' ? 'Variant' : 'Profile'} + + + {profile.nativeImageCapable ? 'Verified' : 'Review'} + +
+
+ {profile.profileModel || 'Model not detected'} ·{' '} + {profile.nativeImageReason || 'Native read preferred.'} +
-
- {profile.resolutionSource.replace(/-/g, ' ')} -
-
-
@@ -684,9 +1147,246 @@ export default function ImageAnalysisSection() {
))}
+ )} + + + } + meta={ + + Advanced + + } + action={ +
+ + {showProfileRouting && ( + + )} +
+ } + className="border-dashed" + > + + {data.catalog.profileNames.map((profileName) => ( + + + {showProfileRouting ? ( +
+ {mappingDrafts.length === 0 ? ( +
+ No explicit overrides saved. +
+ ) : ( +
+ {mappingDrafts.map((row) => ( +
+
+
+ Direct override + {!(row.profileName.trim() && row.backendId) && ( + + Draft + + )} +
+ +
+ +
+ { + updateMappingRow(row.id, { profileName: event.target.value }); + }} + onBlur={(event) => { + const nextMappingDrafts = mappingDrafts.map((entry) => + entry.id === row.id + ? { ...entry, profileName: event.currentTarget.value.trim() } + : entry + ); + void commitMappingDrafts(nextMappingDrafts); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> + +
+
+ ))} +
+ )} +
+ ) : ( +
+ Hidden by default. + {mappingDrafts.length > 0 + ? ` ${mappingDrafts.length} override${mappingDrafts.length === 1 ? '' : 's'} saved.` + : ' No overrides saved.'} +
+ )} +
+ + } + meta={{data.profiles.length} profiles} + > +
+ {data.profiles.map((profile, index) => ( +
0 && 'border-t border-slate-400/12', + getCoverageRowClass(index, profile) + )} + > +
+
+
+ {profile.name} +
+ + {profile.kind === 'variant' ? 'Variant' : 'Profile'} + + + {profile.target} + + {profile.nativeReadPreference && ( + + Native + + )} +
+
+ {profile.backendDisplayName || profile.profileModel || 'Native file access'} ·{' '} + {routeSourceLabel(profile.resolutionSource)} +
+
+ +
+ {profile.profileModel && ( + + {profile.profileModel} + + )} + + {currentTargetModeLabel(profile.currentTargetMode)} + +
+
+ ))}
-
-
+ +
); diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 3396e3ce..70af1d16 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -161,7 +161,7 @@ export interface OfficialChannelsStatus { export type SettingsTab = | 'websearch' - | 'imageanalysis' + | 'image' | 'channels' | 'globalenv' | 'proxy' diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx index 7a33f808..6739f4fb 100644 --- a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx +++ b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx @@ -38,7 +38,7 @@ function createStatus(overrides: Partial = {}): ImageAnalys status: 'active', backendId: 'gemini', backendDisplayName: 'Google Gemini', - model: 'gemini-2.5-flash', + model: 'gemini-3-flash-preview', resolutionSource: 'cliproxy-bridge', reason: null, shouldPersistHook: true, @@ -56,6 +56,10 @@ function createStatus(overrides: Partial = {}): ImageAnalys proxyReason: 'Local CLIProxy service is reachable.', effectiveRuntimeMode: 'cliproxy-image-analysis', effectiveRuntimeReason: null, + profileModel: 'gemini-3-flash-preview', + nativeReadPreference: false, + nativeImageCapable: true, + nativeImageReason: 'gemini-3-flash-preview can read images natively.', ...overrides, }; } @@ -80,18 +84,16 @@ describe('ImageAnalysisStatusSection', () => { it('renders a compact saved summary with a settings link', () => { render(); - expect(screen.getByText('Image Analysis')).toBeInTheDocument(); - expect(screen.getByText('Saved')).toBeInTheDocument(); - expect(screen.getByText('CLIProxy active')).toBeInTheDocument(); + expect(screen.getByText('Image')).toBeInTheDocument(); + expect(screen.getByText(/Saved status · Transformer ready/i)).toBeInTheDocument(); + expect(screen.getByText('Ready')).toBeInTheDocument(); + expect(screen.getByText('Use native image reading')).toBeInTheDocument(); expect( - screen.getByText(/Saved backend: Google Gemini\. Images and PDFs resolve through CLIProxy/i) + screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i) ).toBeInTheDocument(); - expect(screen.getByText('Backend')).toBeInTheDocument(); - expect(screen.getByText('Current target')).toBeInTheDocument(); - expect(screen.getByText('Persistence')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Open Settings/i })).toHaveAttribute( 'href', - '/settings?tab=imageanalysis' + '/settings?tab=image' ); }); @@ -99,12 +101,12 @@ describe('ImageAnalysisStatusSection', () => { render(); expect(screen.getByText('Bypassed')).toBeInTheDocument(); + expect(screen.getByText(/Saved status · Codex CLI bypasses the hook/i)).toBeInTheDocument(); expect( - screen.getByText(/Current target Codex CLI bypasses the hook\. Saved Claude-side backend/i) + screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i) ).toBeInTheDocument(); - expect(screen.getByText('Codex CLI')).toBeInTheDocument(); expect( - screen.getByText(/Current launch path bypasses the Claude Read hook/i) + screen.getByText(/Current target Codex CLI bypasses the Claude Read hook/i) ).toBeInTheDocument(); }); @@ -114,6 +116,8 @@ describe('ImageAnalysisStatusSection', () => { status={createStatus({ backendId: 'ghcp', backendDisplayName: 'GitHub Copilot (OAuth)', + model: 'claude-haiku-4.5', + profileModel: 'claude-haiku-4.5', authReadiness: 'missing', authProvider: 'ghcp', authDisplayName: 'GitHub Copilot (OAuth)', @@ -126,15 +130,94 @@ describe('ImageAnalysisStatusSection', () => { /> ); - expect(screen.getByText('Needs auth')).toBeInTheDocument(); + expect(screen.getByText('Auth')).toBeInTheDocument(); expect( - screen.getByText(/Saved backend: GitHub Copilot \(OAuth\)\. Current runtime falls back/i) + screen.getByText(/Transformer route: GitHub Copilot \(OAuth\) · claude-haiku-4.5\./i) ).toBeInTheDocument(); expect( screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length ).toBeGreaterThanOrEqual(1); }); + it('calls the toggle handler immediately for native image reading', () => { + const onToggleNativeRead = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('switch', { name: /Use native image reading/i })); + + expect(onToggleNativeRead).toHaveBeenCalledWith(true); + }); + + it('writes the native image preference into the raw settings json', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/glm/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'glm', + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_AUTH_TOKEN: 'saved-token', + }, + }, + mtime: 1, + path: '/tmp/glm.settings.json', + imageAnalysisStatus: createStatus(), + }) + ); + } + + if (url.includes('/api/settings/glm/image-analysis-status')) { + return Promise.resolve( + createJsonResponse({ + imageAnalysisStatus: createStatus({ + backendId: null, + backendDisplayName: null, + model: null, + resolutionSource: 'native-compatible', + supported: false, + shouldPersistHook: false, + runtimePath: null, + authReadiness: 'not-needed', + authProvider: null, + authDisplayName: null, + authReason: null, + proxyReadiness: 'not-needed', + proxyReason: null, + effectiveRuntimeMode: 'native-read', + nativeReadPreference: true, + }), + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + render(); + + await screen.findByText(/Transformer route: Google Gemini/i); + + fireEvent.click(screen.getByRole('switch', { name: /Use native image reading/i })); + + await waitFor(() => { + expect((screen.getByLabelText('raw config editor') as HTMLTextAreaElement).value).toContain( + '"ccs_image"' + ); + }); + expect((screen.getByLabelText('raw config editor') as HTMLTextAreaElement).value).toContain( + '"native_read": true' + ); + }); + it('switches to live preview when editor JSON changes', async () => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -179,7 +262,7 @@ describe('ImageAnalysisStatusSection', () => { render(); - expect(await screen.findByText('Google Gemini')).toBeInTheDocument(); + expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('raw config editor'), { target: { @@ -197,10 +280,9 @@ describe('ImageAnalysisStatusSection', () => { }); await waitFor(() => { - expect(screen.getByText('Live Preview')).toBeInTheDocument(); + expect(screen.getByText(/Live preview/i)).toBeInTheDocument(); }); - expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); - expect(screen.getByText(/Preview from the current editor JSON/i)).toBeInTheDocument(); + expect(screen.getByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument(); }); it('falls back to saved status messaging when the editor JSON is invalid', async () => { @@ -232,16 +314,14 @@ describe('ImageAnalysisStatusSection', () => { render(); - expect(await screen.findByText('Google Gemini')).toBeInTheDocument(); + expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('raw config editor'), { target: { value: '{' }, }); await waitFor(() => { - expect( - screen.getByText(/Showing saved status until the JSON above is valid again/i) - ).toBeInTheDocument(); + expect(screen.getByText(/Saved status/i)).toBeInTheDocument(); }); }); @@ -303,7 +383,7 @@ describe('ImageAnalysisStatusSection', () => { render(); - expect(await screen.findByText('Google Gemini')).toBeInTheDocument(); + expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('raw config editor'), { target: { @@ -320,7 +400,7 @@ describe('ImageAnalysisStatusSection', () => { }, }); - expect(await screen.findByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); + expect(await screen.findByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('raw config editor'), { target: { @@ -338,9 +418,8 @@ describe('ImageAnalysisStatusSection', () => { }); await waitFor(() => { - expect(screen.getByText('Refreshing')).toBeInTheDocument(); + expect(screen.getByText(/Refreshing preview/i)).toBeInTheDocument(); }); - expect(screen.getByText(/Refreshing from the current editor state/i)).toBeInTheDocument(); secondPreviewResolver?.( createJsonResponse({ @@ -356,7 +435,7 @@ describe('ImageAnalysisStatusSection', () => { ); await waitFor(() => { - expect(screen.getByText('Codex')).toBeInTheDocument(); + expect(screen.getByText(/Codex/i)).toBeInTheDocument(); }); }); }); diff --git a/ui/tests/unit/ui/pages/image-analysis-section.test.tsx b/ui/tests/unit/ui/pages/image-analysis-section.test.tsx index 5b8a8eec..92854150 100644 --- a/ui/tests/unit/ui/pages/image-analysis-section.test.tsx +++ b/ui/tests/unit/ui/pages/image-analysis-section.test.tsx @@ -18,7 +18,7 @@ describe('ImageAnalysisSection', () => { enabled: true, timeout: 60, providerModels: { - gemini: 'gemini-2.5-flash', + gemini: 'gemini-3-flash-preview', ghcp: 'claude-haiku-4.5', }, fallbackBackend: 'gemini', @@ -30,17 +30,18 @@ describe('ImageAnalysisSection', () => { state: 'partial', title: 'Partially ready', detail: - '1 backend still needs auth, runtime, or review before every profile path is healthy.', + '1 profile routes through Image on the current Claude target path. 1 prefer native image reading.', backendCount: 2, mappedProfileCount: 1, activeProfileCount: 1, bypassedProfileCount: 1, + nativeProfileCount: 1, }, backends: [ { backendId: 'gemini', displayName: 'Google Gemini', - model: 'gemini-2.5-flash', + model: 'gemini-3-flash-preview', state: 'ready', authReadiness: 'ready', authReason: null, @@ -74,6 +75,10 @@ describe('ImageAnalysisSection', () => { effectiveRuntimeMode: 'cliproxy-image-analysis', effectiveRuntimeReason: null, currentTargetMode: 'active', + profileModel: 'gemini-3-flash-preview', + nativeReadPreference: false, + nativeImageCapable: true, + nativeImageReason: 'gemini-3-flash-preview can read images natively.', }, { name: 'codexProfile', @@ -88,6 +93,10 @@ describe('ImageAnalysisSection', () => { effectiveRuntimeMode: 'cliproxy-image-analysis', effectiveRuntimeReason: null, currentTargetMode: 'bypassed', + profileModel: 'claude-haiku-4.5', + nativeReadPreference: true, + nativeImageCapable: true, + nativeImageReason: 'claude-haiku-4.5 can read images natively.', }, ], catalog: { @@ -110,21 +119,32 @@ describe('ImageAnalysisSection', () => { if (url === '/api/image-analysis' && method === 'PUT') { const body = JSON.parse(String(init?.body ?? '{}')) as { + enabled?: boolean; timeout?: number; fallbackBackend?: string; profileBackends?: Record; providerModels?: Record; }; + const providerModels = body.providerModels ?? {}; payload = { ...payload, config: { - enabled: true, + enabled: body.enabled ?? payload.config.enabled, timeout: body.timeout ?? payload.config.timeout, - fallbackBackend: body.fallbackBackend ?? payload.config.fallbackBackend, + fallbackBackend: + 'fallbackBackend' in body + ? (body.fallbackBackend ?? null) + : payload.config.fallbackBackend, providerModels: { - gemini: String(body.providerModels?.gemini ?? payload.config.providerModels.gemini), - ghcp: String(body.providerModels?.ghcp ?? payload.config.providerModels.ghcp), + gemini: + 'gemini' in providerModels + ? (providerModels.gemini ?? '') + : payload.config.providerModels.gemini, + ghcp: + 'ghcp' in providerModels + ? (providerModels.ghcp ?? '') + : payload.config.providerModels.ghcp, }, profileBackends: body.profileBackends ?? payload.config.profileBackends, }, @@ -146,18 +166,19 @@ describe('ImageAnalysisSection', () => { it('renders global controls and saves updated config', async () => { render(, { withSettingsProvider: true }); - expect(await screen.findByText('Image Analysis')).toBeInTheDocument(); + expect(await screen.findByText('Image')).toBeInTheDocument(); expect(screen.getByText('Partially ready')).toBeInTheDocument(); - expect(screen.getByText('Provider models')).toBeInTheDocument(); - expect(screen.getByText('Profile mappings')).toBeInTheDocument(); - expect(screen.getByText('Profile coverage')).toBeInTheDocument(); - expect(screen.getByText('Bypassed')).toBeInTheDocument(); + expect(screen.getByText('Core setup')).toBeInTheDocument(); + expect(screen.getAllByText('Native reading').length).toBeGreaterThan(0); + expect(screen.getByText('Profile routing')).toBeInTheDocument(); + expect(screen.getAllByText('Coverage').length).toBeGreaterThan(0); + expect(screen.getAllByText('Bypassed').length).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); const timeoutInput = screen.getByDisplayValue('60'); await userEvent.clear(timeoutInput); await userEvent.type(timeoutInput, '120'); - - await userEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await userEvent.tab(); await waitFor(() => { expect(fetch).toHaveBeenCalledWith( @@ -168,10 +189,12 @@ describe('ImageAnalysisSection', () => { ); }); - const putCall = fetchMock.mock.calls.find( - ([url, init]) => - url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT' - ); + const putCall = fetchMock.mock.calls + .filter( + ([url, init]) => + url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT' + ) + .at(-1); expect(putCall).toBeDefined(); const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}')); @@ -183,7 +206,97 @@ describe('ImageAnalysisSection', () => { }, }); - expect(await screen.findByText('Image Analysis settings saved.')).toBeInTheDocument(); + expect(await screen.findByText('Image settings saved.')).toBeInTheDocument(); + }); + + it('allows saving a disabled configuration even when every provider model is cleared', async () => { + render(, { withSettingsProvider: true }); + + await screen.findByDisplayValue('gemini-3-flash-preview'); + + await userEvent.click(screen.getByRole('switch')); + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/image-analysis', + expect.objectContaining({ + method: 'PUT', + }) + ); + }); + await waitFor(() => { + expect(screen.getByDisplayValue('gemini-3-flash-preview')).not.toBeDisabled(); + }); + + fetchMock.mockClear(); + + await userEvent.click(screen.getAllByRole('button', { name: 'Clear' })[0]); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/image-analysis', + expect.objectContaining({ + method: 'PUT', + }) + ); + }); + await waitFor(() => { + expect(screen.getAllByRole('button', { name: 'Clear' })).toHaveLength(1); + }); + + fetchMock.mockClear(); + + await userEvent.click(screen.getByRole('button', { name: 'Clear' })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/image-analysis', + expect.objectContaining({ + method: 'PUT', + }) + ); + }); + + const putCall = fetchMock.mock.calls + .filter( + ([url, init]) => + url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT' + ) + .at(-1); + expect(putCall).toBeDefined(); + + const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}')); + expect(requestBody).toMatchObject({ + enabled: false, + fallbackBackend: null, + providerModels: { + gemini: null, + ghcp: null, + }, + }); + }); + + it('auto-saves edits without rendering a dedicated save button', async () => { + render(, { withSettingsProvider: true }); + + expect(await screen.findByText('Image')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument(); + + const timeoutInput = screen.getByDisplayValue('60'); + await userEvent.clear(timeoutInput); + await userEvent.type(timeoutInput, '90'); + await userEvent.tab(); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/image-analysis', + expect.objectContaining({ + method: 'PUT', + }) + ); + }); + + expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument(); }); it('surfaces a clear retryable error when the backend route is not available yet', async () => { @@ -209,7 +322,7 @@ describe('ImageAnalysisSection', () => { expect( await screen.findByText( - /Image Analysis settings returned an unexpected response\. Restart the dashboard server/i + /Image settings returned an unexpected response\. Restart the dashboard server/i ) ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();