From 3246c40319b0f9329c375ae3fe73ee02261579bb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 14:57:56 -0400 Subject: [PATCH 01/29] feat(codex-dashboard): add manual long-context controls --- src/shared/compatible-cli-contracts.ts | 4 + .../services/codex-dashboard-service.ts | 14 ++ .../codex-dashboard-service.test.ts | 30 +++ .../codex-top-level-controls-card.tsx | 235 +++++++++++++++++- ui/src/lib/codex-config.ts | 4 + .../codex-overview-tab.test.tsx | 2 + .../codex-top-level-controls-card.test.tsx | 67 +++++ ui/tests/unit/hooks/use-codex.test.tsx | 2 + ui/tests/unit/ui/pages/codex-page.test.tsx | 2 + 9 files changed, 359 insertions(+), 1 deletion(-) diff --git a/src/shared/compatible-cli-contracts.ts b/src/shared/compatible-cli-contracts.ts index 0c0f1b42..7433d322 100644 --- a/src/shared/compatible-cli-contracts.ts +++ b/src/shared/compatible-cli-contracts.ts @@ -91,6 +91,8 @@ export interface CodexSupportMatrixEntry { export interface CodexUserConfigDiagnostics { model: string | null; modelReasoningEffort: string | null; + modelContextWindow: number | null; + modelAutoCompactTokenLimit: number | null; modelProvider: string | null; activeProfile: string | null; approvalPolicy: string | null; @@ -137,6 +139,8 @@ export interface CodexRawConfigResponse { export interface CodexTopLevelSettingsPatch { model?: string | null; modelReasoningEffort?: string | null; + modelContextWindow?: number | null; + modelAutoCompactTokenLimit?: number | null; modelProvider?: string | null; approvalPolicy?: string | null; sandboxMode?: string | null; diff --git a/src/web-server/services/codex-dashboard-service.ts b/src/web-server/services/codex-dashboard-service.ts index 725c0bc1..8bba991f 100644 --- a/src/web-server/services/codex-dashboard-service.ts +++ b/src/web-server/services/codex-dashboard-service.ts @@ -243,6 +243,18 @@ function applyTopLevelSettingsPatch( 'model_reasoning_effort' ); } + if (hasOwn(values, 'modelContextWindow')) { + setNumberField(target, 'model_context_window', values.modelContextWindow, { + integer: true, + min: 1, + }); + } + if (hasOwn(values, 'modelAutoCompactTokenLimit')) { + setNumberField(target, 'model_auto_compact_token_limit', values.modelAutoCompactTokenLimit, { + integer: true, + min: 1, + }); + } if (hasOwn(values, 'modelProvider')) { setStringField(target, 'model_provider', values.modelProvider); } @@ -778,6 +790,8 @@ export async function getCodexDashboardDiagnostics(): Promise { path.join(codexHome, 'config.toml'), `model = "gpt-5.4" profile = "work" +model_context_window = 800000 +model_auto_compact_token_limit = 700000 model_provider = "cliproxy" approval_policy = "never" sandbox_mode = "danger-full-access" @@ -217,6 +219,8 @@ model = "gpt-5.4" expect(diagnostics.binary.installed).toBe(true); expect(diagnostics.binary.supportsConfigOverrides).toBe(true); expect(diagnostics.config.model).toBe('gpt-5.4'); + expect(diagnostics.config.modelContextWindow).toBe(800000); + expect(diagnostics.config.modelAutoCompactTokenLimit).toBe(700000); expect(diagnostics.config.activeProfile).toBe('work'); expect(diagnostics.config.modelProvider).toBe('cliproxy'); expect(diagnostics.config.profileCount).toBe(1); @@ -376,6 +380,8 @@ bearer_token = "secret" values: { model: 'gpt-5.4', modelReasoningEffort: 'high', + modelContextWindow: 800000, + modelAutoCompactTokenLimit: 700000, approvalPolicy: 'never', sandboxMode: 'workspace-write', webSearch: 'cached', @@ -394,10 +400,14 @@ bearer_token = "secret" const diagnostics = await getCodexDashboardDiagnostics(); expect(diagnostics.config.model).toBe('gpt-5.4'); expect(diagnostics.config.modelReasoningEffort).toBe('high'); + expect(diagnostics.config.modelContextWindow).toBe(800000); + expect(diagnostics.config.modelAutoCompactTokenLimit).toBe(700000); expect(diagnostics.config.toolOutputTokenLimit).toBe(12000); expect(diagnostics.config.personality).toBe('friendly'); expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a'); expect(result.rawText).toContain('model = "gpt-5.4"'); + expect(result.rawText).toContain('model_context_window = 800000'); + expect(result.rawText).toContain('model_auto_compact_token_limit = 700000'); expect(result.config?.model).toBe('gpt-5.4'); }); @@ -665,4 +675,24 @@ bearer_token = "secret" }) ).rejects.toThrow(CodexRawConfigValidationError); }); + + it('rejects invalid long-context values in structured top-level patches', async () => { + await expect( + patchCodexConfig({ + kind: 'top-level', + values: { + modelContextWindow: 0, + }, + }) + ).rejects.toThrow(CodexRawConfigValidationError); + + await expect( + patchCodexConfig({ + kind: 'top-level', + values: { + modelAutoCompactTokenLimit: 1.5, + }, + }) + ).rejects.toThrow(CodexRawConfigValidationError); + }); }); diff --git a/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx b/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx index 15dc5927..2194b5b9 100644 --- a/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx +++ b/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { Loader2, SlidersHorizontal } from 'lucide-react'; +import { CircleAlert, Loader2, SlidersHorizontal } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -14,6 +15,11 @@ import type { CodexTopLevelSettingsView } from '@/lib/codex-config'; import { CodexConfigCardShell } from './codex-config-card-shell'; const UNSET = '__unset__'; +const GPT_54_MAX_CONTEXT_WINDOW = 1_050_000; +const GPT_54_STANDARD_CONTEXT_WINDOW = 272_000; +const CCS_GPT_54_STARTER_CONTEXT_WINDOW = 800_000; +const CCS_GPT_54_STARTER_AUTO_COMPACT_TOKEN_LIMIT = 700_000; +const INTEGER_FORMATTER = new Intl.NumberFormat('en-US'); interface CodexTopLevelControlsCardProps { values: CodexTopLevelSettingsView; @@ -32,6 +38,14 @@ function withCurrentValue(options: string[], current: string | null | undefined) return current && !options.includes(current) ? [current, ...options] : options; } +function formatInteger(value: number) { + return INTEGER_FORMATTER.format(value); +} + +function isGpt54ModelId(value: string | null | undefined) { + return value?.trim().toLowerCase().startsWith('gpt-5.4') ?? false; +} + function buildTopLevelPatch( initialValues: CodexTopLevelSettingsView, draft: CodexTopLevelSettingsView @@ -42,6 +56,12 @@ function buildTopLevelPatch( if (draft.modelReasoningEffort !== initialValues.modelReasoningEffort) { patch.modelReasoningEffort = draft.modelReasoningEffort; } + if (draft.modelContextWindow !== initialValues.modelContextWindow) { + patch.modelContextWindow = draft.modelContextWindow; + } + if (draft.modelAutoCompactTokenLimit !== initialValues.modelAutoCompactTokenLimit) { + patch.modelAutoCompactTokenLimit = draft.modelAutoCompactTokenLimit; + } if (draft.modelProvider !== initialValues.modelProvider) { patch.modelProvider = draft.modelProvider; } @@ -91,6 +111,11 @@ function TopLevelControlsForm({ const personalityOptions = withCurrentValue(['none', 'friendly', 'pragmatic'], draft.personality); const patch = buildTopLevelPatch(initialValues, draft); const hasChanges = Object.keys(patch).length > 0; + const isGpt54Selected = isGpt54ModelId(draft.model); + const parseOptionalInteger = (value: string) => { + const trimmed = value.trim(); + return trimmed.length > 0 ? Number(trimmed) : null; + }; return ( <> @@ -266,6 +291,214 @@ function TopLevelControlsForm({ +
+
+
+
+ +

Long context override

+ + Manual opt-in only + + + {isGpt54Selected ? 'GPT-5.4 selected' : 'GPT-5.4 reference'} + +
+

Draft values only. Nothing applies until Save.

+
+ +
+ + + +
+
+ +
+
+

+ Official max +

+

1.05M / 1M

+

GPT-5.4 context cap

+
+
+

+ Standard window +

+

+ {formatInteger(GPT_54_STANDARD_CONTEXT_WINDOW)} +

+

Normal usage window

+
+
+

+ Above 272K +

+

Counts 2x

+

Usage-limit cost above 272K

+
+
+ +
+
+
+

+ One cautious pair +

+
+ Context {formatInteger(CCS_GPT_54_STARTER_CONTEXT_WINDOW)} +
+
+ Auto-compact {formatInteger(CCS_GPT_54_STARTER_AUTO_COMPACT_TOKEN_LIMIT)} +
+
+
+ + Not official + + + Draft only + +
+
+
+ Quick-fill only. Review before saving. + {!isGpt54Selected && draft.model ? ( + + {draft.model} should be checked separately. + + ) : null} +
+
+ +
+
+

Model context window

+ + setDraft((current) => ({ + ...current, + modelContextWindow: parseOptionalInteger(event.target.value), + })) + } + placeholder="Unset" + disabled={disabled} + /> +

+ Writes model_context_window. Leave unset to keep Codex defaults. +

+
+ +
+

Auto-compact token limit

+ + setDraft((current) => ({ + ...current, + modelAutoCompactTokenLimit: parseOptionalInteger(event.target.value), + })) + } + placeholder="Unset" + disabled={disabled} + /> +

+ Writes model_auto_compact_token_limit. Leave unset to keep model + defaults. +

+
+
+ +
+ Docs + + GPT-5.4 model page + + + Release notes + + + Config reference + +
+
+
-

Draft values only. Nothing applies until Save.

+

+ Draft values only. Nothing applies until Save. +

From ae459fc3d7ee85bffc43494e7a4d7b06263e6982 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 31 Mar 2026 22:54:10 -0400 Subject: [PATCH 04/29] feat(image-analysis): resolve backend status per profile --- src/api/services/profile-types.ts | 29 ++ src/ccs.ts | 36 +- src/cliproxy/services/variant-settings.ts | 23 +- src/commands/config-image-analysis-command.ts | 107 +++++ src/config/unified-config-loader.ts | 17 +- src/config/unified-config-types.ts | 6 + src/copilot/copilot-executor.ts | 5 +- .../hooks/get-image-analysis-hook-env.ts | 28 +- .../hooks/image-analysis-backend-resolver.ts | 379 ++++++++++++++++++ .../image-analyzer-profile-hook-injector.ts | 66 ++- src/utils/hooks/index.ts | 7 + src/web-server/routes/settings-routes.ts | 64 ++- .../editor/image-analysis-status-section.tsx | 173 ++++++++ ui/src/components/profiles/editor/index.tsx | 1 + .../profiles/editor/raw-editor-section.tsx | 7 + ui/src/components/profiles/editor/types.ts | 3 +- ui/src/lib/api-client.ts | 29 ++ 17 files changed, 932 insertions(+), 48 deletions(-) create mode 100644 src/utils/hooks/image-analysis-backend-resolver.ts create mode 100644 ui/src/components/profiles/editor/image-analysis-status-section.tsx diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts index 2f8bd40a..2cb096c0 100644 --- a/src/api/services/profile-types.ts +++ b/src/api/services/profile-types.ts @@ -64,6 +64,35 @@ export interface CliproxyBridgeMetadata { usesCurrentAuthToken: boolean; } +export interface ImageAnalysisProfileStatus { + enabled: boolean; + supported: boolean; + status: 'active' | 'mapped' | 'attention' | 'disabled' | 'skipped' | 'hook-missing'; + backendId: string | null; + backendDisplayName: string | null; + model: string | null; + resolutionSource: + | 'cliproxy-provider' + | 'cliproxy-variant' + | 'cliproxy-composite' + | 'copilot-alias' + | 'cliproxy-bridge' + | 'profile-backend' + | 'fallback-backend' + | 'disabled' + | 'unsupported-profile' + | 'unresolved' + | 'missing-model'; + reason: string | null; + shouldPersistHook: boolean; + persistencePath: string | null; + runtimePath: string | null; + usesCurrentTarget: boolean | null; + usesCurrentAuthToken: boolean | null; + hookInstalled: boolean | null; + sharedHookInstalled: boolean | null; +} + export interface ResolvedCliproxyBridgeProfile { name: string; provider: CLIProxyProvider; diff --git a/src/ccs.ts b/src/ccs.ts index d427d234..cd0b6ca1 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -33,7 +33,7 @@ import { } from './utils/websearch-manager'; import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; -import { getImageAnalysisHookEnv } from './utils/hooks'; +import { getImageAnalysisHookEnv, installImageAnalyzerHook } from './utils/hooks'; import { fail, info, warn } from './utils/ui'; import { isCopilotSubcommandToken } from './copilot/constants'; import { @@ -682,10 +682,15 @@ async function main(): Promise { if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); } - // Inject Image Analyzer hook into profile settings before launch - ensureImageAnalyzerHooks(profileInfo.name); - const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); + // Inject Image Analyzer hook into profile settings before launch + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + cliproxyProvider: provider, + isComposite: profileInfo.isComposite, + settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined, + }); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles const variantPort = profileInfo.port; // variant-specific port for isolation const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; @@ -839,8 +844,12 @@ async function main(): Promise { } else if (profileInfo.type === 'copilot') { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy ensureWebSearchMcpOrThrow(); + installImageAnalyzerHook(); // Inject Image Analyzer hook into profile settings before launch - ensureImageAnalyzerHooks(profileInfo.name); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + }); const { executeCopilotProfile } = await import('./copilot'); const copilotConfig = profileInfo.copilotConfig; @@ -871,9 +880,8 @@ async function main(): Promise { // Settings-based profiles (glm, glmt) are third-party providers if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); + installImageAnalyzerHook(); } - // Inject Image Analyzer hook into profile settings before launch - ensureImageAnalyzerHooks(profileInfo.name); // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); @@ -902,6 +910,13 @@ async function main(): Promise { : getSettingsPath(profileInfo.name)); const settings = resolvedSettings ?? loadSettings(expandedSettingsPath); const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + settings, + cliproxyBridge, + }); if (resolvedTarget !== 'claude') { const compatibility = evaluateTargetRuntimeCompatibility({ target: resolvedTarget, @@ -998,7 +1013,12 @@ async function main(): Promise { } const webSearchEnv = getWebSearchHookEnv(); - const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name); + const imageAnalysisEnv = getImageAnalysisHookEnv({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + }); // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 6e982528..924aa95d 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -161,7 +161,12 @@ export function createSettingsFile( } // Inject Image Analyzer hooks into variant settings - ensureImageAnalyzerHooks(`${provider}-${name}`); + ensureImageAnalyzerHooks({ + profileName: `${provider}-${name}`, + profileType: 'cliproxy', + cliproxyProvider: provider, + settingsPath, + }); return settingsPath; } @@ -195,7 +200,12 @@ export function createSettingsFileUnified( } // Inject Image Analyzer hooks into variant settings - ensureImageAnalyzerHooks(`${provider}-${name}`); + ensureImageAnalyzerHooks({ + profileName: `${provider}-${name}`, + profileType: 'cliproxy', + cliproxyProvider: provider, + settingsPath, + }); return settingsPath; } @@ -284,7 +294,6 @@ export function createCompositeSettingsFile( ensureDir(settingsDir); writeSettings(settingsPath, settings); - // Hook injectors target ~/.ccs/.settings.json; only run for default path. if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) { try { ensureWebSearchMcpOrThrow(); @@ -292,7 +301,13 @@ export function createCompositeSettingsFile( rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } - ensureImageAnalyzerHooks(`composite-${name}`); + ensureImageAnalyzerHooks({ + profileName: `composite-${name}`, + profileType: 'cliproxy', + cliproxyProvider: tiers[defaultTier].provider, + isComposite: true, + settingsPath, + }); } return settingsPath; diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index 41ec5df5..0301cfc6 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -18,13 +18,19 @@ import { mapExternalProviderName, } from '../cliproxy/provider-capabilities'; import { extractOption, hasAnyFlag } from './arg-extractor'; +import { normalizeImageAnalysisBackendId } from '../utils/hooks'; interface ImageAnalysisCommandOptions { enable?: boolean; disable?: boolean; timeout?: number; setModel?: { provider: string; model: string }; + setFallback?: string; + setProfileBackend?: { profile: string; backend: string }; + clearProfileBackend?: string; setModelError?: string; + setFallbackError?: string; + setProfileBackendError?: string; help?: boolean; } @@ -62,6 +68,36 @@ function parseArgs(args: string[]): ImageAnalysisCommandOptions { } } + const setFallbackIdx = args.indexOf('--set-fallback'); + if (setFallbackIdx !== -1) { + const backend = args[setFallbackIdx + 1]; + if (backend && !backend.startsWith('-')) { + options.setFallback = backend; + } else { + options.setFallbackError = '--set-fallback requires '; + } + } + + const setProfileBackendIdx = args.indexOf('--set-profile-backend'); + if (setProfileBackendIdx !== -1) { + const profile = args[setProfileBackendIdx + 1]; + const backend = args[setProfileBackendIdx + 2]; + if (profile && backend && !profile.startsWith('-') && !backend.startsWith('-')) { + options.setProfileBackend = { profile, backend }; + } else { + options.setProfileBackendError = '--set-profile-backend requires '; + } + } + + const clearProfileBackend = extractOption(args, ['--clear-profile-backend']); + if (clearProfileBackend.found) { + if (clearProfileBackend.value && !clearProfileBackend.value.startsWith('-')) { + options.clearProfileBackend = clearProfileBackend.value; + } else { + options.setProfileBackendError = '--clear-profile-backend requires '; + } + } + return options; } @@ -82,6 +118,13 @@ function showHelp(): void { console.log(` ${color('--disable', 'command')} Disable image analysis`); console.log(` ${color('--timeout ', 'command')} Set analysis timeout (10-600)`); console.log(` ${color('--set-model

', 'command')} Set model for provider`); + console.log(` ${color('--set-fallback ', 'command')} Set fallback backend`); + console.log( + ` ${color('--set-profile-backend

', 'command')} Map a profile alias to a backend` + ); + console.log( + ` ${color('--clear-profile-backend

', 'command')} Remove a saved profile mapping` + ); console.log(` ${color('--help, -h', 'command')} Show this help`); console.log(''); @@ -157,6 +200,15 @@ function showStatus(): void { console.log(subheader('Configuration:')); console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`); console.log(` Section: ${dim('image_analysis')}`); + console.log(` Fallback backend: ${color(config.fallback_backend || 'none', 'command')}`); + const profileBackends = Object.entries(config.profile_backends ?? {}); + if (profileBackends.length > 0) { + console.log(''); + console.log(subheader('Profile Backends:')); + for (const [profile, backend] of profileBackends) { + console.log(` ${color(profile.padEnd(16), 'command')} ${backend}`); + } + } console.log(''); // Troubleshooting hint if disabled @@ -181,6 +233,16 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< process.exit(1); } + if (options.setFallbackError) { + console.error(fail(options.setFallbackError)); + process.exit(1); + } + + if (options.setProfileBackendError) { + console.error(fail(options.setProfileBackendError)); + process.exit(1); + } + // Validate conflicting flags (Edge case #2: --enable + --disable conflict) if (options.enable && options.disable) { console.error(fail('Cannot use --enable and --disable together')); @@ -229,6 +291,51 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< hasChanges = true; } + if (options.setFallback) { + const normalizedBackend = normalizeImageAnalysisBackendId( + options.setFallback, + Object.keys(imageConfig.provider_models) + ); + if (!normalizedBackend) { + console.error(fail(`Invalid fallback backend: ${options.setFallback}`)); + process.exit(1); + } + imageConfig.fallback_backend = normalizedBackend; + hasChanges = true; + } + + if (options.setProfileBackend) { + const profileName = options.setProfileBackend.profile.trim(); + const normalizedBackend = normalizeImageAnalysisBackendId( + options.setProfileBackend.backend, + Object.keys(imageConfig.provider_models) + ); + if (!profileName) { + console.error(fail('Profile name cannot be empty')); + process.exit(1); + } + if (!normalizedBackend) { + console.error(fail(`Invalid backend: ${options.setProfileBackend.backend}`)); + process.exit(1); + } + imageConfig.profile_backends = { + ...(imageConfig.profile_backends ?? {}), + [profileName]: normalizedBackend, + }; + hasChanges = true; + } + + if (options.clearProfileBackend) { + const profileName = options.clearProfileBackend.trim().toLowerCase(); + const nextProfileBackends = Object.fromEntries( + Object.entries(imageConfig.profile_backends ?? {}).filter( + ([name]) => name.trim().toLowerCase() !== profileName + ) + ); + imageConfig.profile_backends = nextProfileBackends; + hasChanges = true; + } + if (hasChanges) { updateUnifiedConfig({ image_analysis: imageConfig }); console.log(ok('Configuration updated')); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 64567307..878dfbd1 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -44,6 +44,7 @@ import { normalizeOfficialChannelIds, resolveLegacyDiscordSelection, } from '../channels/official-channels-runtime'; +import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver'; const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; @@ -556,12 +557,16 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours, }, // Image analysis config - enabled by default for CLIProxy providers - image_analysis: { + image_analysis: canonicalizeImageAnalysisConfig({ enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, provider_models: partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, - }, + fallback_backend: + partial.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend, + profile_backends: + partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends, + }), }; } @@ -1267,12 +1272,16 @@ export function getDashboardAuthConfig(): DashboardAuthConfig { export function getImageAnalysisConfig(): ImageAnalysisConfig { const config = loadOrCreateUnifiedConfig(); - return { + return canonicalizeImageAnalysisConfig({ enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, provider_models: config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, - }; + fallback_backend: + config.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend, + profile_backends: + config.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends, + }); } /** diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index cdfcd639..5651d82d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -759,6 +759,10 @@ export interface ImageAnalysisConfig { timeout: number; /** Provider-to-model mapping for vision analysis */ provider_models: Record; + /** Fallback backend used when a profile does not resolve to a provider-specific backend */ + fallback_backend?: string; + /** Explicit profile-name-to-backend overrides for settings/custom aliases */ + profile_backends?: Record; } /** @@ -780,6 +784,8 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { iflow: 'qwen3-vl-plus', kimi: 'vision-model', }, + fallback_backend: 'gemini', + profile_backends: {}, }; /** diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index d1487cbf..53c40263 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -165,7 +165,10 @@ export async function executeCopilotProfile( // Merge with current environment (global env first, copilot overrides, then hook env vars) const webSearchEnv = getWebSearchHookEnv(); - const imageAnalysisEnv = getImageAnalysisHookEnv('copilot'); + const imageAnalysisEnv = getImageAnalysisHookEnv({ + profileName: 'copilot', + profileType: 'copilot', + }); const env = stripClaudeCodeEnv({ ...process.env, ...globalEnv, diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 0dd9aa14..40835023 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -8,6 +8,11 @@ */ import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + resolveImageAnalysisStatus, + type ImageAnalysisResolutionContext, +} from './image-analysis-backend-resolver'; /** * Serialize provider_models map to env var format: provider:model,provider:model @@ -22,21 +27,30 @@ function serializeProviderModels(providerModels: Record): string * Get image analysis hook environment variables. * These env vars control the hook's behavior via Claude Code hook system. * - * @param provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex') + * @param input - Current runtime context * @returns Environment variables for image analysis hook */ -export function getImageAnalysisHookEnv(provider?: string): Record { +export function getImageAnalysisHookEnv( + input?: string | ImageAnalysisResolutionContext +): Record { const config = getImageAnalysisConfig(); - - // Check if current provider has a vision model configured - const hasVisionModel = provider && config.provider_models[provider]; - const skipImageAnalysis = !config.enabled || !hasVisionModel; + const context = + typeof input === 'string' + ? { + profileName: input, + cliproxyProvider: mapExternalProviderName(input) ?? undefined, + } + : input; + const status = context + ? resolveImageAnalysisStatus(context, config) + : resolveImageAnalysisStatus({ profileName: '' }, config); + const skipImageAnalysis = !status.supported; return { CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0', CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60), CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models), - CCS_CURRENT_PROVIDER: provider || '', + CCS_CURRENT_PROVIDER: status.backendId || '', CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', }; } diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts new file mode 100644 index 00000000..ca19d5f5 --- /dev/null +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -0,0 +1,379 @@ +import { + DEFAULT_IMAGE_ANALYSIS_CONFIG, + type ImageAnalysisConfig, +} from '../../config/unified-config-types'; +import { + getProviderDisplayName, + isCLIProxyProvider, + mapExternalProviderName, +} from '../../cliproxy/provider-capabilities'; +import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer'; +import type { CliproxyBridgeMetadata } from '../../api/services/profile-types'; +import type { Settings } from '../../types/config'; +import type { ProfileType } from '../../types/profile'; + +export type ImageAnalysisResolutionSource = + | 'cliproxy-provider' + | 'cliproxy-variant' + | 'cliproxy-composite' + | 'copilot-alias' + | 'cliproxy-bridge' + | 'profile-backend' + | 'fallback-backend' + | 'disabled' + | 'unsupported-profile' + | 'unresolved' + | 'missing-model'; + +export type ImageAnalysisStatusCode = + | 'active' + | 'mapped' + | 'attention' + | 'disabled' + | 'skipped' + | 'hook-missing'; + +export interface ImageAnalysisResolutionContext { + profileName: string; + profileType?: ProfileType; + settingsPath?: string | null; + cliproxyProvider?: string | null; + isComposite?: boolean; + settings?: Pick | null; + cliproxyBridge?: CliproxyBridgeMetadata | null; + hookInstalled?: boolean; + sharedHookInstalled?: boolean; +} + +export interface ImageAnalysisStatus { + enabled: boolean; + supported: boolean; + status: ImageAnalysisStatusCode; + backendId: string | null; + backendDisplayName: string | null; + model: string | null; + resolutionSource: ImageAnalysisResolutionSource; + reason: string | null; + shouldPersistHook: boolean; + persistencePath: string | null; + runtimePath: string | null; + usesCurrentTarget: boolean | null; + usesCurrentAuthToken: boolean | null; + hookInstalled: boolean | null; + sharedHookInstalled: boolean | null; +} + +function resolveProviderFromBaseUrl(baseUrl: unknown): string | null { + if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) { + return null; + } + + try { + const parsed = new URL(baseUrl); + const extracted = extractProviderFromPathname(parsed.pathname); + return extracted ? mapExternalProviderName(extracted) : null; + } catch { + const extracted = extractProviderFromPathname(baseUrl); + return extracted ? mapExternalProviderName(extracted) : null; + } +} + +function findCaseInsensitiveKey( + entries: Record | undefined, + requestedKey: string +): string | null { + if (!entries) { + return null; + } + + const normalizedRequestedKey = requestedKey.trim().toLowerCase(); + for (const key of Object.keys(entries)) { + if (key.trim().toLowerCase() === normalizedRequestedKey) { + return key; + } + } + + return null; +} + +export function normalizeImageAnalysisBackendId( + value: string | null | undefined, + knownBackends: Iterable = [] +): string | null { + if (!value || value.trim().length === 0) { + return null; + } + + const trimmed = value.trim(); + const canonicalProvider = mapExternalProviderName(trimmed.toLowerCase()); + if (canonicalProvider) { + return canonicalProvider; + } + + const knownBackendList = Array.from(knownBackends); + const exactKey = knownBackendList.find((backend) => backend === trimmed); + if (exactKey) { + return exactKey; + } + + const caseInsensitiveKey = knownBackendList.find( + (backend) => backend.trim().toLowerCase() === trimmed.toLowerCase() + ); + if (caseInsensitiveKey) { + return caseInsensitiveKey; + } + + return trimmed.toLowerCase(); +} + +export function canonicalizeImageAnalysisConfig(config: ImageAnalysisConfig): ImageAnalysisConfig { + const normalizedProviderModels = Object.entries(config.provider_models ?? {}).reduce( + (acc, [backend, model]) => { + const normalizedBackend = normalizeImageAnalysisBackendId( + backend, + Object.keys(DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models) + ); + if (!normalizedBackend || typeof model !== 'string' || model.trim().length === 0) { + return acc; + } + + acc[normalizedBackend] = model.trim(); + return acc; + }, + {} as Record + ); + + const normalizedFallbackBackend = + normalizeImageAnalysisBackendId( + config.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend, + Object.keys(normalizedProviderModels) + ) ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend; + + const normalizedProfileBackends = Object.entries(config.profile_backends ?? {}).reduce( + (acc, [profileName, backend]) => { + const trimmedProfileName = profileName.trim(); + const normalizedBackend = normalizeImageAnalysisBackendId( + backend, + Object.keys(normalizedProviderModels) + ); + if (!trimmedProfileName || !normalizedBackend) { + return acc; + } + + acc[trimmedProfileName] = normalizedBackend; + return acc; + }, + {} as Record + ); + + return { + enabled: config.enabled, + timeout: config.timeout, + provider_models: normalizedProviderModels, + fallback_backend: normalizedFallbackBackend, + profile_backends: normalizedProfileBackends, + }; +} + +function resolveConfiguredProfileBackend( + profileName: string, + config: ImageAnalysisConfig +): string | null { + if (!config.profile_backends) { + return null; + } + + const exactKey = config.profile_backends[profileName]; + if (exactKey) { + return normalizeImageAnalysisBackendId(exactKey, Object.keys(config.provider_models)); + } + + const matchedKey = findCaseInsensitiveKey(config.profile_backends, profileName); + if (!matchedKey) { + return null; + } + + return normalizeImageAnalysisBackendId( + config.profile_backends[matchedKey], + Object.keys(config.provider_models) + ); +} + +function getBackendDisplayName(backendId: string | null): string | null { + if (!backendId) { + return null; + } + + return isCLIProxyProvider(backendId) ? getProviderDisplayName(backendId) : backendId; +} + +function getRuntimePath(backendId: string | null): string | null { + if (!backendId) { + return null; + } + + return `/api/provider/${backendId}`; +} + +function resolveBackend( + context: ImageAnalysisResolutionContext, + config: ImageAnalysisConfig +): Pick { + const { profileName, profileType, cliproxyProvider, isComposite, cliproxyBridge, settings } = + context; + + if (!config.enabled) { + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'disabled', + reason: 'Disabled globally.', + }; + } + + if (profileType === 'default' || profileType === 'account') { + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'unsupported-profile', + reason: 'This profile type is not currently covered by image-analysis runtime.', + }; + } + + if (profileType === 'copilot' || profileName === 'copilot') { + const backendId = normalizeImageAnalysisBackendId('ghcp', Object.keys(config.provider_models)); + return { + backendId, + backendDisplayName: getBackendDisplayName(backendId), + resolutionSource: 'copilot-alias', + reason: null, + }; + } + + const normalizedCliproxyProvider = normalizeImageAnalysisBackendId( + cliproxyProvider, + Object.keys(config.provider_models) + ); + if (normalizedCliproxyProvider) { + return { + backendId: normalizedCliproxyProvider, + backendDisplayName: getBackendDisplayName(normalizedCliproxyProvider), + resolutionSource: + isComposite || profileName.startsWith('composite-') + ? 'cliproxy-composite' + : profileName === normalizedCliproxyProvider + ? 'cliproxy-provider' + : 'cliproxy-variant', + reason: null, + }; + } + + const bridgeBackend = normalizeImageAnalysisBackendId( + cliproxyBridge?.provider ?? + resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL ?? undefined), + Object.keys(config.provider_models) + ); + if (bridgeBackend) { + return { + backendId: bridgeBackend, + backendDisplayName: getBackendDisplayName(bridgeBackend), + resolutionSource: 'cliproxy-bridge', + reason: null, + }; + } + + const mappedBackend = resolveConfiguredProfileBackend(profileName, config); + if (mappedBackend) { + return { + backendId: mappedBackend, + backendDisplayName: getBackendDisplayName(mappedBackend), + resolutionSource: 'profile-backend', + reason: null, + }; + } + + const fallbackBackend = normalizeImageAnalysisBackendId( + config.fallback_backend, + Object.keys(config.provider_models) + ); + if (fallbackBackend) { + return { + backendId: fallbackBackend, + backendDisplayName: getBackendDisplayName(fallbackBackend), + resolutionSource: 'fallback-backend', + reason: null, + }; + } + + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'unresolved', + reason: 'No supported backend could be resolved.', + }; +} + +export function resolveImageAnalysisStatus( + context: ImageAnalysisResolutionContext, + rawConfig: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG +): ImageAnalysisStatus { + const config = canonicalizeImageAnalysisConfig(rawConfig); + const resolution = resolveBackend(context, config); + const model = resolution.backendId + ? (config.provider_models[resolution.backendId] ?? null) + : null; + const shouldPersistHook = + config.enabled && context.profileType !== 'default' && context.profileType !== 'account'; + + let status: ImageAnalysisStatusCode = 'active'; + let reason = resolution.reason; + + if (!config.enabled) { + status = 'disabled'; + reason ??= + 'This profile falls back to native Read because image analysis is turned off in CCS config.'; + } else if (!resolution.backendId) { + status = 'skipped'; + reason ??= 'No supported backend could be resolved.'; + } else if (!model) { + status = 'skipped'; + reason = 'Resolved backend has no image-analysis model configured.'; + } else if ( + shouldPersistHook && + (context.hookInstalled === false || context.sharedHookInstalled === false) + ) { + status = 'hook-missing'; + reason = + context.sharedHookInstalled === false + ? 'Shared image-analysis hook is not installed.' + : 'Profile hook is missing from the persisted settings file.'; + } else if ( + resolution.resolutionSource === 'cliproxy-bridge' && + context.cliproxyBridge && + (!context.cliproxyBridge.usesCurrentTarget || !context.cliproxyBridge.usesCurrentAuthToken) + ) { + status = 'attention'; + reason = + 'Active, but runtime uses the current CLIProxy target instead of the stale route saved in this profile.'; + } else if (resolution.resolutionSource === 'profile-backend') { + status = 'mapped'; + } + + return { + enabled: config.enabled, + supported: Boolean(config.enabled && resolution.backendId && model), + status, + backendId: resolution.backendId, + backendDisplayName: resolution.backendDisplayName, + model, + resolutionSource: resolution.resolutionSource, + reason, + shouldPersistHook, + persistencePath: shouldPersistHook ? `${context.profileName}.settings.json` : null, + runtimePath: getRuntimePath(resolution.backendId), + usesCurrentTarget: context.cliproxyBridge?.usesCurrentTarget ?? null, + usesCurrentAuthToken: context.cliproxyBridge?.usesCurrentAuthToken ?? null, + hookInstalled: context.hookInstalled ?? null, + sharedHookInstalled: context.sharedHookInstalled ?? null, + }; +} diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index 6c3c8316..a2b28978 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -4,7 +4,7 @@ * Injects image analyzer hooks into per-profile settings files. * This replaces the global ~/.claude/settings.json approach. * - * Injects for profiles configured in image_analysis.provider_models. + * Injects for profiles that resolve to a supported image-analysis backend. * * @module utils/hooks/image-analyzer-profile-injector */ @@ -18,9 +18,13 @@ import { } from './image-analyzer-hook-configuration'; import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { getCcsDir } from '../config-manager'; +import { + resolveImageAnalysisStatus, + type ImageAnalysisResolutionContext, +} from './image-analysis-backend-resolver'; -// Valid profile name pattern (alphanumeric, dash, underscore only) -const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/; +// Valid profile name pattern (alphanumeric, dot, dash, underscore only) +const VALID_PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; /** * Get migration marker path (respects CCS_HOME for test isolation) @@ -51,6 +55,39 @@ function hasCcsHook(settings: Record): boolean { }); } +export function getImageAnalysisProfileSettingsPath( + profileName: string, + settingsPath?: string | null +): string { + if (typeof settingsPath === 'string' && settingsPath.trim().length > 0) { + return settingsPath; + } + + return path.join(getCcsDir(), `${profileName}.settings.json`); +} + +export function hasImageAnalysisProfileHook( + profileName: string, + settingsPath?: string | null +): boolean { + if (!VALID_PROFILE_NAME.test(profileName)) { + return false; + } + + const resolvedSettingsPath = getImageAnalysisProfileSettingsPath(profileName, settingsPath); + if (!fs.existsSync(resolvedSettingsPath)) { + return false; + } + + try { + const content = fs.readFileSync(resolvedSettingsPath, 'utf8'); + const settings = JSON.parse(content) as Record; + return hasCcsHook(settings); + } catch { + return false; + } +} + /** * One-time migration marker management */ @@ -79,13 +116,14 @@ function migrateGlobalHook(): void { /** * Ensure image analyzer hook is configured in profile's settings file * - * Only injects for CLIProxy profiles with vision support (agy, gemini). - * - * @param profileName - Name of the profile (e.g., 'agy', 'gemini') + * @param input - Profile name or pre-resolved runtime context * @returns true if hook is configured (existing or newly added) */ -export function ensureProfileHooks(profileName: string): boolean { +export function ensureProfileHooks(input: string | ImageAnalysisResolutionContext): boolean { try { + const context = typeof input === 'string' ? { profileName: input } : input; + const profileName = context.profileName; + // Validate profile name to prevent path traversal if (!VALID_PROFILE_NAME.test(profileName)) { if (process.env.CCS_DEBUG) { @@ -95,16 +133,8 @@ export function ensureProfileHooks(profileName: string): boolean { } const imageConfig = getImageAnalysisConfig(); - - // Only inject for profiles that have a model mapping in provider_models - // This allows dynamic extension without hardcoding profile names - const configuredProviders = Object.keys(imageConfig.provider_models); - if (!configuredProviders.includes(profileName)) { - return false; - } - - // Skip if image analysis is disabled - if (!imageConfig.enabled) { + const status = resolveImageAnalysisStatus(context, imageConfig); + if (!status.supported || !status.shouldPersistHook) { return false; } @@ -119,7 +149,7 @@ export function ensureProfileHooks(profileName: string): boolean { fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 }); } - const settingsPath = path.join(ccsDir, `${profileName}.settings.json`); + const settingsPath = getImageAnalysisProfileSettingsPath(profileName, context.settingsPath); // Read existing settings or create empty let settings: Record = {}; diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 7e05c9d5..0c44e572 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -7,6 +7,13 @@ */ export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env'; +export { + canonicalizeImageAnalysisConfig, + resolveImageAnalysisStatus, + normalizeImageAnalysisBackendId, + type ImageAnalysisResolutionContext, + type ImageAnalysisStatus, +} from './image-analysis-backend-resolver'; export { getImageAnalyzerHookPath, getImageAnalyzerHookConfig, diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 193080b4..ce3803c3 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -4,10 +4,9 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import * as lockfile from 'proper-lockfile'; -import { getCcsDir, loadSettings } from '../../utils/config-manager'; +import { getCcsDir, loadConfigSafe, loadSettings } from '../../utils/config-manager'; import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys'; import { listVariants } from '../../cliproxy/services/variant-service'; import { @@ -21,17 +20,28 @@ import { import { regenerateConfig } from '../../cliproxy/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; import { resolveCliproxyBridgeMetadata } from '../../api/services'; -import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { + getImageAnalysisConfig, + loadOrCreateUnifiedConfig, + mutateUnifiedConfig, +} from '../../config/unified-config-loader'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import type { Settings } from '../../types/config'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { expandPath } from '../../utils/helpers'; import { canonicalizeModelIdForProvider, extractProviderFromPathname, getDeniedModelIdReasonForProvider, } from '../../cliproxy/model-id-normalizer'; import { createRouteErrorHelpers } from './route-helpers'; +import { + getImageAnalysisProfileSettingsPath, + hasImageAnalysisProfileHook, +} from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; +import { resolveImageAnalysisStatus } from '../../utils/hooks'; const router = Router(); const MODEL_ENV_KEYS = [ @@ -94,8 +104,16 @@ function resolveSettingsPath(profileOrVariant: string): string { const variants = listVariants(); const variant = variants[profileOrVariant]; if (variant?.settings) { - // Variant settings path (e.g., ~/.ccs/agy-g3.settings.json) - return resolvePathWithin(resolvedCcsDir, variant.settings.replace(/^~/, os.homedir())); + return path.resolve(expandPath(variant.settings)); + } + + try { + const configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant]; + if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) { + return path.resolve(expandPath(configuredSettingsPath)); + } + } catch { + // Fall back to the conventional ~/.ccs/.settings.json path below. } // Regular profile settings @@ -251,6 +269,40 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting return changed ? next : settings; } +function resolveImageAnalysisStatusForProfile( + profileOrVariant: string, + settings: Settings, + settingsPath: string +) { + const variants = listVariants(); + const variant = variants[profileOrVariant]; + const cliproxyProvider = resolveProviderForProfile(profileOrVariant); + const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); + const status = resolveImageAnalysisStatus( + { + profileName: profileOrVariant, + profileType: cliproxyProvider ? 'cliproxy' : 'settings', + cliproxyProvider, + isComposite: Boolean( + variant && 'type' in variant && (variant as { type?: string }).type === 'composite' + ), + settingsPath, + settings, + cliproxyBridge, + hookInstalled: hasImageAnalysisProfileHook(profileOrVariant, settingsPath), + sharedHookInstalled: hasImageAnalyzerHook(), + }, + getImageAnalysisConfig() + ); + + return { + ...status, + persistencePath: status.shouldPersistHook + ? getImageAnalysisProfileSettingsPath(profileOrVariant, settingsPath) + : null, + }; +} + function writeSettingsAtomically(settingsPath: string, settings: Settings): void { const tempPath = `${settingsPath}.tmp.${process.pid}`; fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); @@ -338,6 +390,7 @@ router.get('/:profile', (req: Request, res: Response): void => { mtime: stat.mtime.getTime(), path: settingsPath, cliproxyBridge: resolveCliproxyBridgeMetadata(settings), + imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -368,6 +421,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { mtime: stat.mtime.getTime(), path: settingsPath, cliproxyBridge: resolveCliproxyBridgeMetadata(settings), + imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); diff --git a/ui/src/components/profiles/editor/image-analysis-status-section.tsx b/ui/src/components/profiles/editor/image-analysis-status-section.tsx new file mode 100644 index 00000000..e807a251 --- /dev/null +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -0,0 +1,173 @@ +import { AlertTriangle, Image as ImageIcon, Route } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { ImageAnalysisStatus } from '@/lib/api-client'; + +interface ImageAnalysisStatusSectionProps { + status?: ImageAnalysisStatus | null; +} + +function getBadge(status: ImageAnalysisStatus | null | undefined): { + label: string; + variant: 'default' | 'secondary' | 'destructive' | 'outline'; +} { + switch (status?.status) { + case 'active': + return { label: 'Ready', variant: 'default' }; + case 'mapped': + return { label: 'Saved mapping', variant: 'secondary' }; + case 'attention': + return { label: 'Needs review', variant: 'outline' }; + case 'disabled': + return { label: 'Disabled', variant: 'outline' }; + case 'hook-missing': + return { label: 'Setup needed', variant: 'destructive' }; + case 'skipped': + return { label: 'Not available', variant: 'outline' }; + default: + return { label: 'Checking', variant: 'outline' }; + } +} + +function getSummary(status: ImageAnalysisStatus): string { + const backendName = status.backendDisplayName || status.backendId || 'this backend'; + + switch (status.status) { + case 'disabled': + return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off."; + case 'mapped': + return `Ready via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config.`; + case 'attention': + return `Ready via ${backendName}, but runtime is using the current CLIProxy route instead of the route saved in this profile.`; + case 'hook-missing': + return `Configured for ${backendName}, but the image-analysis hook is not fully installed yet.`; + case 'skipped': + return status.reason || 'Skipped for this profile.'; + case 'active': + default: + if (status.resolutionSource === 'cliproxy-bridge') { + return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`; + } + + if (status.resolutionSource === 'fallback-backend') { + return `Ready via ${backendName} fallback. Images and PDFs are routed through CLIProxy before Claude sees text.`; + } + + return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`; + } +} + +function getRuntimeLine(status: ImageAnalysisStatus): string { + if (!status.runtimePath) { + return 'Read -> native file access'; + } + + return `Read -> image-analysis hook -> ${status.runtimePath}`; +} + +function getPersistenceLine(status: ImageAnalysisStatus): string { + if (!status.shouldPersistHook || !status.persistencePath) { + return 'Not persisted for this profile type'; + } + + if (status.hookInstalled) { + return `${status.persistencePath} hook`; + } + + return `${status.persistencePath} hook missing`; +} + +export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectionProps) { + if (!status) { + return ( +

+
+
+

Checking backend status...

+
+ ); + } + + const badge = getBadge(status); + const detailLabel = status.supported ? 'Model' : 'Reason'; + const detailValue = status.supported ? status.model : status.reason || 'Unavailable'; + + return ( +
+
+
+
+ +

Image-analysis backend

+
+

+ Derived runtime status. This section is not written into the JSON editor above. +

+
+ + {badge.label} + +
+ +

+ {getSummary(status)} +

+ +
+
+
+ Backend +
+
{status.backendDisplayName || 'Unresolved'}
+
+ +
+
+ Runtime +
+
+ {getRuntimeLine(status)} +
+
+ +
+
+ Persistence +
+
+ {getPersistenceLine(status)} +
+
+ +
+
+ {detailLabel} +
+
+ {detailValue} +
+
+
+ + {(status.status === 'attention' || status.status === 'hook-missing') && ( +
+ + {status.reason} +
+ )} + +
+ + + WebSearch stays managed separately and is not controlled by this backend status. + +
+
+ ); +} diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index f9709845..470de66b 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -254,6 +254,7 @@ export function ProfileEditor({ isRawJsonValid={computedIsRawJsonValid} rawJsonEdits={rawJsonEdits} settings={settings} + imageAnalysisStatus={data?.imageAnalysisStatus} onChange={handleRawJsonChange} missingRequiredFields={missingRequiredFields} /> diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index 686fb2e7..c077ccee 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -6,7 +6,9 @@ import { Suspense, lazy } from 'react'; import { Loader2, X, AlertTriangle } from 'lucide-react'; import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; +import { ImageAnalysisStatusSection } from './image-analysis-status-section'; import type { Settings } from './types'; +import type { ImageAnalysisStatus } from '@/lib/api-client'; // Lazy load CodeEditor const CodeEditor = lazy(() => @@ -18,6 +20,7 @@ interface RawEditorSectionProps { isRawJsonValid: boolean; rawJsonEdits: string | null; settings: Settings | undefined; + imageAnalysisStatus?: ImageAnalysisStatus | null; onChange: (value: string) => void; missingRequiredFields?: string[]; } @@ -27,6 +30,7 @@ export function RawEditorSection({ isRawJsonValid, rawJsonEdits, settings, + imageAnalysisStatus, onChange, missingRequiredFields = [], }: RawEditorSectionProps) { @@ -75,6 +79,9 @@ export function RawEditorSection({ />
+
+ +
{/* Global Env Indicator */}
diff --git a/ui/src/components/profiles/editor/types.ts b/ui/src/components/profiles/editor/types.ts index 9e2365da..e38baa84 100644 --- a/ui/src/components/profiles/editor/types.ts +++ b/ui/src/components/profiles/editor/types.ts @@ -2,7 +2,7 @@ * Types for Profile Editor */ -import type { CliTarget, CliproxyBridgeMetadata } from '@/lib/api-client'; +import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/lib/api-client'; export interface Settings { env?: Record; @@ -14,6 +14,7 @@ export interface SettingsResponse { mtime: number; path: string; cliproxyBridge?: CliproxyBridgeMetadata | null; + imageAnalysisStatus?: ImageAnalysisStatus | null; } export interface ProfileEditorProps { diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 749c63f3..7fef8193 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -111,6 +111,35 @@ export interface CliproxyBridgeMetadata { usesCurrentAuthToken: boolean; } +export interface ImageAnalysisStatus { + enabled: boolean; + supported: boolean; + status: 'active' | 'mapped' | 'attention' | 'disabled' | 'skipped' | 'hook-missing'; + backendId: string | null; + backendDisplayName: string | null; + model: string | null; + resolutionSource: + | 'cliproxy-provider' + | 'cliproxy-variant' + | 'cliproxy-composite' + | 'copilot-alias' + | 'cliproxy-bridge' + | 'profile-backend' + | 'fallback-backend' + | 'disabled' + | 'unsupported-profile' + | 'unresolved' + | 'missing-model'; + reason: string | null; + shouldPersistHook: boolean; + persistencePath: string | null; + runtimePath: string | null; + usesCurrentTarget: boolean | null; + usesCurrentAuthToken: boolean | null; + hookInstalled: boolean | null; + sharedHookInstalled: boolean | null; +} + export interface Profile { name: string; settingsPath: string; From 9277b4a087db20e067b163341b3add43c9c49c29 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 31 Mar 2026 22:54:43 -0400 Subject: [PATCH 05/29] test(image-analysis): cover resolver and dashboard status --- .../image-analysis-backend-resolver.test.ts | 98 +++++++++ ...age-analyzer-profile-hook-injector.test.ts | 70 +++++++ ...tings-routes-image-analysis-status.test.ts | 186 ++++++++++++++++++ .../image-analysis-status-section.test.tsx | 65 ++++++ 4 files changed, 419 insertions(+) create mode 100644 tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts create mode 100644 tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts create mode 100644 tests/unit/web-server/settings-routes-image-analysis-status.test.ts create mode 100644 ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts new file mode 100644 index 00000000..b7146d82 --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'bun:test'; +import { + DEFAULT_IMAGE_ANALYSIS_CONFIG, + type ImageAnalysisConfig, +} from '../../../../src/config/unified-config-types'; +import { + canonicalizeImageAnalysisConfig, + resolveImageAnalysisStatus, +} from '../../../../src/utils/hooks/image-analysis-backend-resolver'; + +describe('image-analysis-backend-resolver', () => { + it('canonicalizes provider aliases in config', () => { + const config = canonicalizeImageAnalysisConfig({ + enabled: true, + timeout: 60, + provider_models: { + copilot: 'claude-haiku-4.5', + gemini: 'gemini-2.5-flash', + }, + fallback_backend: 'Gemini', + profile_backends: { + orq: 'copilot', + }, + }); + + expect(config.provider_models.ghcp).toBe('claude-haiku-4.5'); + expect(config.provider_models.copilot).toBeUndefined(); + expect(config.fallback_backend).toBe('gemini'); + expect(config.profile_backends?.orq).toBe('ghcp'); + }); + + it('resolves copilot to the ghcp backend without a duplicate provider key', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'copilot', + profileType: 'copilot', + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(true); + expect(status.backendId).toBe('ghcp'); + expect(status.model).toBe('claude-haiku-4.5'); + expect(status.resolutionSource).toBe('copilot-alias'); + }); + + it('uses the fallback backend for an unmapped settings profile', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'glm', + profileType: 'settings', + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(true); + expect(status.backendId).toBe('gemini'); + expect(status.resolutionSource).toBe('fallback-backend'); + expect(status.model).toBe('gemini-2.5-flash'); + }); + + it('uses explicit profile_backends overrides for custom aliases', () => { + const config: ImageAnalysisConfig = { + ...DEFAULT_IMAGE_ANALYSIS_CONFIG, + profile_backends: { + orq: 'copilot', + }, + }; + + const status = resolveImageAnalysisStatus( + { + profileName: 'orq', + profileType: 'settings', + }, + config + ); + + expect(status.supported).toBe(true); + expect(status.status).toBe('mapped'); + expect(status.backendId).toBe('ghcp'); + expect(status.resolutionSource).toBe('profile-backend'); + }); + + it('reports hook-missing when the profile should persist a hook but it is absent', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'glm', + profileType: 'settings', + hookInstalled: false, + sharedHookInstalled: true, + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.status).toBe('hook-missing'); + expect(status.reason).toContain('Profile hook is missing'); + }); +}); diff --git a/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts new file mode 100644 index 00000000..96c07f32 --- /dev/null +++ b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + ensureProfileHooks, + getImageAnalysisProfileSettingsPath, + hasImageAnalysisProfileHook, +} from '../../../../src/utils/hooks/image-analyzer-profile-hook-injector'; + +function writeJson(filePath: string, value: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8'); +} + +describe('image-analyzer-profile-hook-injector', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analyzer-profile-hook-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('persists dotted settings profile hooks into the resolved custom settings path', () => { + const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json'); + writeJson(customSettingsPath, { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }, + }); + + const ensured = ensureProfileHooks({ + profileName: 'foo.bar', + profileType: 'settings', + settingsPath: customSettingsPath, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }, + }, + }); + + const defaultSettingsPath = path.join(tempHome, '.ccs', 'foo.bar.settings.json'); + const persisted = JSON.parse(fs.readFileSync(customSettingsPath, 'utf8')) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + + expect(ensured).toBe(true); + expect(getImageAnalysisProfileSettingsPath('foo.bar', customSettingsPath)).toBe( + customSettingsPath + ); + expect(hasImageAnalysisProfileHook('foo.bar', customSettingsPath)).toBe(true); + expect(fs.existsSync(defaultSettingsPath)).toBe(false); + expect(persisted.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true); + }); +}); diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts new file mode 100644 index 00000000..229edb86 --- /dev/null +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -0,0 +1,186 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import settingsRoutes from '../../../src/web-server/routes/settings-routes'; + +function writeJson(filePath: string, value: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n'); +} + +function installSharedHook(tempHome: string): string { + const hookPath = path.join(tempHome, '.ccs', 'hooks', 'image-analyzer-transformer.cjs'); + fs.mkdirSync(path.dirname(hookPath), { recursive: true }); + fs.writeFileSync(hookPath, '#!/usr/bin/env node\n', 'utf8'); + return hookPath; +} + +function writeProfileSettings( + tempHome: string, + profileName: string, + env: Record, + settingsPath = path.join(tempHome, '.ccs', `${profileName}.settings.json`) +): string { + const hookPath = installSharedHook(tempHome); + writeJson(settingsPath, { + env, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [{ type: 'command', command: `node "${hookPath}"`, timeout: 65000 }], + }, + ], + }, + }); + return settingsPath; +} + +describe('settings-routes image-analysis status', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/settings', settingsRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-status-routes-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('returns fallback-backed image analysis status for settings profiles', async () => { + writeProfileSettings(tempHome, 'glm', { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }); + + const response = await fetch(`${baseUrl}/api/settings/glm/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + status: string; + backendId: string | null; + resolutionSource: string; + model: string | null; + persistencePath: string | null; + }; + }; + + expect(body.imageAnalysisStatus.status).toBe('active'); + expect(body.imageAnalysisStatus.backendId).toBe('gemini'); + expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend'); + expect(body.imageAnalysisStatus.model).toBe('gemini-2.5-flash'); + expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json'); + }); + + it('returns explicit mapped status for custom aliases', async () => { + writeJson(path.join(tempHome, '.ccs', 'config.yaml'), { + version: 11, + image_analysis: { + enabled: true, + timeout: 60, + provider_models: { + gemini: 'gemini-2.5-flash', + ghcp: 'claude-haiku-4.5', + }, + profile_backends: { + orq: 'copilot', + }, + }, + }); + writeProfileSettings(tempHome, 'orq', { + ANTHROPIC_BASE_URL: 'https://openrouter.ai/api/v1', + ANTHROPIC_API_KEY: 'orq-test-key', + }); + + const response = await fetch(`${baseUrl}/api/settings/orq/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + status: string; + backendId: string | null; + resolutionSource: string; + model: string | null; + }; + }; + + expect(body.imageAnalysisStatus.status).toBe('mapped'); + expect(body.imageAnalysisStatus.backendId).toBe('ghcp'); + expect(body.imageAnalysisStatus.resolutionSource).toBe('profile-backend'); + expect(body.imageAnalysisStatus.model).toBe('claude-haiku-4.5'); + }); + + it('uses the configured custom settings path for status and persistence diagnostics', async () => { + const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json'); + writeJson(path.join(tempHome, '.ccs', 'config.json'), { + profiles: { + 'foo.bar': customSettingsPath, + }, + }); + writeProfileSettings( + tempHome, + 'foo.bar', + { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }, + customSettingsPath + ); + + const response = await fetch(`${baseUrl}/api/settings/foo.bar/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + path: string; + imageAnalysisStatus: { + persistencePath: string | null; + hookInstalled: boolean | null; + }; + }; + + expect(body.path).toBe(customSettingsPath); + expect(body.imageAnalysisStatus.persistencePath).toBe(customSettingsPath); + expect(body.imageAnalysisStatus.hookInstalled).toBe(true); + }); +}); diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx new file mode 100644 index 00000000..dfb07934 --- /dev/null +++ b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ImageAnalysisStatusSection } from '@/components/profiles/editor/image-analysis-status-section'; +import type { ImageAnalysisStatus } from '@/lib/api-client'; + +function createStatus(overrides: Partial = {}): ImageAnalysisStatus { + return { + enabled: true, + supported: true, + status: 'active', + backendId: 'gemini', + backendDisplayName: 'Google Gemini', + model: 'gemini-2.5-flash', + resolutionSource: 'cliproxy-bridge', + reason: null, + shouldPersistHook: true, + persistencePath: '/tmp/.ccs/glm.settings.json', + runtimePath: '/api/provider/gemini', + usesCurrentTarget: true, + usesCurrentAuthToken: true, + hookInstalled: true, + sharedHookInstalled: true, + ...overrides, + }; +} + +describe('ImageAnalysisStatusSection', () => { + it('renders active bridge diagnostics near the raw editor footer stack', () => { + render(); + + expect(screen.getByText('Image-analysis backend')).toBeInTheDocument(); + expect( + screen.getByText( + /Derived runtime status\. This section is not written into the JSON editor above\./i + ) + ).toBeInTheDocument(); + expect(screen.getByText('Ready')).toBeInTheDocument(); + expect(screen.getByText(/Ready via Google Gemini\./i)).toBeInTheDocument(); + expect(screen.getByText('Google Gemini')).toBeInTheDocument(); + expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument(); + expect(screen.getByText('gemini-2.5-flash')).toBeInTheDocument(); + }); + + it('renders mapped status and the explicit mapping explanation', () => { + render( + + ); + + expect(screen.getByText('Saved mapping')).toBeInTheDocument(); + expect( + screen.getByText(/Ready via saved GitHub Copilot \(OAuth\) mapping/i) + ).toBeInTheDocument(); + expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); + expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument(); + }); +}); From d40cc60a1da520e9591803e07f26864013ada730 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 31 Mar 2026 22:55:16 -0400 Subject: [PATCH 06/29] docs(readme): document image-analysis backend visibility --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a75d3d6f..85360515 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ The dashboard provides visual management for all account types: > **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, provision `ccs-websearch.WebSearch` when the managed runtime is available, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed. > Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly. +> **Image-analysis backend visibility:** `ccs config image-analysis --set-fallback ` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend ` and `--clear-profile-backend ` for explicit per-profile mappings. In the dashboard raw JSON editor, CCS now shows a derived `Image-analysis backend` status block below the JSON viewer so you can see the active backend, whether the hook is persisted, and whether the result is runtime-derived rather than saved in the profile JSON. + > **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. **llama.cpp Integration**: Run a local llama.cpp OpenAI-compatible server and create a profile with `ccs api create --preset llamacpp`. CCS defaults to `http://127.0.0.1:8080`, matching the standard llama.cpp server port. From 665668579d39ab5d092ec6aaabca73dec9b9e856 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 31 Mar 2026 23:01:44 -0400 Subject: [PATCH 07/29] fix(image-analysis): reject unknown backend mappings --- src/commands/config-image-analysis-command.ts | 11 +++- .../config-image-analysis-command.test.ts | 59 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index 0301cfc6..ba9f855d 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -40,6 +40,13 @@ const IMAGE_ANALYSIS_PROVIDER_ALIASES = Object.freeze( ) ); +function isConfiguredImageAnalysisBackend( + backend: string | null, + providerModels: Record +): backend is string { + return Boolean(backend && Object.prototype.hasOwnProperty.call(providerModels, backend)); +} + function parseArgs(args: string[]): ImageAnalysisCommandOptions { const options: ImageAnalysisCommandOptions = { enable: hasAnyFlag(args, ['--enable']), @@ -296,7 +303,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< options.setFallback, Object.keys(imageConfig.provider_models) ); - if (!normalizedBackend) { + if (!isConfiguredImageAnalysisBackend(normalizedBackend, imageConfig.provider_models)) { console.error(fail(`Invalid fallback backend: ${options.setFallback}`)); process.exit(1); } @@ -314,7 +321,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< console.error(fail('Profile name cannot be empty')); process.exit(1); } - if (!normalizedBackend) { + if (!isConfiguredImageAnalysisBackend(normalizedBackend, imageConfig.provider_models)) { console.error(fail(`Invalid backend: ${options.setProfileBackend.backend}`)); process.exit(1); } diff --git a/tests/unit/commands/config-image-analysis-command.test.ts b/tests/unit/commands/config-image-analysis-command.test.ts index 6a7f9134..3215964e 100644 --- a/tests/unit/commands/config-image-analysis-command.test.ts +++ b/tests/unit/commands/config-image-analysis-command.test.ts @@ -4,7 +4,7 @@ * Unit tests for ccs config image-analysis subcommand. */ -import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -33,6 +33,13 @@ function createConfigYaml(content: string): void { fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8'); } +async function loadHandleConfigImageAnalysisCommand() { + const mod = await import( + `../../../src/commands/config-image-analysis-command?test=${Date.now()}-${Math.random()}` + ); + return mod.handleConfigImageAnalysisCommand; +} + describe('config image-analysis command', () => { describe('config file parsing', () => { it('should parse enabled status from config.yaml', () => { @@ -152,6 +159,56 @@ image_analysis: expect(validProviders.includes(provider)).toBe(false); } }); + + it('rejects invalid fallback backends that are not configured', async () => { + const handleConfigImageAnalysisCommand = await loadHandleConfigImageAnalysisCommand(); + const originalProcessExit = process.exit; + + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + + try { + await expect( + handleConfigImageAnalysisCommand(['--set-fallback', 'unknown-provider']) + ).rejects.toThrow('process.exit(1)'); + } finally { + process.exit = originalProcessExit; + } + + const configPath = path.join(testDir, 'config.yaml'); + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).not.toContain('fallback_backend: unknown-provider'); + } else { + expect(fs.existsSync(configPath)).toBe(false); + } + }); + + it('rejects invalid profile backend mappings that are not configured', async () => { + const handleConfigImageAnalysisCommand = await loadHandleConfigImageAnalysisCommand(); + const originalProcessExit = process.exit; + + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + + try { + await expect( + handleConfigImageAnalysisCommand(['--set-profile-backend', 'orq', 'unknown-provider']) + ).rejects.toThrow('process.exit(1)'); + } finally { + process.exit = originalProcessExit; + } + + const configPath = path.join(testDir, 'config.yaml'); + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).not.toContain('unknown-provider'); + } else { + expect(fs.existsSync(configPath)).toBe(false); + } + }); }); describe('default configuration', () => { From d394772f7cf8acd2fb2051bda5aec8bd2622c141 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 31 Mar 2026 23:18:13 -0400 Subject: [PATCH 08/29] fix(image-analysis): preview backend status safely --- README.md | 2 +- src/ccs.ts | 45 +++- .../hooks/image-analysis-backend-resolver.ts | 28 ++- src/web-server/routes/settings-routes.ts | 30 +++ .../image-analysis-backend-resolver.test.ts | 35 ++- ...tings-routes-image-analysis-status.test.ts | 58 +++++ .../editor/image-analysis-status-section.tsx | 47 +++- ui/src/components/profiles/editor/index.tsx | 63 ++++- .../profiles/editor/raw-editor-section.tsx | 10 +- .../image-analysis-status-section.test.tsx | 215 +++++++++++++++++- 10 files changed, 507 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 85360515..5841ac4b 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ The dashboard provides visual management for all account types: > **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, provision `ccs-websearch.WebSearch` when the managed runtime is available, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed. > Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly. -> **Image-analysis backend visibility:** `ccs config image-analysis --set-fallback ` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend ` and `--clear-profile-backend ` for explicit per-profile mappings. In the dashboard raw JSON editor, CCS now shows a derived `Image-analysis backend` status block below the JSON viewer so you can see the active backend, whether the hook is persisted, and whether the result is runtime-derived rather than saved in the profile JSON. +> **Image-analysis backend visibility:** `ccs config image-analysis --set-fallback ` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend ` and `--clear-profile-backend ` for explicit per-profile mappings. In the dashboard raw JSON editor, CCS now shows a derived `Image-analysis backend` status block below the JSON viewer so you can see the configured backend, whether the hook is persisted, and whether the result is runtime-derived rather than saved in the profile JSON. > **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration. diff --git a/src/ccs.ts b/src/ccs.ts index cd0b6ca1..5a282bd0 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1013,12 +1013,55 @@ async function main(): Promise { } const webSearchEnv = getWebSearchHookEnv(); - const imageAnalysisEnv = getImageAnalysisHookEnv({ + let imageAnalysisEnv = getImageAnalysisHookEnv({ profileName: profileInfo.name, profileType: profileInfo.type, settings, cliproxyBridge, }); + + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; + if ( + resolvedTarget === 'claude' && + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider + ) { + const verboseProxyLaunch = + remainingArgs.includes('--verbose') || + remainingArgs.includes('-v') || + targetRemainingArgs.includes('--verbose') || + targetRemainingArgs.includes('-v'); + + if (!isAuthenticated(imageAnalysisProvider as CLIProxyProvider)) { + console.error( + info( + `Image analysis via ${imageAnalysisProvider} is configured, but CLIProxy auth is missing. This session will use native Read. Run "ccs ${imageAnalysisProvider} --auth" to enable it.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } else { + const ensureServiceResult = await ensureCliproxyService( + CLIPROXY_DEFAULT_PORT, + verboseProxyLaunch + ); + if (!ensureServiceResult.started) { + console.error( + warn( + `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + } + } // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index ca19d5f5..6dcfc8a3 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -292,6 +292,17 @@ function resolveBackend( }; } + const hasDirectAnthropicApiKey = Boolean(settings?.env?.ANTHROPIC_API_KEY?.trim()); + const hasBaseUrl = Boolean(settings?.env?.ANTHROPIC_BASE_URL?.trim()); + if (hasDirectAnthropicApiKey && !hasBaseUrl) { + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'unresolved', + reason: 'Direct Anthropic settings profiles use native file access unless explicitly mapped.', + }; + } + const fallbackBackend = normalizeImageAnalysisBackendId( config.fallback_backend, Object.keys(config.provider_models) @@ -323,7 +334,10 @@ export function resolveImageAnalysisStatus( ? (config.provider_models[resolution.backendId] ?? null) : null; const shouldPersistHook = - config.enabled && context.profileType !== 'default' && context.profileType !== 'account'; + config.enabled && + context.profileType !== 'default' && + context.profileType !== 'account' && + Boolean(resolution.backendId && model); let status: ImageAnalysisStatusCode = 'active'; let reason = resolution.reason; @@ -353,8 +367,16 @@ export function resolveImageAnalysisStatus( (!context.cliproxyBridge.usesCurrentTarget || !context.cliproxyBridge.usesCurrentAuthToken) ) { status = 'attention'; - reason = - 'Active, but runtime uses the current CLIProxy target instead of the stale route saved in this profile.'; + if (!context.cliproxyBridge.usesCurrentTarget && !context.cliproxyBridge.usesCurrentAuthToken) { + reason = + 'Runtime uses the current CLIProxy route and auth token instead of the saved values in this profile.'; + } else if (!context.cliproxyBridge.usesCurrentTarget) { + reason = + 'Runtime uses the current CLIProxy route instead of the saved route in this profile.'; + } else { + reason = + 'Runtime uses the current CLIProxy auth token instead of the saved token in this profile.'; + } } else if (resolution.resolutionSource === 'profile-backend') { status = 'mapped'; } diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index ce3803c3..576fe3ad 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -303,6 +303,13 @@ function resolveImageAnalysisStatusForProfile( }; } +function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) { + const normalizedSettings = canonicalizeProfileSettings(profileOrVariant, settings); + const settingsPath = resolveSettingsPath(profileOrVariant); + + return resolveImageAnalysisStatusForProfile(profileOrVariant, normalizedSettings, settingsPath); +} + function writeSettingsAtomically(settingsPath: string, settings: Settings): void { const tempPath = `${settingsPath}.tmp.${process.pid}`; fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); @@ -428,6 +435,29 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { } }); +/** + * POST /api/settings/:profile/image-analysis-status - Preview image analysis status from editor JSON + */ +router.post('/:profile/image-analysis-status', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + + try { + const { profile } = req.params; + const { settings } = req.body; + + if (!settings || typeof settings !== 'object') { + res.status(400).json({ error: 'settings object is required in request body' }); + return; + } + + res.json({ + imageAnalysisStatus: resolvePreviewImageAnalysisStatus(profile, settings as Settings), + }); + } catch (error) { + respondInternalError(res, error, 'Internal server error.'); + } +}); + /** Required env vars for CLIProxy providers to function */ const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts index b7146d82..0f49d6b6 100644 --- a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -44,11 +44,17 @@ describe('image-analysis-backend-resolver', () => { expect(status.resolutionSource).toBe('copilot-alias'); }); - it('uses the fallback backend for an unmapped settings profile', () => { + it('uses the fallback backend for an unmapped third-party settings profile', () => { const status = resolveImageAnalysisStatus( { profileName: 'glm', profileType: 'settings', + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_AUTH_TOKEN: 'glm-test-key', + }, + }, }, DEFAULT_IMAGE_ANALYSIS_CONFIG ); @@ -59,6 +65,27 @@ describe('image-analysis-backend-resolver', () => { expect(status.model).toBe('gemini-2.5-flash'); }); + it('keeps direct Anthropic settings profiles on native read unless explicitly mapped', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'claude-direct', + profileType: 'settings', + settings: { + env: { + ANTHROPIC_API_KEY: 'anthropic-test-key', + }, + }, + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(false); + expect(status.backendId).toBeNull(); + expect(status.status).toBe('skipped'); + expect(status.shouldPersistHook).toBe(false); + expect(status.reason).toContain('native file access'); + }); + it('uses explicit profile_backends overrides for custom aliases', () => { const config: ImageAnalysisConfig = { ...DEFAULT_IMAGE_ANALYSIS_CONFIG, @@ -86,6 +113,12 @@ describe('image-analysis-backend-resolver', () => { { profileName: 'glm', profileType: 'settings', + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_AUTH_TOKEN: 'glm-test-key', + }, + }, hookInstalled: false, sharedHookInstalled: true, }, diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts index 229edb86..cae53e5e 100644 --- a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -113,6 +113,33 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json'); }); + it('keeps direct Anthropic settings profiles on native read diagnostics', async () => { + writeJson(path.join(tempHome, '.ccs', 'claude-direct.settings.json'), { + env: { + ANTHROPIC_API_KEY: 'anthropic-test-key', + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/claude-direct/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + status: string; + backendId: string | null; + shouldPersistHook: boolean; + runtimePath: string | null; + reason: string | null; + }; + }; + + expect(body.imageAnalysisStatus.status).toBe('skipped'); + expect(body.imageAnalysisStatus.backendId).toBeNull(); + expect(body.imageAnalysisStatus.shouldPersistHook).toBe(false); + expect(body.imageAnalysisStatus.runtimePath).toBeNull(); + expect(body.imageAnalysisStatus.reason).toContain('native file access'); + }); + it('returns explicit mapped status for custom aliases', async () => { writeJson(path.join(tempHome, '.ccs', 'config.yaml'), { version: 11, @@ -183,4 +210,35 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.persistencePath).toBe(customSettingsPath); expect(body.imageAnalysisStatus.hookInstalled).toBe(true); }); + + it('previews image-analysis status from unsaved editor settings', async () => { + writeProfileSettings(tempHome, 'glm', { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }); + + const response = await fetch(`${baseUrl}/api/settings/glm/image-analysis-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp', + ANTHROPIC_AUTH_TOKEN: 'preview-token', + }, + }, + }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + backendId: string | null; + resolutionSource: string; + }; + }; + + expect(body.imageAnalysisStatus.backendId).toBe('ghcp'); + expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge'); + }); }); diff --git a/ui/src/components/profiles/editor/image-analysis-status-section.tsx b/ui/src/components/profiles/editor/image-analysis-status-section.tsx index e807a251..ab9aa0ef 100644 --- a/ui/src/components/profiles/editor/image-analysis-status-section.tsx +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -5,6 +5,8 @@ import type { ImageAnalysisStatus } from '@/lib/api-client'; interface ImageAnalysisStatusSectionProps { status?: ImageAnalysisStatus | null; + source?: 'saved' | 'editor'; + previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; } function getBadge(status: ImageAnalysisStatus | null | undefined): { @@ -13,7 +15,7 @@ function getBadge(status: ImageAnalysisStatus | null | undefined): { } { switch (status?.status) { case 'active': - return { label: 'Ready', variant: 'default' }; + return { label: 'Configured', variant: 'default' }; case 'mapped': return { label: 'Saved mapping', variant: 'secondary' }; case 'attention': @@ -36,28 +38,32 @@ function getSummary(status: ImageAnalysisStatus): string { case 'disabled': return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off."; case 'mapped': - return `Ready via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config.`; + return `Configured via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config when image analysis is available for the session.`; case 'attention': - return `Ready via ${backendName}, but runtime is using the current CLIProxy route instead of the route saved in this profile.`; + return `Configured via ${backendName}, but ${status.reason || 'runtime details no longer match the saved profile state.'}`; case 'hook-missing': - return `Configured for ${backendName}, but the image-analysis hook is not fully installed yet.`; + return `Configured for ${backendName}, but ${status.reason || 'the image-analysis hook is not fully installed yet.'}`; case 'skipped': return status.reason || 'Skipped for this profile.'; case 'active': default: if (status.resolutionSource === 'cliproxy-bridge') { - return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`; + return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`; } if (status.resolutionSource === 'fallback-backend') { - return `Ready via ${backendName} fallback. Images and PDFs are routed through CLIProxy before Claude sees text.`; + return `Configured via ${backendName} fallback. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`; } - return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`; + return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`; } } function getRuntimeLine(status: ImageAnalysisStatus): string { + if (status.status === 'hook-missing') { + return 'Read -> native file access (hook install required)'; + } + if (!status.runtimePath) { return 'Read -> native file access'; } @@ -65,6 +71,25 @@ function getRuntimeLine(status: ImageAnalysisStatus): string { return `Read -> image-analysis hook -> ${status.runtimePath}`; } +function getStatusContext( + source: 'saved' | 'editor', + previewState: ImageAnalysisStatusSectionProps['previewState'] +): string { + if (previewState === 'invalid') { + return 'Showing last saved runtime status. The live preview resumes when the JSON above is valid again.'; + } + + if (previewState === 'refreshing') { + return 'Refreshing the live preview from the current editor state.'; + } + + if (source === 'editor') { + return 'Live preview from the current editor state. Save to persist these backend and hook changes.'; + } + + return 'Saved runtime status for this profile. This section is derived and is not written into the JSON editor above.'; +} + function getPersistenceLine(status: ImageAnalysisStatus): string { if (!status.shouldPersistHook || !status.persistencePath) { return 'Not persisted for this profile type'; @@ -77,7 +102,11 @@ function getPersistenceLine(status: ImageAnalysisStatus): string { return `${status.persistencePath} hook missing`; } -export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectionProps) { +export function ImageAnalysisStatusSection({ + status, + source = 'saved', + previewState = 'saved', +}: ImageAnalysisStatusSectionProps) { if (!status) { return (
@@ -101,7 +130,7 @@ export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectio

Image-analysis backend

- Derived runtime status. This section is not written into the JSON editor above. + {getStatusContext(source, previewState)}

diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index 470de66b..bb4242a2 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -4,7 +4,7 @@ */ /* eslint-disable react-refresh/only-export-components */ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useDeferredValue } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; @@ -107,6 +107,63 @@ export function ProfileEditor({ return Object.keys(localEdits).length > 0; }, [rawJsonEdits, localEdits, settings]); + const deferredPreviewJson = useDeferredValue(computedRawJsonContent); + const previewSettings = useMemo((): Settings | null => { + if (!computedHasChanges || !computedIsRawJsonValid) { + return null; + } + + try { + return JSON.parse(deferredPreviewJson) as Settings; + } catch { + return null; + } + }, [computedHasChanges, computedIsRawJsonValid, deferredPreviewJson]); + + const { + data: previewStatusResponse, + isFetching: isPreviewStatusFetching, + isError: isPreviewStatusError, + } = useQuery<{ imageAnalysisStatus: SettingsResponse['imageAnalysisStatus'] }>({ + queryKey: ['settings', profileName, 'image-analysis-status-preview', deferredPreviewJson], + enabled: previewSettings !== null, + placeholderData: (previousData) => previousData, + queryFn: async () => { + const res = await fetch(`/api/settings/${profileName}/image-analysis-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: previewSettings }), + }); + + if (!res.ok) { + throw new Error(`Failed to preview image-analysis status: ${res.status}`); + } + + return res.json(); + }, + }); + + const imageAnalysisStatus = + computedHasChanges && computedIsRawJsonValid && !isPreviewStatusError + ? (previewStatusResponse?.imageAnalysisStatus ?? data?.imageAnalysisStatus) + : data?.imageAnalysisStatus; + const imageAnalysisStatusSource = + computedHasChanges && + computedIsRawJsonValid && + !isPreviewStatusError && + previewStatusResponse?.imageAnalysisStatus + ? 'editor' + : 'saved'; + const imageAnalysisStatusPreviewState = !computedHasChanges + ? 'saved' + : !computedIsRawJsonValid + ? 'invalid' + : isPreviewStatusError + ? 'saved' + : isPreviewStatusFetching && !previewStatusResponse?.imageAnalysisStatus + ? 'refreshing' + : 'preview'; + // Check for missing required fields (informational warning) const missingRequiredFields = useMemo(() => { const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; @@ -254,7 +311,9 @@ export function ProfileEditor({ isRawJsonValid={computedIsRawJsonValid} rawJsonEdits={rawJsonEdits} settings={settings} - imageAnalysisStatus={data?.imageAnalysisStatus} + imageAnalysisStatus={imageAnalysisStatus} + imageAnalysisStatusSource={imageAnalysisStatusSource} + imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState} onChange={handleRawJsonChange} missingRequiredFields={missingRequiredFields} /> diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index c077ccee..45902541 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -21,6 +21,8 @@ interface RawEditorSectionProps { rawJsonEdits: string | null; settings: Settings | undefined; imageAnalysisStatus?: ImageAnalysisStatus | null; + imageAnalysisStatusSource?: 'saved' | 'editor'; + imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; onChange: (value: string) => void; missingRequiredFields?: string[]; } @@ -31,6 +33,8 @@ export function RawEditorSection({ rawJsonEdits, settings, imageAnalysisStatus, + imageAnalysisStatusSource = 'saved', + imageAnalysisStatusPreviewState = 'saved', onChange, missingRequiredFields = [], }: RawEditorSectionProps) { @@ -80,7 +84,11 @@ export function RawEditorSection({
- +
{/* Global Env Indicator */}
diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx index dfb07934..87b91b4f 100644 --- a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx +++ b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx @@ -1,8 +1,36 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { ImageAnalysisStatusSection } from '@/components/profiles/editor/image-analysis-status-section'; +import { fireEvent, render, screen, waitFor } from '@tests/setup/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ImageAnalysisStatus } from '@/lib/api-client'; +vi.mock('@/components/shared/code-editor', () => ({ + CodeEditor: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( +