mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(image): add native-read controls and autosave settings
- add per-profile native image preferences and native-capability detection - redesign Settings -> Image around compact sections with Web-style autosave - expose richer backend/profile status and update Gemini Flash defaults
This commit is contained in:
@@ -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 <backend>` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend <profile> <backend>` and `--clear-profile-backend <profile>` 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 <backend>` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend <profile> <backend>` and `--clear-profile-backend <profile>` 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.
|
||||
|
||||
|
||||
@@ -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<CliproxyImageAnalysisDeps> = {}
|
||||
): Promise<CliproxyImageAnalysisResolution> {
|
||||
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
|
||||
|
||||
@@ -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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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.
|
||||
|
||||
@@ -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:'));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise<void>
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Settings, 'env'> | null;
|
||||
settings?: Pick<Settings, 'env' | 'ccs_image'> | 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<ImageAnalysisStatus, 'backendId' | 'backendDisplayName' | 'resolutionSource' | 'reason'> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
|
||||
): 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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -58,7 +58,8 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,10 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ImageAnalysisStatus['resolutionSource'], string> = {
|
||||
'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<CliTarget, string> = {
|
||||
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 (
|
||||
<div className="rounded-md border bg-muted/20 p-4" aria-live="polite">
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-64 animate-pulse rounded bg-muted" />
|
||||
<div className="rounded-2xl border bg-muted/20 px-4 py-3" aria-live="polite">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-52 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="rounded-md border bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<section className="rounded-2xl border bg-background/95 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-sky-600" />
|
||||
<h3 className="text-sm font-semibold">Image Analysis</h3>
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">Image</h3>
|
||||
<Badge className={cn('h-5 border px-1.5 text-[10px]', headerBadge.className)}>
|
||||
{headerBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getPreviewLabel(source, previewState)} · {getHeaderLabel(effectiveStatus, target)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{getStatusContext(source, previewState)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={previewBadge.variant} className="h-5 px-1.5 text-[10px]">
|
||||
{previewBadge.label}
|
||||
</Badge>
|
||||
<Badge className={cn('h-5 border px-1.5 text-[10px]', runtimeBadge.className)}>
|
||||
{runtimeBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{getSummary(status, target)}</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-md border bg-background/70 p-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Backend
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">
|
||||
{status.backendDisplayName || status.backendId || 'Native file access'}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{SOURCE_LABELS[status.resolutionSource]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background/70 p-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Current target
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">{TARGET_LABELS[target]}</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{getTargetDetail(status, target)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background/70 p-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Persistence
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">
|
||||
{getPersistenceValue(status)}
|
||||
</div>
|
||||
<p
|
||||
className="mt-1 text-xs leading-5 text-muted-foreground"
|
||||
title={status.persistencePath || 'Not required'}
|
||||
>
|
||||
{getPersistenceDetail(status)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Auth
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
{status.authReadiness === 'ready'
|
||||
? `${status.authDisplayName || status.authProvider} ready`
|
||||
: status.authReadiness === 'missing'
|
||||
? status.authReason
|
||||
: status.authReadiness === 'not-needed'
|
||||
? 'Not required'
|
||||
: 'Unknown'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Proxy
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
{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'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Model
|
||||
</dt>
|
||||
<dd className={cn('text-sm text-foreground', status.model && 'font-mono text-xs')}>
|
||||
{getModelValue(status)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{showNotice && (
|
||||
<div className="mt-4 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span>
|
||||
{status.effectiveRuntimeReason || status.reason || 'Review the saved configuration.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CRUD and backend routing now live in global Settings.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/settings?tab=imageanalysis">
|
||||
<Button size="sm" variant="outline" className="h-8 shrink-0" asChild>
|
||||
<Link to="/settings?tab=image">
|
||||
Open Settings
|
||||
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-xl border bg-muted/15 px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium text-foreground">Use native image reading</div>
|
||||
{capabilityLabel && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{capabilityLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{getToggleSummary(effectiveStatus, target)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={nativeReadChecked}
|
||||
onCheckedChange={onToggleNativeRead}
|
||||
disabled={!onToggleNativeRead}
|
||||
aria-label="Use native image reading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<div className="mt-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
{note}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
{/* Global Env Indicator */}
|
||||
|
||||
@@ -6,6 +6,9 @@ import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/l
|
||||
|
||||
export interface Settings {
|
||||
env?: Record<string, string>;
|
||||
ccs_image?: {
|
||||
native_read?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -150,19 +150,19 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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) {
|
||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
||||
<TabsList className="grid w-full grid-cols-8">
|
||||
{tabs.map(({ value, label, icon: Icon }) => (
|
||||
<TabsTrigger key={value} value={value} className="gap-1.5 px-1 text-xs">
|
||||
<TabsTrigger key={value} value={value} className="gap-1.5 px-2 text-xs">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -132,7 +132,7 @@ function SettingsPageInner() {
|
||||
<SectionErrorBoundary>
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
|
||||
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||
{activeTab === 'channels' && <ChannelsSection />}
|
||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||
{activeTab === 'thinking' && <ThinkingSection />}
|
||||
@@ -146,7 +146,7 @@ function SettingsPageInner() {
|
||||
{/* Desktop View - Side-by-side panels */}
|
||||
<PanelGroup direction="horizontal" className="h-full hidden md:flex">
|
||||
{/* Left Panel - Settings Controls */}
|
||||
<Panel defaultSize={40} minSize={30} maxSize={55}>
|
||||
<Panel defaultSize={46} minSize={36} maxSize={62}>
|
||||
<div className="h-full border-r flex flex-col bg-muted/30 relative">
|
||||
{/* Header with Tabs */}
|
||||
<div className="p-5 border-b bg-background">
|
||||
@@ -157,7 +157,7 @@ function SettingsPageInner() {
|
||||
<SectionErrorBoundary>
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
|
||||
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||
{activeTab === 'channels' && <ChannelsSection />}
|
||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||
{activeTab === 'thinking' && <ThinkingSection />}
|
||||
@@ -175,7 +175,7 @@ function SettingsPageInner() {
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Right Panel - Config Viewer */}
|
||||
<Panel defaultSize={60} minSize={35}>
|
||||
<Panel defaultSize={54} minSize={35}>
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-background flex items-center justify-between">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -161,7 +161,7 @@ export interface OfficialChannelsStatus {
|
||||
|
||||
export type SettingsTab =
|
||||
| 'websearch'
|
||||
| 'imageanalysis'
|
||||
| 'image'
|
||||
| 'channels'
|
||||
| 'globalenv'
|
||||
| 'proxy'
|
||||
|
||||
@@ -38,7 +38,7 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): 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<ImageAnalysisStatus> = {}): 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(<ImageAnalysisStatusSection status={createStatus()} />);
|
||||
|
||||
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(<ImageAnalysisStatusSection status={createStatus()} target="codex" />);
|
||||
|
||||
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(
|
||||
<ImageAnalysisStatusSection status={createStatus()} onToggleNativeRead={onToggleNativeRead} />
|
||||
);
|
||||
|
||||
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(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||
|
||||
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(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||
|
||||
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(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||
|
||||
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(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
providerModels?: Record<string, string | null>;
|
||||
};
|
||||
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(<ImageAnalysisSection />, { 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(<ImageAnalysisSection />, { 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(<ImageAnalysisSection />, { 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();
|
||||
|
||||
Reference in New Issue
Block a user