mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 12:23:35 +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.
|
> **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.
|
> 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.
|
> **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
|
* - WebSearch and ImageAnalysis hook integration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
import {
|
import {
|
||||||
getEffectiveEnvVars,
|
getEffectiveEnvVars,
|
||||||
getRemoteEnvVars,
|
getRemoteEnvVars,
|
||||||
@@ -29,6 +30,7 @@ import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
|||||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||||
import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer';
|
import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer';
|
||||||
import type { ProxyTarget } from '../proxy-target-resolver';
|
import type { ProxyTarget } from '../proxy-target-resolver';
|
||||||
|
import { isSettings, type Settings } from '../../types/config';
|
||||||
|
|
||||||
export interface RemoteProxyConfig {
|
export interface RemoteProxyConfig {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -129,17 +131,36 @@ function normalizeCodexEnvForDirectUpstream(envVars: NodeJS.ProcessEnv): NodeJS.
|
|||||||
return nextEnv ?? envVars;
|
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(
|
export async function resolveCliproxyImageAnalysisEnv(
|
||||||
options: ResolveCliproxyImageAnalysisEnvOptions,
|
options: ResolveCliproxyImageAnalysisEnvOptions,
|
||||||
deps: Partial<CliproxyImageAnalysisDeps> = {}
|
deps: Partial<CliproxyImageAnalysisDeps> = {}
|
||||||
): Promise<CliproxyImageAnalysisResolution> {
|
): Promise<CliproxyImageAnalysisResolution> {
|
||||||
const resolvedDeps = { ...defaultCliproxyImageAnalysisDeps, ...deps };
|
const resolvedDeps = { ...defaultCliproxyImageAnalysisDeps, ...deps };
|
||||||
|
const settings = loadImageAnalysisSettings(options.profileSettingsPath);
|
||||||
const context = {
|
const context = {
|
||||||
profileName: options.profileName,
|
profileName: options.profileName,
|
||||||
profileType: 'cliproxy' as const,
|
profileType: 'cliproxy' as const,
|
||||||
cliproxyProvider: options.provider,
|
cliproxyProvider: options.provider,
|
||||||
isComposite: options.isComposite,
|
isComposite: options.isComposite,
|
||||||
settingsPath: options.profileSettingsPath,
|
settingsPath: options.profileSettingsPath,
|
||||||
|
settings,
|
||||||
hookInstalled: resolvedDeps.hasImageAnalysisProfileHook(
|
hookInstalled: resolvedDeps.hasImageAnalysisProfileHook(
|
||||||
options.profileName,
|
options.profileName,
|
||||||
options.profileSettingsPath
|
options.profileSettingsPath
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface ModelEntry {
|
|||||||
thinking?: ThinkingSupport;
|
thinking?: ThinkingSupport;
|
||||||
/** Whether model supports 1M extended context window (appends [1m] suffix) */
|
/** Whether model supports 1M extended context window (appends [1m] suffix) */
|
||||||
extendedContext?: boolean;
|
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',
|
id: 'claude-opus-4-6-thinking',
|
||||||
name: 'Claude Opus 4.6 Thinking',
|
name: 'Claude Opus 4.6 Thinking',
|
||||||
description: 'Latest flagship, extended thinking',
|
description: 'Latest flagship, extended thinking',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'budget',
|
type: 'budget',
|
||||||
min: 1024,
|
min: 1024,
|
||||||
@@ -101,6 +104,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'claude-sonnet-4-6',
|
id: 'claude-sonnet-4-6',
|
||||||
name: 'Claude Sonnet 4.6',
|
name: 'Claude Sonnet 4.6',
|
||||||
description: 'Latest Sonnet with thinking budget support',
|
description: 'Latest Sonnet with thinking budget support',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'budget',
|
type: 'budget',
|
||||||
min: 1024,
|
min: 1024,
|
||||||
@@ -113,6 +117,15 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'gemini-3.1-pro-preview',
|
id: 'gemini-3.1-pro-preview',
|
||||||
name: 'Gemini 3.1 Pro',
|
name: 'Gemini 3.1 Pro',
|
||||||
description: 'Google latest Gemini Pro model via Antigravity',
|
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 },
|
thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true },
|
||||||
extendedContext: true,
|
extendedContext: true,
|
||||||
},
|
},
|
||||||
@@ -128,6 +141,16 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
name: 'Gemini 3.1 Pro',
|
name: 'Gemini 3.1 Pro',
|
||||||
tier: 'pro',
|
tier: 'pro',
|
||||||
description: 'Latest Gemini Pro model, requires paid Google account',
|
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 },
|
thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true },
|
||||||
extendedContext: true,
|
extendedContext: true,
|
||||||
},
|
},
|
||||||
@@ -135,6 +158,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'gemini-2.5-pro',
|
id: 'gemini-2.5-pro',
|
||||||
name: 'Gemini 2.5 Pro',
|
name: 'Gemini 2.5 Pro',
|
||||||
description: 'Stable, works with free Google account',
|
description: 'Stable, works with free Google account',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'budget',
|
type: 'budget',
|
||||||
min: 128,
|
min: 128,
|
||||||
@@ -264,6 +288,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'kimi-k2.5',
|
id: 'kimi-k2.5',
|
||||||
name: 'Kimi K2.5',
|
name: 'Kimi K2.5',
|
||||||
description: 'Latest multimodal model (262K context)',
|
description: 'Latest multimodal model (262K context)',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'budget',
|
type: 'budget',
|
||||||
min: 1024,
|
min: 1024,
|
||||||
@@ -300,6 +325,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'claude-opus-4-6',
|
id: 'claude-opus-4-6',
|
||||||
name: 'Claude Opus 4.6',
|
name: 'Claude Opus 4.6',
|
||||||
description: 'Latest flagship model',
|
description: 'Latest flagship model',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'budget',
|
type: 'budget',
|
||||||
min: 1024,
|
min: 1024,
|
||||||
@@ -313,6 +339,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'claude-sonnet-4-6',
|
id: 'claude-sonnet-4-6',
|
||||||
name: 'Claude Sonnet 4.6',
|
name: 'Claude Sonnet 4.6',
|
||||||
description: 'Balanced performance and speed',
|
description: 'Balanced performance and speed',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'budget',
|
type: 'budget',
|
||||||
min: 1024,
|
min: 1024,
|
||||||
@@ -326,6 +353,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'claude-opus-4-5-20251101',
|
id: 'claude-opus-4-5-20251101',
|
||||||
name: 'Claude Opus 4.5',
|
name: 'Claude Opus 4.5',
|
||||||
description: 'Most capable Claude model',
|
description: 'Most capable Claude model',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'budget',
|
type: 'budget',
|
||||||
min: 1024,
|
min: 1024,
|
||||||
@@ -339,6 +367,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'claude-sonnet-4-5-20250929',
|
id: 'claude-sonnet-4-5-20250929',
|
||||||
name: 'Claude Sonnet 4.5',
|
name: 'Claude Sonnet 4.5',
|
||||||
description: 'Balanced performance and speed',
|
description: 'Balanced performance and speed',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'budget',
|
type: 'budget',
|
||||||
min: 1024,
|
min: 1024,
|
||||||
@@ -352,6 +381,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'claude-sonnet-4-20250514',
|
id: 'claude-sonnet-4-20250514',
|
||||||
name: 'Claude Sonnet 4',
|
name: 'Claude Sonnet 4',
|
||||||
description: 'Previous generation Sonnet',
|
description: 'Previous generation Sonnet',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'budget',
|
type: 'budget',
|
||||||
min: 1024,
|
min: 1024,
|
||||||
@@ -365,6 +395,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
id: 'claude-haiku-4-5-20251001',
|
id: 'claude-haiku-4-5-20251001',
|
||||||
name: 'Claude Haiku 4.5',
|
name: 'Claude Haiku 4.5',
|
||||||
description: 'Fast and efficient',
|
description: 'Fast and efficient',
|
||||||
|
nativeImageInput: true,
|
||||||
thinking: { type: 'none' },
|
thinking: { type: 'none' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -514,6 +545,14 @@ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: str
|
|||||||
return model?.extendedContext === true;
|
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).
|
* Check if model is a native Gemini model (not Claude via Antigravity).
|
||||||
* Native Gemini models get extended context auto-enabled.
|
* Native Gemini models get extended context auto-enabled.
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ function showHelp(): void {
|
|||||||
if (IMAGE_ANALYSIS_PROVIDER_ALIASES.length > 0) {
|
if (IMAGE_ANALYSIS_PROVIDER_ALIASES.length > 0) {
|
||||||
console.log(` ${dim(`Aliases accepted: ${IMAGE_ANALYSIS_PROVIDER_ALIASES.join(', ')}`)}`);
|
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('');
|
||||||
|
|
||||||
console.log(subheader('Examples:'));
|
console.log(subheader('Examples:'));
|
||||||
|
|||||||
@@ -773,8 +773,8 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
timeout: 60,
|
timeout: 60,
|
||||||
provider_models: {
|
provider_models: {
|
||||||
agy: 'gemini-2.5-flash',
|
agy: 'gemini-3-1-flash-preview',
|
||||||
gemini: 'gemini-2.5-flash',
|
gemini: 'gemini-3-flash-preview',
|
||||||
codex: 'gpt-5.1-codex-mini',
|
codex: 'gpt-5.1-codex-mini',
|
||||||
kiro: 'kiro-claude-haiku-4-5',
|
kiro: 'kiro-claude-haiku-4-5',
|
||||||
ghcp: 'claude-haiku-4.5',
|
ghcp: 'claude-haiku-4.5',
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise<void>
|
|||||||
results.errors.push({
|
results.errors.push({
|
||||||
name: 'Image Analysis',
|
name: 'Image Analysis',
|
||||||
message: 'No provider models configured for 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`);
|
console.log(` ${warn('Providers:')} None configured`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ export interface ModelPreset {
|
|||||||
haiku: string;
|
haiku: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CcsImageSettings {
|
||||||
|
native_read?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude CLI settings.json structure
|
* Claude CLI settings.json structure
|
||||||
* Located at: ~/.claude/settings.json or profile-specific
|
* Located at: ~/.claude/settings.json or profile-specific
|
||||||
@@ -83,6 +87,8 @@ export interface Settings {
|
|||||||
env?: EnvVars;
|
env?: EnvVars;
|
||||||
/** Saved model presets for this provider */
|
/** Saved model presets for this provider */
|
||||||
presets?: ModelPreset[];
|
presets?: ModelPreset[];
|
||||||
|
/** CCS-only per-profile Image preferences */
|
||||||
|
ccs_image?: CcsImageSettings;
|
||||||
[key: string]: unknown; // Allow other settings
|
[key: string]: unknown; // Allow other settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
isCLIProxyProvider,
|
isCLIProxyProvider,
|
||||||
mapExternalProviderName,
|
mapExternalProviderName,
|
||||||
} from '../../cliproxy/provider-capabilities';
|
} from '../../cliproxy/provider-capabilities';
|
||||||
|
import { getProviderCatalog, supportsNativeImageInput } from '../../cliproxy/model-catalog';
|
||||||
import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer';
|
import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer';
|
||||||
import type { CliproxyBridgeMetadata } from '../../api/services/profile-types';
|
import type { CliproxyBridgeMetadata } from '../../api/services/profile-types';
|
||||||
import type { Settings } from '../../types/config';
|
import type { Settings } from '../../types/config';
|
||||||
import type { ProfileType } from '../../types/profile';
|
import type { ProfileType } from '../../types/profile';
|
||||||
|
import { stripModelConfigurationSuffixes } from '../../shared/extended-context-utils';
|
||||||
|
|
||||||
export type ImageAnalysisResolutionSource =
|
export type ImageAnalysisResolutionSource =
|
||||||
| 'cliproxy-provider'
|
| 'cliproxy-provider'
|
||||||
@@ -20,6 +22,7 @@ export type ImageAnalysisResolutionSource =
|
|||||||
| 'cliproxy-bridge'
|
| 'cliproxy-bridge'
|
||||||
| 'profile-backend'
|
| 'profile-backend'
|
||||||
| 'fallback-backend'
|
| 'fallback-backend'
|
||||||
|
| 'native-compatible'
|
||||||
| 'disabled'
|
| 'disabled'
|
||||||
| 'unsupported-profile'
|
| 'unsupported-profile'
|
||||||
| 'unresolved'
|
| 'unresolved'
|
||||||
@@ -49,7 +52,7 @@ export interface ImageAnalysisResolutionContext {
|
|||||||
settingsPath?: string | null;
|
settingsPath?: string | null;
|
||||||
cliproxyProvider?: string | null;
|
cliproxyProvider?: string | null;
|
||||||
isComposite?: boolean;
|
isComposite?: boolean;
|
||||||
settings?: Pick<Settings, 'env'> | null;
|
settings?: Pick<Settings, 'env' | 'ccs_image'> | null;
|
||||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||||
hookInstalled?: boolean;
|
hookInstalled?: boolean;
|
||||||
sharedHookInstalled?: boolean;
|
sharedHookInstalled?: boolean;
|
||||||
@@ -79,8 +82,26 @@ export interface ImageAnalysisStatus {
|
|||||||
proxyReason: string | null;
|
proxyReason: string | null;
|
||||||
effectiveRuntimeMode: ImageAnalysisEffectiveRuntimeMode;
|
effectiveRuntimeMode: ImageAnalysisEffectiveRuntimeMode;
|
||||||
effectiveRuntimeReason: string | null;
|
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 {
|
function resolveProviderFromBaseUrl(baseUrl: unknown): string | null {
|
||||||
if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) {
|
if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -233,9 +254,102 @@ function getRuntimePath(backendId: string | null): string | null {
|
|||||||
return `/api/provider/${backendId}`;
|
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,
|
context: ImageAnalysisResolutionContext,
|
||||||
config: ImageAnalysisConfig
|
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'> {
|
): Pick<ImageAnalysisStatus, 'backendId' | 'backendDisplayName' | 'resolutionSource' | 'reason'> {
|
||||||
const { profileName, profileType, cliproxyProvider, isComposite, cliproxyBridge, settings } =
|
const { profileName, profileType, cliproxyProvider, isComposite, cliproxyBridge, settings } =
|
||||||
context;
|
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') {
|
if (profileType === 'copilot' || profileName === 'copilot') {
|
||||||
const backendId = normalizeImageAnalysisBackendId('ghcp', Object.keys(config.provider_models));
|
const backendId = normalizeImageAnalysisBackendId('ghcp', Object.keys(config.provider_models));
|
||||||
return {
|
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 hasDirectAnthropicApiKey = Boolean(settings?.env?.ANTHROPIC_API_KEY?.trim());
|
||||||
const hasBaseUrl = Boolean(settings?.env?.ANTHROPIC_BASE_URL?.trim());
|
const hasBaseUrl = Boolean(settings?.env?.ANTHROPIC_BASE_URL?.trim());
|
||||||
if (hasDirectAnthropicApiKey && !hasBaseUrl) {
|
if (hasDirectAnthropicApiKey && !hasBaseUrl) {
|
||||||
@@ -347,7 +475,8 @@ export function resolveImageAnalysisStatus(
|
|||||||
rawConfig: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG
|
rawConfig: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG
|
||||||
): ImageAnalysisStatus {
|
): ImageAnalysisStatus {
|
||||||
const config = canonicalizeImageAnalysisConfig(rawConfig);
|
const config = canonicalizeImageAnalysisConfig(rawConfig);
|
||||||
const resolution = resolveBackend(context, config);
|
const nativeSupport = resolveNativeImageSupport(context, config);
|
||||||
|
const resolution = resolveBackend(context, config, nativeSupport);
|
||||||
const model = resolution.backendId
|
const model = resolution.backendId
|
||||||
? (config.provider_models[resolution.backendId] ?? null)
|
? (config.provider_models[resolution.backendId] ?? null)
|
||||||
: null;
|
: null;
|
||||||
@@ -444,5 +573,9 @@ export function resolveImageAnalysisStatus(
|
|||||||
status === 'hook-missing' || !config.enabled || !resolution.backendId || !model
|
status === 'hook-missing' || !config.enabled || !resolution.backendId || !model
|
||||||
? reason
|
? reason
|
||||||
: null,
|
: 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 DashboardTarget = 'claude' | 'droid' | 'codex';
|
||||||
type DashboardSummaryState = 'ready' | 'partial' | 'needs_setup' | 'disabled';
|
type DashboardSummaryState = 'ready' | 'partial' | 'needs_setup' | 'disabled';
|
||||||
type BackendState = 'ready' | 'starts_on_launch' | 'needs_auth' | 'needs_proxy' | 'review';
|
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 {
|
interface ImageAnalysisRouteBody {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -74,8 +81,9 @@ function resolveCurrentTargetMode(
|
|||||||
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
|
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
|
||||||
): CurrentTargetMode {
|
): CurrentTargetMode {
|
||||||
if (!status.enabled) return 'disabled';
|
if (!status.enabled) return 'disabled';
|
||||||
if (!status.backendId) return 'unresolved';
|
|
||||||
if (target !== 'claude') return 'bypassed';
|
if (target !== 'claude') return 'bypassed';
|
||||||
|
if (status.nativeReadPreference) return 'native';
|
||||||
|
if (!status.backendId) return 'unresolved';
|
||||||
if (status.status === 'hook-missing') return 'setup';
|
if (status.status === 'hook-missing') return 'setup';
|
||||||
if (status.effectiveRuntimeMode === 'native-read') return 'fallback';
|
if (status.effectiveRuntimeMode === 'native-read') return 'fallback';
|
||||||
return 'active';
|
return 'active';
|
||||||
@@ -140,6 +148,10 @@ async function buildDashboardPayload() {
|
|||||||
effectiveRuntimeMode: status.effectiveRuntimeMode,
|
effectiveRuntimeMode: status.effectiveRuntimeMode,
|
||||||
effectiveRuntimeReason: status.effectiveRuntimeReason,
|
effectiveRuntimeReason: status.effectiveRuntimeReason,
|
||||||
currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status),
|
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,
|
effectiveRuntimeMode: status.effectiveRuntimeMode,
|
||||||
effectiveRuntimeReason: status.effectiveRuntimeReason,
|
effectiveRuntimeReason: status.effectiveRuntimeReason,
|
||||||
currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status),
|
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,
|
authReason: status.authReason,
|
||||||
proxyReadiness: status.proxyReadiness,
|
proxyReadiness: status.proxyReadiness,
|
||||||
proxyReason: status.proxyReason,
|
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(
|
const mappedProfileCount = allProfileRows.filter(
|
||||||
(row) => row.resolutionSource === 'profile-backend'
|
(row) => row.resolutionSource === 'profile-backend'
|
||||||
).length;
|
).length;
|
||||||
|
const nativeProfileCount = allProfileRows.filter((row) => row.nativeReadPreference).length;
|
||||||
const blockerCount = backendRows.filter(
|
const blockerCount = backendRows.filter(
|
||||||
(row) => row.state === 'needs_auth' || row.state === 'needs_proxy' || row.state === 'review'
|
(row) => row.state === 'needs_auth' || row.state === 'needs_proxy' || row.state === 'review'
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
let summaryState: DashboardSummaryState = 'ready';
|
let summaryState: DashboardSummaryState = 'ready';
|
||||||
let title = '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) {
|
if (!config.enabled) {
|
||||||
summaryState = 'disabled';
|
summaryState = 'disabled';
|
||||||
title = 'Disabled';
|
title = 'Disabled';
|
||||||
detail =
|
detail = 'Image is turned off globally. Images and PDFs fall back to native file access.';
|
||||||
'Image Analysis is turned off globally. Images and PDFs fall back to native file access.';
|
|
||||||
} else if (backendRows.length === 0) {
|
} else if (backendRows.length === 0) {
|
||||||
summaryState = 'needs_setup';
|
summaryState = 'needs_setup';
|
||||||
title = 'Needs provider models';
|
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) {
|
} else if (blockerCount > 0) {
|
||||||
summaryState = activeProfileCount > 0 ? 'partial' : 'needs_setup';
|
summaryState = activeProfileCount > 0 ? 'partial' : 'needs_setup';
|
||||||
title = activeProfileCount > 0 ? 'Partially ready' : 'Needs setup';
|
title = activeProfileCount > 0 ? 'Partially ready' : 'Needs setup';
|
||||||
@@ -265,6 +287,7 @@ async function buildDashboardPayload() {
|
|||||||
mappedProfileCount,
|
mappedProfileCount,
|
||||||
activeProfileCount,
|
activeProfileCount,
|
||||||
bypassedProfileCount,
|
bypassedProfileCount,
|
||||||
|
nativeProfileCount,
|
||||||
},
|
},
|
||||||
backends: backendRows,
|
backends: backendRows,
|
||||||
profiles: allProfileRows,
|
profiles: allProfileRows,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const CLIPROXY_API_KEY = 'test-api-key-12345';
|
|||||||
|
|
||||||
// Default provider models for testing (matches DEFAULT_IMAGE_ANALYSIS_CONFIG)
|
// Default provider models for testing (matches DEFAULT_IMAGE_ANALYSIS_CONFIG)
|
||||||
const DEFAULT_PROVIDER_MODELS =
|
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
|
const DEFAULT_PROVIDER = 'agy'; // Default test provider
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -626,7 +626,7 @@ describe('Image Analyzer Hook', () => {
|
|||||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||||
CCS_PROFILE_TYPE: 'cliproxy',
|
CCS_PROFILE_TYPE: 'cliproxy',
|
||||||
CCS_CURRENT_PROVIDER: 'agy',
|
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.max_tokens).toBe(4096);
|
||||||
expect(body.messages).toHaveLength(1);
|
expect(body.messages).toHaveLength(1);
|
||||||
expect(body.messages[0].role).toBe('user');
|
expect(body.messages[0].role).toBe('user');
|
||||||
@@ -738,7 +738,8 @@ describe('Image Analyzer Hook', () => {
|
|||||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||||
CCS_PROFILE_TYPE: 'cliproxy',
|
CCS_PROFILE_TYPE: 'cliproxy',
|
||||||
CCS_CURRENT_PROVIDER: 'codex',
|
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_IMAGE_ANALYSIS_ENABLED: '1',
|
||||||
CCS_PROFILE_TYPE: 'cliproxy',
|
CCS_PROFILE_TYPE: 'cliproxy',
|
||||||
CCS_CURRENT_PROVIDER: 'unknown-provider',
|
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);
|
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', () => {
|
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_IMAGE_ANALYSIS_ENABLED: '1',
|
||||||
CCS_PROFILE_TYPE: 'cliproxy',
|
CCS_PROFILE_TYPE: 'cliproxy',
|
||||||
CCS_CURRENT_PROVIDER: 'codex',
|
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,
|
...env,
|
||||||
},
|
},
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
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 () => {
|
it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => {
|
||||||
const result = await invokeHook({
|
const result = await invokeHook({
|
||||||
CCS_CURRENT_PROVIDER: 'unknown-provider',
|
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);
|
expect(result.code).toBe(0);
|
||||||
|
|||||||
@@ -100,9 +100,17 @@ describe('Model Catalog', () => {
|
|||||||
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
|
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;
|
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);
|
assert.strictEqual(gem25.tier, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has 2 models total', () => {
|
it('includes Gemini Flash with pro tier', () => {
|
||||||
const { MODEL_CATALOG } = modelCatalog;
|
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
|
enabled: true
|
||||||
timeout: 60
|
timeout: 60
|
||||||
provider_models:
|
provider_models:
|
||||||
agy: gemini-2.5-flash
|
agy: gemini-3-1-flash-preview
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||||
expect(content).toContain('enabled: true');
|
expect(content).toContain('enabled: true');
|
||||||
expect(content).toContain('timeout: 60');
|
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', () => {
|
it('should parse disabled status from config.yaml', () => {
|
||||||
@@ -79,14 +79,14 @@ image_analysis:
|
|||||||
enabled: true
|
enabled: true
|
||||||
timeout: 60
|
timeout: 60
|
||||||
provider_models:
|
provider_models:
|
||||||
agy: gemini-2.5-flash
|
agy: gemini-3-1-flash-preview
|
||||||
gemini: gemini-2.5-pro
|
gemini: gemini-2.5-pro
|
||||||
codex: gpt-5.1-codex-mini
|
codex: gpt-5.1-codex-mini
|
||||||
kiro: kiro-claude-haiku-4-5
|
kiro: kiro-claude-haiku-4-5
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
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('gemini: gemini-2.5-pro');
|
||||||
expect(content).toContain('codex: gpt-5.1-codex-mini');
|
expect(content).toContain('codex: gpt-5.1-codex-mini');
|
||||||
expect(content).toContain('kiro: kiro-claude-haiku-4-5');
|
expect(content).toContain('kiro: kiro-claude-haiku-4-5');
|
||||||
@@ -218,8 +218,8 @@ image_analysis:
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
timeout: 60,
|
timeout: 60,
|
||||||
provider_models: {
|
provider_models: {
|
||||||
agy: 'gemini-2.5-flash',
|
agy: 'gemini-3-1-flash-preview',
|
||||||
gemini: 'gemini-2.5-flash',
|
gemini: 'gemini-3-flash-preview',
|
||||||
codex: 'gpt-5.1-codex-mini',
|
codex: 'gpt-5.1-codex-mini',
|
||||||
kiro: 'kiro-claude-haiku-4-5',
|
kiro: 'kiro-claude-haiku-4-5',
|
||||||
ghcp: 'claude-haiku-4.5',
|
ghcp: 'claude-haiku-4.5',
|
||||||
@@ -244,7 +244,7 @@ image_analysis:
|
|||||||
enabled: true
|
enabled: true
|
||||||
timeout: 60
|
timeout: 60
|
||||||
provider_models:
|
provider_models:
|
||||||
agy: gemini-2.5-flash
|
agy: gemini-3-1-flash-preview
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
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.supported).toBe(true);
|
||||||
expect(status.backendId).toBe('gemini');
|
expect(status.backendId).toBe('gemini');
|
||||||
expect(status.resolutionSource).toBe('fallback-backend');
|
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', () => {
|
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');
|
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', () => {
|
it('reports hook-missing when the profile should persist a hook but it is absent', () => {
|
||||||
const status = resolveImageAnalysisStatus(
|
const status = resolveImageAnalysisStatus(
|
||||||
{
|
{
|
||||||
@@ -128,4 +151,32 @@ describe('image-analysis-backend-resolver', () => {
|
|||||||
expect(status.status).toBe('hook-missing');
|
expect(status.status).toBe('hook-missing');
|
||||||
expect(status.reason).toContain('Profile hook is 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.',
|
proxyReason: 'CLIProxy runtime readiness has not been verified yet.',
|
||||||
effectiveRuntimeMode: 'native-read',
|
effectiveRuntimeMode: 'native-read',
|
||||||
effectiveRuntimeReason: null,
|
effectiveRuntimeReason: null,
|
||||||
|
profileModel: 'claude-haiku-4.5',
|
||||||
|
nativeReadPreference: false,
|
||||||
|
nativeImageCapable: true,
|
||||||
|
nativeImageReason: 'claude-haiku-4.5 can read images natively.',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe('image-analysis routes', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
timeout: 60,
|
timeout: 60,
|
||||||
provider_models: {
|
provider_models: {
|
||||||
gemini: 'gemini-2.5-flash',
|
gemini: 'gemini-3-flash-preview',
|
||||||
ghcp: 'claude-haiku-4.5',
|
ghcp: 'claude-haiku-4.5',
|
||||||
},
|
},
|
||||||
fallback_backend: 'gemini',
|
fallback_backend: 'gemini',
|
||||||
@@ -158,7 +158,7 @@ describe('image-analysis routes', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
backendId: 'gemini',
|
backendId: 'gemini',
|
||||||
model: 'gemini-2.5-flash',
|
model: 'gemini-3-flash-preview',
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
backendId: 'ghcp',
|
backendId: 'ghcp',
|
||||||
@@ -227,7 +227,7 @@ describe('image-analysis routes', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
providerModels: {
|
providerModels: {
|
||||||
gemini: 'gemini-2.5-flash',
|
gemini: 'gemini-3-flash-preview',
|
||||||
},
|
},
|
||||||
fallbackBackend: 'gemini',
|
fallbackBackend: 'gemini',
|
||||||
profileBackends: {
|
profileBackends: {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ describe('settings-routes image-analysis status', () => {
|
|||||||
expect(body.imageAnalysisStatus.status).toBe('active');
|
expect(body.imageAnalysisStatus.status).toBe('active');
|
||||||
expect(body.imageAnalysisStatus.backendId).toBe('gemini');
|
expect(body.imageAnalysisStatus.backendId).toBe('gemini');
|
||||||
expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend');
|
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.persistencePath).toContain('glm.settings.json');
|
||||||
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
|
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
|
||||||
expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read');
|
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.resolutionSource).toBe('cliproxy-bridge');
|
||||||
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
|
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 { Link } from 'react-router-dom';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client';
|
import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client';
|
||||||
|
|
||||||
@@ -10,158 +11,122 @@ interface ImageAnalysisStatusSectionProps {
|
|||||||
target?: CliTarget;
|
target?: CliTarget;
|
||||||
source?: 'saved' | 'editor';
|
source?: 'saved' | 'editor';
|
||||||
previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
|
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> = {
|
const TARGET_LABELS: Record<CliTarget, string> = {
|
||||||
claude: 'Claude Code',
|
claude: 'Claude Code',
|
||||||
droid: 'Factory Droid',
|
droid: 'Factory Droid',
|
||||||
codex: 'Codex CLI',
|
codex: 'Codex CLI',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPreviewBadge(
|
function getPreviewLabel(
|
||||||
source: 'saved' | 'editor',
|
source: 'saved' | 'editor',
|
||||||
previewState: ImageAnalysisStatusSectionProps['previewState']
|
previewState: ImageAnalysisStatusSectionProps['previewState']
|
||||||
) {
|
) {
|
||||||
if (previewState === 'refreshing') {
|
if (previewState === 'refreshing') return 'Refreshing preview';
|
||||||
return { label: 'Refreshing', variant: 'outline' as const };
|
if (previewState === 'invalid') return 'Saved status';
|
||||||
}
|
return source === 'editor' ? 'Live preview' : 'Saved status';
|
||||||
if (source === 'editor') {
|
|
||||||
return { label: 'Live Preview', variant: 'secondary' as const };
|
|
||||||
}
|
|
||||||
return { label: 'Saved', variant: 'outline' as const };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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') {
|
if (status.status === 'disabled') {
|
||||||
return {
|
return {
|
||||||
label: 'Disabled',
|
label: 'Disabled',
|
||||||
className: 'border-border/80 bg-background/85 text-muted-foreground',
|
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') {
|
if (target !== 'claude') {
|
||||||
return {
|
return {
|
||||||
label: 'Bypassed',
|
label: 'Bypassed',
|
||||||
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
|
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 {
|
return {
|
||||||
label: 'Native fallback',
|
label: 'Native',
|
||||||
className: 'border-border/80 bg-background/85 text-muted-foreground',
|
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 {
|
return {
|
||||||
label: 'Starts on launch',
|
label: status.status === 'hook-missing' ? 'Setup' : 'Auth',
|
||||||
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
|
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 {
|
return {
|
||||||
label: 'CLIProxy active',
|
label: 'Ready',
|
||||||
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
|
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusContext(
|
function getToggleSummary(status: ImageAnalysisStatus, target: CliTarget): string {
|
||||||
source: 'saved' | 'editor',
|
if (status.nativeReadPreference) {
|
||||||
previewState: ImageAnalysisStatusSectionProps['previewState']
|
if (status.profileModel && status.nativeImageCapable) {
|
||||||
) {
|
return `${status.profileModel} looks image-ready. CCS will bypass the transformer here.`;
|
||||||
if (previewState === 'invalid') {
|
}
|
||||||
return 'Showing saved status until the JSON above is valid again.';
|
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.'
|
if (!status.backendDisplayName) {
|
||||||
: 'Saved status for this profile.';
|
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 {
|
function getExceptionalNote(status: ImageAnalysisStatus, target: CliTarget): string | null {
|
||||||
const backendName = status.backendDisplayName || status.backendId || 'native file access';
|
|
||||||
if (status.status === 'disabled') {
|
if (status.status === 'disabled') {
|
||||||
return 'Image Analysis is disabled globally for this profile.';
|
return 'Image is disabled globally in CCS settings.';
|
||||||
}
|
|
||||||
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.`;
|
|
||||||
}
|
}
|
||||||
if (target !== 'claude') {
|
if (target !== 'claude') {
|
||||||
return status.effectiveRuntimeMode === 'native-read'
|
return `Current target ${TARGET_LABELS[target]} bypasses the Claude Read hook.`;
|
||||||
? `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}.`;
|
|
||||||
}
|
}
|
||||||
if (status.effectiveRuntimeMode === 'native-read') {
|
if (status.nativeReadPreference) {
|
||||||
return `Saved backend: ${backendName}. Current runtime falls back to native file access.`;
|
return status.nativeImageCapable === true ? null : status.nativeImageReason;
|
||||||
}
|
|
||||||
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.status === 'hook-missing') {
|
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') {
|
if (status.authReadiness === 'missing') {
|
||||||
return status.effectiveRuntimeReason || status.reason || 'Using native file access.';
|
return status.authReason;
|
||||||
}
|
}
|
||||||
if (status.proxyReadiness === 'stopped') {
|
if (status.proxyReadiness === 'unavailable') {
|
||||||
return 'Auth is ready. Local CLIProxy will start on demand.';
|
return status.proxyReason;
|
||||||
}
|
}
|
||||||
return 'Current target can use the saved backend.';
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageAnalysisStatusSection({
|
export function ImageAnalysisStatusSection({
|
||||||
@@ -169,147 +134,88 @@ export function ImageAnalysisStatusSection({
|
|||||||
target = 'claude',
|
target = 'claude',
|
||||||
source = 'saved',
|
source = 'saved',
|
||||||
previewState = 'saved',
|
previewState = 'saved',
|
||||||
|
nativeReadPreferenceOverride,
|
||||||
|
onToggleNativeRead,
|
||||||
}: ImageAnalysisStatusSectionProps) {
|
}: ImageAnalysisStatusSectionProps) {
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/20 p-4" aria-live="polite">
|
<div className="rounded-2xl border bg-muted/20 px-4 py-3" aria-live="polite">
|
||||||
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
|
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||||
<div className="mt-2 h-3 w-64 animate-pulse rounded bg-muted" />
|
<div className="mt-2 h-3 w-52 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewBadge = getPreviewBadge(source, previewState);
|
const nativeReadChecked = nativeReadPreferenceOverride ?? status.nativeReadPreference;
|
||||||
const runtimeBadge = getRuntimeBadge(status, target);
|
const effectiveStatus = { ...status, nativeReadPreference: nativeReadChecked };
|
||||||
const showNotice =
|
const headerBadge = getHeaderBadge(effectiveStatus, target);
|
||||||
status.status === 'hook-missing' ||
|
const note = getExceptionalNote(effectiveStatus, target);
|
||||||
status.authReadiness === 'missing' ||
|
const capabilityLabel = status.nativeImageCapable
|
||||||
status.proxyReadiness === 'unavailable';
|
? 'Verified'
|
||||||
|
: status.profileModel
|
||||||
|
? 'Unknown'
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-md border bg-muted/20 p-4">
|
<section className="rounded-2xl border bg-background/95 px-4 py-3 shadow-sm">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ImageIcon className="h-4 w-4 text-sky-600" />
|
<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">
|
||||||
<h3 className="text-sm font-semibold">Image Analysis</h3>
|
<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>
|
</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>
|
||||||
|
|
||||||
<div className="rounded-md border bg-background/70 p-3">
|
<Button size="sm" variant="outline" className="h-8 shrink-0" asChild>
|
||||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
<Link to="/settings?tab=image">
|
||||||
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">
|
|
||||||
Open Settings
|
Open Settings
|
||||||
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,31 @@ export function ProfileEditor({
|
|||||||
setRawJsonEdits(value);
|
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
|
// Sync Visual Editor changes to Raw JSON
|
||||||
const updateEnvValue = (key: string, value: string) => {
|
const updateEnvValue = (key: string, value: string) => {
|
||||||
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
|
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
|
||||||
@@ -165,6 +190,7 @@ export function ProfileEditor({
|
|||||||
(!previewStatusResponse?.imageAnalysisStatus || isPreviewStatusPlaceholderData)
|
(!previewStatusResponse?.imageAnalysisStatus || isPreviewStatusPlaceholderData)
|
||||||
? 'refreshing'
|
? 'refreshing'
|
||||||
: 'preview';
|
: 'preview';
|
||||||
|
const nativeReadPreferenceOverride = currentSettings?.ccs_image?.native_read === true;
|
||||||
|
|
||||||
// Check for missing required fields (informational warning)
|
// Check for missing required fields (informational warning)
|
||||||
const missingRequiredFields = useMemo(() => {
|
const missingRequiredFields = useMemo(() => {
|
||||||
@@ -318,6 +344,8 @@ export function ProfileEditor({
|
|||||||
imageAnalysisStatus={imageAnalysisStatus}
|
imageAnalysisStatus={imageAnalysisStatus}
|
||||||
imageAnalysisStatusSource={imageAnalysisStatusSource}
|
imageAnalysisStatusSource={imageAnalysisStatusSource}
|
||||||
imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState}
|
imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState}
|
||||||
|
nativeReadPreferenceOverride={nativeReadPreferenceOverride}
|
||||||
|
onToggleNativeRead={updateNativeImageRead}
|
||||||
onChange={handleRawJsonChange}
|
onChange={handleRawJsonChange}
|
||||||
missingRequiredFields={missingRequiredFields}
|
missingRequiredFields={missingRequiredFields}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface RawEditorSectionProps {
|
|||||||
imageAnalysisStatus?: ImageAnalysisStatus | null;
|
imageAnalysisStatus?: ImageAnalysisStatus | null;
|
||||||
imageAnalysisStatusSource?: 'saved' | 'editor';
|
imageAnalysisStatusSource?: 'saved' | 'editor';
|
||||||
imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
|
imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
|
||||||
|
nativeReadPreferenceOverride?: boolean;
|
||||||
|
onToggleNativeRead?: (enabled: boolean) => void;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
missingRequiredFields?: string[];
|
missingRequiredFields?: string[];
|
||||||
}
|
}
|
||||||
@@ -37,6 +39,8 @@ export function RawEditorSection({
|
|||||||
imageAnalysisStatus,
|
imageAnalysisStatus,
|
||||||
imageAnalysisStatusSource = 'saved',
|
imageAnalysisStatusSource = 'saved',
|
||||||
imageAnalysisStatusPreviewState = 'saved',
|
imageAnalysisStatusPreviewState = 'saved',
|
||||||
|
nativeReadPreferenceOverride,
|
||||||
|
onToggleNativeRead,
|
||||||
onChange,
|
onChange,
|
||||||
missingRequiredFields = [],
|
missingRequiredFields = [],
|
||||||
}: RawEditorSectionProps) {
|
}: RawEditorSectionProps) {
|
||||||
@@ -91,6 +95,8 @@ export function RawEditorSection({
|
|||||||
target={profileTarget}
|
target={profileTarget}
|
||||||
source={imageAnalysisStatusSource}
|
source={imageAnalysisStatusSource}
|
||||||
previewState={imageAnalysisStatusPreviewState}
|
previewState={imageAnalysisStatusPreviewState}
|
||||||
|
nativeReadPreferenceOverride={nativeReadPreferenceOverride}
|
||||||
|
onToggleNativeRead={onToggleNativeRead}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Global Env Indicator */}
|
{/* Global Env Indicator */}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/l
|
|||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
ccs_image?: {
|
||||||
|
native_read?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsResponse {
|
export interface SettingsResponse {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export interface ImageAnalysisStatus {
|
|||||||
| 'cliproxy-bridge'
|
| 'cliproxy-bridge'
|
||||||
| 'profile-backend'
|
| 'profile-backend'
|
||||||
| 'fallback-backend'
|
| 'fallback-backend'
|
||||||
|
| 'native-compatible'
|
||||||
| 'disabled'
|
| 'disabled'
|
||||||
| 'unsupported-profile'
|
| 'unsupported-profile'
|
||||||
| 'unresolved'
|
| 'unresolved'
|
||||||
@@ -146,6 +147,10 @@ export interface ImageAnalysisStatus {
|
|||||||
proxyReason: string | null;
|
proxyReason: string | null;
|
||||||
effectiveRuntimeMode: 'cliproxy-image-analysis' | 'native-read';
|
effectiveRuntimeMode: 'cliproxy-image-analysis' | 'native-read';
|
||||||
effectiveRuntimeReason: string | null;
|
effectiveRuntimeReason: string | null;
|
||||||
|
profileModel: string | null;
|
||||||
|
nativeReadPreference: boolean;
|
||||||
|
nativeImageCapable: boolean | null;
|
||||||
|
nativeImageReason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageAnalysisSettingsConfig {
|
export interface ImageAnalysisSettingsConfig {
|
||||||
@@ -164,6 +169,7 @@ export interface ImageAnalysisDashboardSummary {
|
|||||||
mappedProfileCount: number;
|
mappedProfileCount: number;
|
||||||
activeProfileCount: number;
|
activeProfileCount: number;
|
||||||
bypassedProfileCount: number;
|
bypassedProfileCount: number;
|
||||||
|
nativeProfileCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageAnalysisDashboardBackend {
|
export interface ImageAnalysisDashboardBackend {
|
||||||
@@ -190,7 +196,18 @@ export interface ImageAnalysisDashboardProfile {
|
|||||||
status: ImageAnalysisStatus['status'];
|
status: ImageAnalysisStatus['status'];
|
||||||
effectiveRuntimeMode: ImageAnalysisStatus['effectiveRuntimeMode'];
|
effectiveRuntimeMode: ImageAnalysisStatus['effectiveRuntimeMode'];
|
||||||
effectiveRuntimeReason: string | null;
|
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 {
|
export interface ImageAnalysisDashboardCatalog {
|
||||||
|
|||||||
@@ -150,19 +150,19 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
|||||||
default: 'gemini-3.1-pro-preview',
|
default: 'gemini-3.1-pro-preview',
|
||||||
opus: 'gemini-3.1-pro-preview',
|
opus: 'gemini-3.1-pro-preview',
|
||||||
sonnet: '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',
|
name: 'Gemini Flash',
|
||||||
description: 'Resolves to the best advertised Gemini Flash preview via Antigravity',
|
description: 'Resolves to the best advertised Gemini Flash preview via Antigravity',
|
||||||
extendedContext: true,
|
extendedContext: true,
|
||||||
presetMapping: {
|
presetMapping: {
|
||||||
default: 'gemini-3-flash-preview',
|
default: 'gemini-3-1-flash-preview',
|
||||||
opus: 'gemini-3.1-pro-preview',
|
opus: 'gemini-3.1-pro-preview',
|
||||||
sonnet: '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 { t } = useTranslation();
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
|
{ 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: 'channels' as const, label: 'Channels', icon: MessageSquare },
|
||||||
{ value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 },
|
{ value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 },
|
||||||
{ value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain },
|
{ 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)}>
|
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
||||||
<TabsList className="grid w-full grid-cols-8">
|
<TabsList className="grid w-full grid-cols-8">
|
||||||
{tabs.map(({ value, label, icon: Icon }) => (
|
{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" />
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export function useSettingsTab() {
|
|||||||
// Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups)
|
// Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups)
|
||||||
const tabParam = searchParams.get('tab')?.toLowerCase();
|
const tabParam = searchParams.get('tab')?.toLowerCase();
|
||||||
const activeTab: SettingsTab =
|
const activeTab: SettingsTab =
|
||||||
tabParam === 'imageanalysis'
|
tabParam === 'imageanalysis' || tabParam === 'image'
|
||||||
? 'imageanalysis'
|
? 'image'
|
||||||
: tabParam === 'channels'
|
: tabParam === 'channels'
|
||||||
? 'channels'
|
? 'channels'
|
||||||
: tabParam === 'globalenv'
|
: tabParam === 'globalenv'
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function SettingsPageInner() {
|
|||||||
<SectionErrorBoundary>
|
<SectionErrorBoundary>
|
||||||
<Suspense fallback={<SectionSkeleton />}>
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
{activeTab === 'websearch' && <WebSearchSection />}
|
{activeTab === 'websearch' && <WebSearchSection />}
|
||||||
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
|
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||||
{activeTab === 'channels' && <ChannelsSection />}
|
{activeTab === 'channels' && <ChannelsSection />}
|
||||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||||
{activeTab === 'thinking' && <ThinkingSection />}
|
{activeTab === 'thinking' && <ThinkingSection />}
|
||||||
@@ -146,7 +146,7 @@ function SettingsPageInner() {
|
|||||||
{/* Desktop View - Side-by-side panels */}
|
{/* Desktop View - Side-by-side panels */}
|
||||||
<PanelGroup direction="horizontal" className="h-full hidden md:flex">
|
<PanelGroup direction="horizontal" className="h-full hidden md:flex">
|
||||||
{/* Left Panel - Settings Controls */}
|
{/* 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">
|
<div className="h-full border-r flex flex-col bg-muted/30 relative">
|
||||||
{/* Header with Tabs */}
|
{/* Header with Tabs */}
|
||||||
<div className="p-5 border-b bg-background">
|
<div className="p-5 border-b bg-background">
|
||||||
@@ -157,7 +157,7 @@ function SettingsPageInner() {
|
|||||||
<SectionErrorBoundary>
|
<SectionErrorBoundary>
|
||||||
<Suspense fallback={<SectionSkeleton />}>
|
<Suspense fallback={<SectionSkeleton />}>
|
||||||
{activeTab === 'websearch' && <WebSearchSection />}
|
{activeTab === 'websearch' && <WebSearchSection />}
|
||||||
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
|
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||||
{activeTab === 'channels' && <ChannelsSection />}
|
{activeTab === 'channels' && <ChannelsSection />}
|
||||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||||
{activeTab === 'thinking' && <ThinkingSection />}
|
{activeTab === 'thinking' && <ThinkingSection />}
|
||||||
@@ -175,7 +175,7 @@ function SettingsPageInner() {
|
|||||||
</PanelResizeHandle>
|
</PanelResizeHandle>
|
||||||
|
|
||||||
{/* Right Panel - Config Viewer */}
|
{/* Right Panel - Config Viewer */}
|
||||||
<Panel defaultSize={60} minSize={35}>
|
<Panel defaultSize={54} minSize={35}>
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b bg-background flex items-center justify-between">
|
<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 =
|
export type SettingsTab =
|
||||||
| 'websearch'
|
| 'websearch'
|
||||||
| 'imageanalysis'
|
| 'image'
|
||||||
| 'channels'
|
| 'channels'
|
||||||
| 'globalenv'
|
| 'globalenv'
|
||||||
| 'proxy'
|
| 'proxy'
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
backendId: 'gemini',
|
backendId: 'gemini',
|
||||||
backendDisplayName: 'Google Gemini',
|
backendDisplayName: 'Google Gemini',
|
||||||
model: 'gemini-2.5-flash',
|
model: 'gemini-3-flash-preview',
|
||||||
resolutionSource: 'cliproxy-bridge',
|
resolutionSource: 'cliproxy-bridge',
|
||||||
reason: null,
|
reason: null,
|
||||||
shouldPersistHook: true,
|
shouldPersistHook: true,
|
||||||
@@ -56,6 +56,10 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
|
|||||||
proxyReason: 'Local CLIProxy service is reachable.',
|
proxyReason: 'Local CLIProxy service is reachable.',
|
||||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||||
effectiveRuntimeReason: null,
|
effectiveRuntimeReason: null,
|
||||||
|
profileModel: 'gemini-3-flash-preview',
|
||||||
|
nativeReadPreference: false,
|
||||||
|
nativeImageCapable: true,
|
||||||
|
nativeImageReason: 'gemini-3-flash-preview can read images natively.',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -80,18 +84,16 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
it('renders a compact saved summary with a settings link', () => {
|
it('renders a compact saved summary with a settings link', () => {
|
||||||
render(<ImageAnalysisStatusSection status={createStatus()} />);
|
render(<ImageAnalysisStatusSection status={createStatus()} />);
|
||||||
|
|
||||||
expect(screen.getByText('Image Analysis')).toBeInTheDocument();
|
expect(screen.getByText('Image')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Saved')).toBeInTheDocument();
|
expect(screen.getByText(/Saved status · Transformer ready/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText('CLIProxy active')).toBeInTheDocument();
|
expect(screen.getByText('Ready')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Use native image reading')).toBeInTheDocument();
|
||||||
expect(
|
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();
|
).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(
|
expect(screen.getByRole('link', { name: /Open Settings/i })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'/settings?tab=imageanalysis'
|
'/settings?tab=image'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,12 +101,12 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
render(<ImageAnalysisStatusSection status={createStatus()} target="codex" />);
|
render(<ImageAnalysisStatusSection status={createStatus()} target="codex" />);
|
||||||
|
|
||||||
expect(screen.getByText('Bypassed')).toBeInTheDocument();
|
expect(screen.getByText('Bypassed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Saved status · Codex CLI bypasses the hook/i)).toBeInTheDocument();
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText('Codex CLI')).toBeInTheDocument();
|
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,6 +116,8 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
status={createStatus({
|
status={createStatus({
|
||||||
backendId: 'ghcp',
|
backendId: 'ghcp',
|
||||||
backendDisplayName: 'GitHub Copilot (OAuth)',
|
backendDisplayName: 'GitHub Copilot (OAuth)',
|
||||||
|
model: 'claude-haiku-4.5',
|
||||||
|
profileModel: 'claude-haiku-4.5',
|
||||||
authReadiness: 'missing',
|
authReadiness: 'missing',
|
||||||
authProvider: 'ghcp',
|
authProvider: 'ghcp',
|
||||||
authDisplayName: 'GitHub Copilot (OAuth)',
|
authDisplayName: 'GitHub Copilot (OAuth)',
|
||||||
@@ -126,15 +130,94 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Needs auth')).toBeInTheDocument();
|
expect(screen.getByText('Auth')).toBeInTheDocument();
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length
|
screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length
|
||||||
).toBeGreaterThanOrEqual(1);
|
).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 () => {
|
it('switches to live preview when editor JSON changes', async () => {
|
||||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
@@ -179,7 +262,7 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
|
|
||||||
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
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'), {
|
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||||
target: {
|
target: {
|
||||||
@@ -197,10 +280,9 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
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(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Preview from the current editor JSON/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to saved status messaging when the editor JSON is invalid', async () => {
|
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" />);
|
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'), {
|
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||||
target: { value: '{' },
|
target: { value: '{' },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(screen.getByText(/Saved status/i)).toBeInTheDocument();
|
||||||
screen.getByText(/Showing saved status until the JSON above is valid again/i)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -303,7 +383,7 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
|
|
||||||
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
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'), {
|
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||||
target: {
|
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'), {
|
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||||
target: {
|
target: {
|
||||||
@@ -338,9 +418,8 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
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?.(
|
secondPreviewResolver?.(
|
||||||
createJsonResponse({
|
createJsonResponse({
|
||||||
@@ -356,7 +435,7 @@ describe('ImageAnalysisStatusSection', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Codex')).toBeInTheDocument();
|
expect(screen.getByText(/Codex/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe('ImageAnalysisSection', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
timeout: 60,
|
timeout: 60,
|
||||||
providerModels: {
|
providerModels: {
|
||||||
gemini: 'gemini-2.5-flash',
|
gemini: 'gemini-3-flash-preview',
|
||||||
ghcp: 'claude-haiku-4.5',
|
ghcp: 'claude-haiku-4.5',
|
||||||
},
|
},
|
||||||
fallbackBackend: 'gemini',
|
fallbackBackend: 'gemini',
|
||||||
@@ -30,17 +30,18 @@ describe('ImageAnalysisSection', () => {
|
|||||||
state: 'partial',
|
state: 'partial',
|
||||||
title: 'Partially ready',
|
title: 'Partially ready',
|
||||||
detail:
|
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,
|
backendCount: 2,
|
||||||
mappedProfileCount: 1,
|
mappedProfileCount: 1,
|
||||||
activeProfileCount: 1,
|
activeProfileCount: 1,
|
||||||
bypassedProfileCount: 1,
|
bypassedProfileCount: 1,
|
||||||
|
nativeProfileCount: 1,
|
||||||
},
|
},
|
||||||
backends: [
|
backends: [
|
||||||
{
|
{
|
||||||
backendId: 'gemini',
|
backendId: 'gemini',
|
||||||
displayName: 'Google Gemini',
|
displayName: 'Google Gemini',
|
||||||
model: 'gemini-2.5-flash',
|
model: 'gemini-3-flash-preview',
|
||||||
state: 'ready',
|
state: 'ready',
|
||||||
authReadiness: 'ready',
|
authReadiness: 'ready',
|
||||||
authReason: null,
|
authReason: null,
|
||||||
@@ -74,6 +75,10 @@ describe('ImageAnalysisSection', () => {
|
|||||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||||
effectiveRuntimeReason: null,
|
effectiveRuntimeReason: null,
|
||||||
currentTargetMode: 'active',
|
currentTargetMode: 'active',
|
||||||
|
profileModel: 'gemini-3-flash-preview',
|
||||||
|
nativeReadPreference: false,
|
||||||
|
nativeImageCapable: true,
|
||||||
|
nativeImageReason: 'gemini-3-flash-preview can read images natively.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'codexProfile',
|
name: 'codexProfile',
|
||||||
@@ -88,6 +93,10 @@ describe('ImageAnalysisSection', () => {
|
|||||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||||
effectiveRuntimeReason: null,
|
effectiveRuntimeReason: null,
|
||||||
currentTargetMode: 'bypassed',
|
currentTargetMode: 'bypassed',
|
||||||
|
profileModel: 'claude-haiku-4.5',
|
||||||
|
nativeReadPreference: true,
|
||||||
|
nativeImageCapable: true,
|
||||||
|
nativeImageReason: 'claude-haiku-4.5 can read images natively.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
catalog: {
|
catalog: {
|
||||||
@@ -110,21 +119,32 @@ describe('ImageAnalysisSection', () => {
|
|||||||
|
|
||||||
if (url === '/api/image-analysis' && method === 'PUT') {
|
if (url === '/api/image-analysis' && method === 'PUT') {
|
||||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
enabled?: boolean;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
fallbackBackend?: string;
|
fallbackBackend?: string;
|
||||||
profileBackends?: Record<string, string>;
|
profileBackends?: Record<string, string>;
|
||||||
providerModels?: Record<string, string | null>;
|
providerModels?: Record<string, string | null>;
|
||||||
};
|
};
|
||||||
|
const providerModels = body.providerModels ?? {};
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
...payload,
|
...payload,
|
||||||
config: {
|
config: {
|
||||||
enabled: true,
|
enabled: body.enabled ?? payload.config.enabled,
|
||||||
timeout: body.timeout ?? payload.config.timeout,
|
timeout: body.timeout ?? payload.config.timeout,
|
||||||
fallbackBackend: body.fallbackBackend ?? payload.config.fallbackBackend,
|
fallbackBackend:
|
||||||
|
'fallbackBackend' in body
|
||||||
|
? (body.fallbackBackend ?? null)
|
||||||
|
: payload.config.fallbackBackend,
|
||||||
providerModels: {
|
providerModels: {
|
||||||
gemini: String(body.providerModels?.gemini ?? payload.config.providerModels.gemini),
|
gemini:
|
||||||
ghcp: String(body.providerModels?.ghcp ?? payload.config.providerModels.ghcp),
|
'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,
|
profileBackends: body.profileBackends ?? payload.config.profileBackends,
|
||||||
},
|
},
|
||||||
@@ -146,18 +166,19 @@ describe('ImageAnalysisSection', () => {
|
|||||||
it('renders global controls and saves updated config', async () => {
|
it('renders global controls and saves updated config', async () => {
|
||||||
render(<ImageAnalysisSection />, { withSettingsProvider: true });
|
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('Partially ready')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Provider models')).toBeInTheDocument();
|
expect(screen.getByText('Core setup')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Profile mappings')).toBeInTheDocument();
|
expect(screen.getAllByText('Native reading').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('Profile coverage')).toBeInTheDocument();
|
expect(screen.getByText('Profile routing')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Bypassed')).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');
|
const timeoutInput = screen.getByDisplayValue('60');
|
||||||
await userEvent.clear(timeoutInput);
|
await userEvent.clear(timeoutInput);
|
||||||
await userEvent.type(timeoutInput, '120');
|
await userEvent.type(timeoutInput, '120');
|
||||||
|
await userEvent.tab();
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
@@ -168,10 +189,12 @@ describe('ImageAnalysisSection', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const putCall = fetchMock.mock.calls.find(
|
const putCall = fetchMock.mock.calls
|
||||||
([url, init]) =>
|
.filter(
|
||||||
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
|
([url, init]) =>
|
||||||
);
|
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
|
||||||
|
)
|
||||||
|
.at(-1);
|
||||||
expect(putCall).toBeDefined();
|
expect(putCall).toBeDefined();
|
||||||
|
|
||||||
const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}'));
|
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 () => {
|
it('surfaces a clear retryable error when the backend route is not available yet', async () => {
|
||||||
@@ -209,7 +322,7 @@ describe('ImageAnalysisSection', () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
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();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||||
|
|||||||
Reference in New Issue
Block a user