diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts new file mode 100644 index 00000000..f6f4d942 --- /dev/null +++ b/src/web-server/routes/image-analysis-routes.ts @@ -0,0 +1,421 @@ +import { Router, type Request, type Response } from 'express'; +import * as fs from 'fs'; +import { getImageAnalysisConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { + CLIPROXY_PROVIDER_IDS, + getProviderDisplayName, + mapExternalProviderName, +} from '../../cliproxy/provider-capabilities'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import { listApiProfiles, resolveCliproxyBridgeMetadata } from '../../api/services'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { expandPath } from '../../utils/helpers'; +import { loadSettings } from '../../utils/config-manager'; +import type { Settings } from '../../types/config'; +import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer'; +import { + normalizeImageAnalysisBackendId, + resolveImageAnalysisRuntimeStatus, +} from '../../utils/hooks'; +import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; +import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; + +const router = Router(); +const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR = + 'Image Analysis endpoints require localhost access when dashboard auth is disabled.'; + +type DashboardTarget = 'claude' | 'droid' | 'codex'; +type DashboardSummaryState = 'ready' | 'partial' | 'needs_setup' | 'disabled'; +type BackendState = 'ready' | 'starts_on_launch' | 'needs_auth' | 'needs_proxy' | 'review'; +type CurrentTargetMode = 'active' | 'bypassed' | 'fallback' | 'setup' | 'disabled' | 'unresolved'; + +interface ImageAnalysisRouteBody { + enabled?: boolean; + timeout?: number; + providerModels?: Record; + fallbackBackend?: string | null; + profileBackends?: Record; +} + +function safeLoadSettings(settingsPath: string | null): Settings | null { + if (!settingsPath) return null; + + try { + const expandedPath = expandPath(settingsPath); + if (!fs.existsSync(expandedPath)) return null; + return loadSettings(expandedPath); + } catch { + return null; + } +} + +function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | 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 resolveTarget(target: unknown): DashboardTarget { + if (target === 'droid' || target === 'codex') return target; + return 'claude'; +} + +function resolveCurrentTargetMode( + target: DashboardTarget, + status: Awaited> +): CurrentTargetMode { + if (!status.enabled) return 'disabled'; + if (!status.backendId) return 'unresolved'; + if (target !== 'claude') return 'bypassed'; + if (status.status === 'hook-missing') return 'setup'; + if (status.effectiveRuntimeMode === 'native-read') return 'fallback'; + return 'active'; +} + +function resolveBackendState( + status: Awaited> +): BackendState { + if (status.authReadiness === 'missing') return 'needs_auth'; + if (status.proxyReadiness === 'unavailable') return 'needs_proxy'; + if (status.proxyReadiness === 'stopped') return 'starts_on_launch'; + if (status.effectiveRuntimeMode === 'native-read' || status.status === 'attention') + return 'review'; + return 'ready'; +} + +function getKnownBackends(): string[] { + return Array.from(new Set(CLIPROXY_PROVIDER_IDS)).sort((left, right) => + left.localeCompare(right) + ); +} + +async function buildDashboardPayload() { + const config = getImageAnalysisConfig(); + const { profiles, variants } = listApiProfiles(); + const sharedHookInstalled = hasImageAnalyzerHook(); + + const profileRows = await Promise.all( + profiles.map(async (profile) => { + const settingsPath = profile.settingsPath || null; + const settings = safeLoadSettings(settingsPath); + const cliproxyProvider = + mapExternalProviderName(profile.name) ?? + resolveCliproxyBridgeMetadata(settings ?? undefined)?.provider ?? + resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL); + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: profile.name, + profileType: 'settings', + cliproxyProvider, + settingsPath, + settings, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings ?? undefined), + hookInstalled: settingsPath + ? hasImageAnalysisProfileHook(profile.name, settingsPath) + : undefined, + sharedHookInstalled, + }, + config + ); + + return { + name: profile.name, + kind: 'profile' as const, + target: resolveTarget(profile.target), + configured: profile.isConfigured, + settingsPath, + backendId: status.backendId, + backendDisplayName: status.backendDisplayName, + resolutionSource: status.resolutionSource, + status: status.status, + effectiveRuntimeMode: status.effectiveRuntimeMode, + effectiveRuntimeReason: status.effectiveRuntimeReason, + currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status), + }; + }) + ); + + const variantRows = await Promise.all( + variants.map(async (variant) => { + const settingsPath = + typeof variant.settings === 'string' && variant.settings !== '-' ? variant.settings : null; + const settings = safeLoadSettings(settingsPath); + const cliproxyProvider = mapExternalProviderName(variant.provider); + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: variant.name, + profileType: 'cliproxy', + cliproxyProvider, + isComposite: variant.provider === 'composite', + settingsPath, + settings, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings ?? undefined), + hookInstalled: settingsPath + ? hasImageAnalysisProfileHook(variant.name, settingsPath) + : undefined, + sharedHookInstalled, + }, + config + ); + + return { + name: variant.name, + kind: 'variant' as const, + target: resolveTarget(variant.target), + configured: true, + settingsPath, + backendId: status.backendId, + backendDisplayName: status.backendDisplayName, + resolutionSource: status.resolutionSource, + status: status.status, + effectiveRuntimeMode: status.effectiveRuntimeMode, + effectiveRuntimeReason: status.effectiveRuntimeReason, + currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status), + }; + }) + ); + + const allProfileRows = [...profileRows, ...variantRows].sort((left, right) => + left.name.localeCompare(right.name) + ); + + const backendRows = await Promise.all( + Object.entries(config.provider_models) + .sort(([left], [right]) => left.localeCompare(right)) + .map(async ([backendId, model]) => { + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: backendId, + profileType: 'cliproxy', + cliproxyProvider: mapExternalProviderName(backendId), + hookInstalled: true, + sharedHookInstalled: true, + }, + config + ); + + return { + backendId, + displayName: getProviderDisplayName(backendId as CLIProxyProvider), + model, + state: resolveBackendState(status), + authReadiness: status.authReadiness, + authReason: status.authReason, + proxyReadiness: status.proxyReadiness, + proxyReason: status.proxyReason, + profilesUsing: allProfileRows.filter((profile) => profile.backendId === backendId).length, + }; + }) + ); + + const activeProfileCount = allProfileRows.filter( + (row) => row.currentTargetMode === 'active' + ).length; + const bypassedProfileCount = allProfileRows.filter( + (row) => row.currentTargetMode === 'bypassed' + ).length; + const mappedProfileCount = allProfileRows.filter( + (row) => row.resolutionSource === 'profile-backend' + ).length; + const blockerCount = backendRows.filter( + (row) => row.state === 'needs_auth' || row.state === 'needs_proxy' || row.state === 'review' + ).length; + + let summaryState: DashboardSummaryState = 'ready'; + let title = 'Ready'; + let detail = `${activeProfileCount} profile${activeProfileCount === 1 ? '' : 's'} can use Image Analysis on the current Claude target path.`; + + if (!config.enabled) { + summaryState = 'disabled'; + title = 'Disabled'; + detail = + 'Image Analysis is turned off globally. Images and PDFs fall back to native file access.'; + } else if (backendRows.length === 0) { + summaryState = 'needs_setup'; + title = 'Needs provider models'; + detail = 'Add at least one provider model before turning Image Analysis on for profiles.'; + } else if (blockerCount > 0) { + summaryState = activeProfileCount > 0 ? 'partial' : 'needs_setup'; + title = activeProfileCount > 0 ? 'Partially ready' : 'Needs setup'; + detail = `${blockerCount} backend${blockerCount === 1 ? '' : 's'} still need auth, runtime, or review before every profile path is healthy.`; + } + + return { + config: { + enabled: config.enabled, + timeout: config.timeout, + providerModels: config.provider_models, + fallbackBackend: config.fallback_backend ?? null, + profileBackends: config.profile_backends ?? {}, + }, + summary: { + state: summaryState, + title, + detail, + backendCount: backendRows.length, + mappedProfileCount, + activeProfileCount, + bypassedProfileCount, + }, + backends: backendRows, + profiles: allProfileRows, + catalog: { + knownBackends: getKnownBackends(), + profileNames: allProfileRows.map((row) => row.name), + }, + }; +} + +router.use((req: Request, res: Response, next) => { + if (requireLocalAccessWhenAuthDisabled(req, res, IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR)) { + next(); + } +}); + +router.get('/', async (_req: Request, res: Response): Promise => { + try { + res.json(await buildDashboardPayload()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.put('/', async (req: Request, res: Response): Promise => { + const body = req.body as ImageAnalysisRouteBody; + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + res.status(400).json({ error: 'Invalid request body. Must be an object.' }); + return; + } + + if (body.enabled !== undefined && typeof body.enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' }); + return; + } + + if (body.timeout !== undefined) { + if (!Number.isInteger(body.timeout) || body.timeout < 10 || body.timeout > 600) { + res.status(400).json({ error: 'Timeout must be an integer between 10 and 600 seconds.' }); + return; + } + } + + if ( + body.providerModels !== undefined && + (body.providerModels === null || + Array.isArray(body.providerModels) || + typeof body.providerModels !== 'object') + ) { + res.status(400).json({ error: 'Invalid value for providerModels. Must be an object.' }); + return; + } + + if ( + body.profileBackends !== undefined && + (body.profileBackends === null || + Array.isArray(body.profileBackends) || + typeof body.profileBackends !== 'object') + ) { + res.status(400).json({ error: 'Invalid value for profileBackends. Must be an object.' }); + return; + } + + if ( + body.fallbackBackend !== undefined && + body.fallbackBackend !== null && + typeof body.fallbackBackend !== 'string' + ) { + res.status(400).json({ error: 'Invalid value for fallbackBackend. Must be a string or null.' }); + return; + } + + try { + const currentConfig = getImageAnalysisConfig(); + const knownBackends = new Set([ + ...getKnownBackends(), + ...Object.keys(currentConfig.provider_models), + ]); + const nextProviderModels = Object.entries( + body.providerModels ?? currentConfig.provider_models + ).reduce( + (acc, [backendId, model]) => { + const normalizedBackend = normalizeImageAnalysisBackendId(backendId, knownBackends); + const normalizedModel = typeof model === 'string' ? model.trim() : ''; + if (!normalizedBackend || normalizedModel.length === 0) { + return acc; + } + acc[normalizedBackend] = normalizedModel; + return acc; + }, + {} as Record + ); + + if (Object.keys(nextProviderModels).length === 0) { + res.status(400).json({ error: 'At least one provider model must remain configured.' }); + return; + } + + const requestedFallback = + typeof body.fallbackBackend === 'string' + ? body.fallbackBackend + : currentConfig.fallback_backend; + const normalizedFallback = normalizeImageAnalysisBackendId( + requestedFallback, + Object.keys(nextProviderModels) + ); + if (!normalizedFallback || !nextProviderModels[normalizedFallback]) { + res + .status(400) + .json({ error: 'Fallback backend must reference a configured provider model.' }); + return; + } + + const nextProfileBackends = {} as Record; + for (const [profileName, backendId] of Object.entries( + body.profileBackends ?? currentConfig.profile_backends ?? {} + )) { + const trimmedProfileName = profileName.trim(); + if (!trimmedProfileName) { + continue; + } + + const normalizedBackend = normalizeImageAnalysisBackendId( + backendId, + Object.keys(nextProviderModels) + ); + if (!normalizedBackend || !nextProviderModels[normalizedBackend]) { + res.status(400).json({ + error: `Profile mapping for "${trimmedProfileName}" references an unknown backend.`, + }); + return; + } + + nextProfileBackends[trimmedProfileName] = normalizedBackend; + } + + mutateUnifiedConfig((config) => { + config.image_analysis = { + enabled: body.enabled ?? currentConfig.enabled, + timeout: body.timeout ?? currentConfig.timeout, + provider_models: nextProviderModels, + fallback_backend: normalizedFallback, + profile_backends: nextProfileBackends, + }; + }); + + res.json(await buildDashboardPayload()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 4a8876e0..85534e0c 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -17,6 +17,7 @@ import variantRoutes from './variant-routes'; import settingsRoutes from './settings-routes'; import channelsRoutes from './channels-routes'; import websearchRoutes from './websearch-routes'; +import imageAnalysisRoutes from './image-analysis-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; @@ -68,6 +69,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== apiRoutes.use('/websearch', websearchRoutes); +apiRoutes.use('/image-analysis', imageAnalysisRoutes); // ==================== Copilot ==================== apiRoutes.use('/copilot', copilotRoutes); diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts new file mode 100644 index 00000000..3d9b399b --- /dev/null +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -0,0 +1,244 @@ +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 { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; +import imageAnalysisRoutes from '../../../src/web-server/routes/image-analysis-routes'; + +describe('image-analysis routes', () => { + let server: Server; + let baseUrl = ''; + let tempHome: string; + let originalCcsHome: string | undefined; + let originalDashboardAuthEnabled: string | undefined; + let forcedRemoteAddress = '127.0.0.1'; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); + app.use('/api/image-analysis', imageAnalysisRoutes); + + 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-analysis-routes-test-')); + originalCcsHome = process.env.CCS_HOME; + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + process.env.CCS_HOME = tempHome; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; + + const glmSettingsPath = path.join(tempHome, 'glm.settings.json'); + const codexSettingsPath = path.join(tempHome, 'codex-profile.settings.json'); + + fs.writeFileSync( + glmSettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'glm-token', + }, + }, + null, + 2 + ) + ); + fs.writeFileSync( + codexSettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp', + ANTHROPIC_AUTH_TOKEN: 'codex-token', + }, + }, + null, + 2 + ) + ); + + mutateUnifiedConfig((config) => { + config.profiles.glm = { + settings: glmSettingsPath, + target: 'claude', + }; + config.profiles.codexProfile = { + settings: codexSettingsPath, + target: 'droid', + }; + config.image_analysis = { + enabled: true, + timeout: 60, + provider_models: { + gemini: 'gemini-2.5-flash', + ghcp: 'claude-haiku-4.5', + }, + fallback_backend: 'gemini', + profile_backends: { + codexProfile: 'ghcp', + }, + }; + }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('blocks remote access when dashboard auth is disabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + + const response = await fetch(`${baseUrl}/api/image-analysis`); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Image Analysis endpoints require localhost access when dashboard auth is disabled.', + }); + }); + + it('returns global settings, backend readiness, and profile coverage', async () => { + const response = await fetch(`${baseUrl}/api/image-analysis`); + expect(response.status).toBe(200); + const payload = await response.json(); + + expect(payload.config).toMatchObject({ + enabled: true, + timeout: 60, + fallbackBackend: 'gemini', + profileBackends: { + codexProfile: 'ghcp', + }, + }); + expect(payload.catalog.knownBackends).toContain('gemini'); + expect(payload.backends).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + backendId: 'gemini', + model: 'gemini-2.5-flash', + }), + expect.objectContaining({ + backendId: 'ghcp', + profilesUsing: 1, + }), + ]) + ); + expect(payload.profiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'glm', + target: 'claude', + currentTargetMode: 'setup', + }), + expect.objectContaining({ + name: 'codexProfile', + target: 'droid', + backendId: 'ghcp', + currentTargetMode: 'bypassed', + }), + ]) + ); + expect(payload.summary).toMatchObject({ + backendCount: 2, + bypassedProfileCount: 1, + }); + }); + + it('updates the saved config through the dashboard route', async () => { + const response = await fetch(`${baseUrl}/api/image-analysis`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: true, + timeout: 120, + providerModels: { + gemini: 'gemini-2.5-pro', + ghcp: 'claude-haiku-4.5', + }, + fallbackBackend: 'ghcp', + profileBackends: { + glm: 'gemini', + codexProfile: 'ghcp', + }, + }), + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.config).toMatchObject({ + timeout: 120, + fallbackBackend: 'ghcp', + profileBackends: { + glm: 'gemini', + codexProfile: 'ghcp', + }, + }); + expect(payload.config.providerModels).toMatchObject({ + gemini: 'gemini-2.5-pro', + }); + }); + + it('rejects profile mappings that point to a missing backend with a client error', async () => { + const response = await fetch(`${baseUrl}/api/image-analysis`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerModels: { + gemini: 'gemini-2.5-flash', + }, + fallbackBackend: 'gemini', + profileBackends: { + codexProfile: 'ghcp', + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Profile mapping for "codexProfile" references an unknown backend.', + }); + }); +}); 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 27bab3c2..2008269b 100644 --- a/ui/src/components/profiles/editor/image-analysis-status-section.tsx +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -1,5 +1,7 @@ -import { AlertTriangle, Image as ImageIcon, Route } from 'lucide-react'; +import { AlertTriangle, ArrowUpRight, Image as ImageIcon } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client'; @@ -10,19 +12,20 @@ interface ImageAnalysisStatusSectionProps { previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; } -const RESOLUTION_SOURCE_LABELS: Record = { +const SOURCE_LABELS: Record = { 'cliproxy-provider': 'Direct provider route', 'cliproxy-variant': 'Variant route', 'cliproxy-composite': 'Composite route', 'copilot-alias': 'Copilot alias', - 'cliproxy-bridge': 'Derived from profile API route', - 'profile-backend': 'Saved Image Analysis mapping', + 'cliproxy-bridge': 'Derived from profile route', + 'profile-backend': 'Explicit profile mapping', 'fallback-backend': 'Fallback backend', disabled: 'Disabled globally', 'unsupported-profile': 'Unsupported profile type', unresolved: 'No backend mapped', 'missing-model': 'Missing model', }; + const TARGET_LABELS: Record = { claude: 'Claude Code', droid: 'Factory Droid', @@ -36,243 +39,131 @@ function getPreviewBadge( if (previewState === 'refreshing') { return { label: 'Refreshing', variant: 'outline' as const }; } - if (source === 'editor') { return { label: 'Live Preview', variant: 'secondary' as const }; } - return { label: 'Saved', variant: 'outline' as const }; } -function getRuntimeBadge(status: ImageAnalysisStatus | null | undefined, target: CliTarget) { - if (!status) return { label: 'Checking', variant: 'outline' as const }; - if (target !== 'claude') { - if (status.status === 'disabled') return { label: 'Disabled', variant: 'outline' as const }; - if (status.status === 'hook-missing') - return { label: 'Setup needed', variant: 'destructive' as const }; - if (status.authReadiness === 'missing') - return { label: 'Needs auth', variant: 'destructive' as const }; - if (status.proxyReadiness === 'unavailable') - return { label: 'Needs proxy', variant: 'destructive' as const }; - if (status.effectiveRuntimeMode === 'native-read' && status.backendId) { - return { label: 'Saved fallback', variant: 'outline' as const }; - } - return { label: 'Target bypassed', variant: 'outline' as const }; - } - if (status.status === 'disabled') return { label: 'Disabled', variant: 'outline' as const }; - if (status.status === 'hook-missing') - return { label: 'Setup needed', variant: 'destructive' as const }; - if (status.authReadiness === 'missing') - return { label: 'Needs auth', variant: 'destructive' as const }; - if (status.proxyReadiness === 'unavailable') - return { label: 'Needs proxy', variant: 'destructive' as const }; - if ( - status.effectiveRuntimeMode === 'cliproxy-image-analysis' && - status.proxyReadiness === 'stopped' - ) { - return { label: 'Starts on launch', variant: 'secondary' as const }; - } - if (status.effectiveRuntimeMode === 'cliproxy-image-analysis') { - return { label: 'CLIProxy Active', variant: 'default' as const }; - } - if ( - status.authReadiness === 'unknown' || - status.proxyReadiness === 'unknown' || - status.status === 'attention' - ) { - return { label: 'Needs review', variant: 'outline' as const }; - } - return { label: 'Native Access', variant: 'outline' as const }; -} - -function getSummary(status: ImageAnalysisStatus, target: CliTarget): string { - const backendName = status.backendDisplayName || status.backendId || 'this backend'; - const currentTarget = TARGET_LABELS[target]; - - if (target !== 'claude') { - if (!status.backendId) { - return ( - status.reason || - `Current default target: ${currentTarget}. This launch path bypasses the Claude Read hook, and no Claude-side Image Analysis backend is currently mapped.` - ); - } - - if (status.status === 'disabled') { - return `Current default target: ${currentTarget}. This launch path bypasses the Claude Read hook, and saved Claude-side Image Analysis is disabled for this profile.`; - } - - if (status.status === 'hook-missing') { - return `Current default target: ${currentTarget}. This launch path bypasses the Claude Read hook, and the saved Claude-side setup for ${backendName} still falls back to native file access because ${status.reason || 'the profile hook is not fully installed yet.'}`; - } - - if (status.effectiveRuntimeMode === 'native-read') { - return `Current default target: ${currentTarget}. This launch path bypasses the Claude Read hook, and the saved Claude-side setup for ${backendName} still falls back to native file access. ${status.effectiveRuntimeReason || status.reason || `Image Analysis via ${backendName} could not be confirmed.`}`; - } - - return `Images and PDFs for this profile are configured to resolve through ${backendName} when the profile runs on Claude Code.`; - } - +function getRuntimeBadge(status: ImageAnalysisStatus, target: CliTarget) { if (status.status === 'disabled') { - return 'Image Analysis is disabled globally, so images and PDFs use built-in file access for this profile.'; + return { + label: 'Disabled', + className: 'border-border/80 bg-background/85 text-muted-foreground', + }; } - - if (!status.backendId) { - return status.reason || 'This profile currently uses built-in file access for images and PDFs.'; - } - if (status.status === 'hook-missing') { - return `Images and PDFs are configured for ${backendName}, but ${status.reason || 'the profile hook is not fully installed yet.'}`; + return { + label: 'Setup needed', + className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', + }; } - - if (status.effectiveRuntimeMode === 'native-read') { - return `This profile currently falls back to native file access. ${status.effectiveRuntimeReason || status.reason || `Image Analysis via ${backendName} could not be confirmed.`}`; + if (status.authReadiness === 'missing') { + return { + label: 'Needs auth', + className: 'border-rose-500/25 bg-rose-500/10 text-rose-800 dark:text-rose-200', + }; } - - if (status.proxyReadiness === 'stopped') { - return `Images and PDFs for this profile resolve through ${backendName}. Auth is ready and CCS will start the local CLIProxy runtime when needed.`; + if (status.proxyReadiness === 'unavailable') { + return { + label: 'Needs proxy', + className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', + }; } - - if (status.resolutionSource === 'profile-backend') { - return `Images and PDFs for this profile resolve through ${backendName} via a saved Image Analysis mapping.`; - } - - if (status.status === 'attention' && status.reason) { - return `Images and PDFs for this profile resolve through ${backendName} via CLIProxy, but ${status.reason}`; - } - - return `Images and PDFs for this profile resolve through ${backendName} via CLIProxy.`; -} - -function getRuntimeLine(status: ImageAnalysisStatus, target: CliTarget): string { if (target !== 'claude') { - return `${TARGET_LABELS[target]} launch -> no Claude Read hook`; + return { + label: 'Bypassed', + className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200', + }; } - if (status.effectiveRuntimeMode === 'native-read') { - return 'Read -> native file access'; + return { + label: 'Native fallback', + className: 'border-border/80 bg-background/85 text-muted-foreground', + }; } - if (status.proxyReadiness === 'stopped') { - return 'Read -> image-analysis hook -> start local CLIProxy'; + return { + label: 'Starts on launch', + className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200', + }; } - - if (status.proxyReadiness === 'remote') { - return 'Read -> image-analysis hook -> remote CLIProxy'; - } - - return `Read -> image-analysis hook -> ${status.runtimePath || 'CLIProxy'}`; -} - -function getConfiguredBackendDetail(status: ImageAnalysisStatus): string { - if (!status.backendId) { - return status.reason || 'No backend mapped'; - } - - return RESOLUTION_SOURCE_LABELS[status.resolutionSource] || 'Configured'; -} - -function getEffectiveRuntimeValue(status: ImageAnalysisStatus, target: CliTarget): string { - if (target !== 'claude') { - return `Not active on ${TARGET_LABELS[target]}`; - } - - if (status.effectiveRuntimeMode === 'native-read') { - return 'Native file access'; - } - - return 'CLIProxy image analysis'; -} - -function getEffectiveRuntimeDetail(status: ImageAnalysisStatus, target: CliTarget): string { - if (target !== 'claude') { - if (status.status === 'hook-missing') { - return ( - status.reason || - `${TARGET_LABELS[target]} bypasses the Claude Read hook, and the saved Claude-side profile hook is still missing.` - ); - } - - if (status.effectiveRuntimeMode === 'native-read') { - return ( - status.effectiveRuntimeReason || - status.reason || - `${TARGET_LABELS[target]} bypasses the Claude Read hook, and the saved Claude-side setup currently falls back to native file access.` - ); - } - - return `${TARGET_LABELS[target]} bypasses the Claude Read hook. Switch the target back to Claude Code to use the saved backend shown here.`; - } - - if (status.effectiveRuntimeMode === 'native-read') { - return ( - status.effectiveRuntimeReason || - status.reason || - 'Uses built-in file access for this profile.' - ); - } - - if (status.proxyReadiness === 'stopped') { - return 'Auth ready • Local CLIProxy starts on demand'; - } - - if (status.proxyReadiness === 'remote') { - return 'Auth ready • Remote CLIProxy reachable'; - } - - if (status.authReadiness === 'ready' && status.proxyReadiness === 'ready') { - return 'Auth ready • Local CLIProxy reachable'; - } - - return status.proxyReason || status.authReason || 'Runtime verified'; -} - -function getAuthLine(status: ImageAnalysisStatus): string { - if (status.authReadiness === 'not-needed') return 'Not required'; - if (status.authReadiness === 'ready') - return `${status.authDisplayName || status.authProvider} ready`; - return status.authReason || 'Auth readiness could not be verified.'; -} - -function getProxyLine(status: ImageAnalysisStatus): string { - if (status.proxyReadiness === 'not-needed') return 'Not required'; - if (status.proxyReadiness === 'ready') return 'Local CLIProxy ready'; - if (status.proxyReadiness === 'remote') return status.proxyReason || 'Remote CLIProxy ready'; - if (status.proxyReadiness === 'stopped') return 'Local CLIProxy idle; starts on launch'; - return status.proxyReason || 'CLIProxy runtime readiness could not be verified.'; + return { + label: 'CLIProxy active', + className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', + }; } 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.'; + return 'Showing saved status until the JSON above is valid again.'; } if (previewState === 'refreshing') { - return 'Refreshing the live preview from the current editor state.'; + return 'Refreshing from the current editor state.'; } - if (source === 'editor') { - return 'Live preview from the current editor state. Save to persist config changes; auth and proxy readiness stay derived below.'; + return source === 'editor' + ? 'Preview from the current editor JSON.' + : 'Saved status for this profile.'; +} + +function getSummary(status: ImageAnalysisStatus, target: CliTarget): string { + const backendName = status.backendDisplayName || status.backendId || 'native file access'; + if (status.status === 'disabled') { + return 'Image Analysis is disabled globally for this profile.'; } - return 'Saved runtime status for this profile. Config stays in the JSON editor above; auth and proxy readiness are derived at runtime.'; + if (!status.backendId) { + return target === 'claude' + ? 'This profile currently uses native file access for images and PDFs.' + : `Current target ${TARGET_LABELS[target]} bypasses the hook and no saved backend is mapped.`; + } + if (target !== 'claude') { + return status.effectiveRuntimeMode === 'native-read' + ? `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side setup for ${backendName} currently falls back to native file access.` + : `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side backend: ${backendName}.`; + } + if (status.effectiveRuntimeMode === 'native-read') { + return `Saved backend: ${backendName}. Current runtime falls back to native file access.`; + } + return `Saved backend: ${backendName}. Images and PDFs resolve through CLIProxy on this target.`; +} + +function getTargetDetail(status: ImageAnalysisStatus, target: CliTarget): string { + if (target !== 'claude') { + return 'Current launch path bypasses the Claude Read hook.'; + } + if (status.status === 'hook-missing') { + return 'Hook must be persisted before this profile can use Image Analysis.'; + } + if (status.effectiveRuntimeMode === 'native-read') { + return status.effectiveRuntimeReason || status.reason || 'Using native file access.'; + } + if (status.proxyReadiness === 'stopped') { + return 'Auth is ready. Local CLIProxy will start on demand.'; + } + return 'Current target can use the saved backend.'; } function getPersistenceValue(status: ImageAnalysisStatus): string { if (!status.shouldPersistHook || !status.persistencePath) { - return 'Not persisted'; + return 'Not required'; } - - return status.hookInstalled ? 'Hook saved to profile' : 'Hook missing from profile'; + return status.hookInstalled ? 'Hook saved' : 'Hook missing'; } function getPersistenceDetail(status: ImageAnalysisStatus): string { if (!status.shouldPersistHook || !status.persistencePath) { - return 'Not required for this profile type'; + return 'No profile-level hook persistence required.'; } - return status.persistencePath; } +function getModelValue(status: ImageAnalysisStatus): string { + return status.model || status.reason || 'Unavailable'; +} + export function ImageAnalysisStatusSection({ status, target = 'claude', @@ -282,23 +173,18 @@ export function ImageAnalysisStatusSection({ if (!status) { return (
-
-
-

Checking backend status...

+
+
); } const previewBadge = getPreviewBadge(source, previewState); const runtimeBadge = getRuntimeBadge(status, target); - const bypassedOnCurrentTarget = target !== 'claude'; - const notice = bypassedOnCurrentTarget - ? null - : status.effectiveRuntimeMode === 'native-read' - ? status.effectiveRuntimeReason - : status.status === 'attention' || status.status === 'hook-missing' - ? status.reason - : null; + const showNotice = + status.status === 'hook-missing' || + status.authReadiness === 'missing' || + status.proxyReadiness === 'unavailable'; return (
@@ -313,64 +199,50 @@ export function ImageAnalysisStatusSection({

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

- {getSummary(status, target)} -

- - {bypassedOnCurrentTarget && ( -
- - - Current default target: {TARGET_LABELS[target]}. The diagnostics below describe the - saved Claude-side Image Analysis hook and apply again if you switch back to Claude Code. - -
- )} +

{getSummary(status, target)}

- Configured backend + Backend
- {status.backendDisplayName || status.backendId || 'No backend mapped'} + {status.backendDisplayName || status.backendId || 'Native file access'}

- {getConfiguredBackendDetail(status)} + {SOURCE_LABELS[status.resolutionSource]}

- Effective runtime -
-
- {getEffectiveRuntimeValue(status, target)} + Current target
+
{TARGET_LABELS[target]}

- {getEffectiveRuntimeDetail(status, target)} + {getTargetDetail(status, target)}

- Hook persistence + Persistence
{getPersistenceValue(status)}

{getPersistenceDetail(status)}

@@ -382,49 +254,61 @@ export function ImageAnalysisStatusSection({
Auth
-
{getAuthLine(status)}
+
+ {status.authReadiness === 'ready' + ? `${status.authDisplayName || status.authProvider} ready` + : status.authReadiness === 'missing' + ? status.authReason + : status.authReadiness === 'not-needed' + ? 'Not required' + : 'Unknown'} +
Proxy
-
{getProxyLine(status)}
+
+ {status.proxyReadiness === 'ready' + ? 'Local CLIProxy ready' + : status.proxyReadiness === 'remote' + ? 'Remote CLIProxy ready' + : status.proxyReadiness === 'stopped' + ? 'Local CLIProxy idle' + : status.proxyReadiness === 'not-needed' + ? 'Not required' + : status.proxyReason || 'Unknown'} +
Model
- {status.model || status.reason || 'Unavailable'} + {getModelValue(status)}
-
-
- Runtime path -
-
- {getRuntimeLine(status, target)} -
-
- - {notice && ( + {showNotice && (
- {notice} + + {status.effectiveRuntimeReason || status.reason || 'Review the saved configuration.'} +
)} -
- - - This panel covers the Image Analysis hook only. WebSearch stays managed separately and is - not controlled here. - +
+

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

+
); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 63f03870..3d4ffeea 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -148,6 +148,72 @@ export interface ImageAnalysisStatus { effectiveRuntimeReason: string | null; } +export interface ImageAnalysisSettingsConfig { + enabled: boolean; + timeout: number; + providerModels: Record; + fallbackBackend: string | null; + profileBackends: Record; +} + +export interface ImageAnalysisDashboardSummary { + state: 'ready' | 'partial' | 'needs_setup' | 'disabled'; + title: string; + detail: string; + backendCount: number; + mappedProfileCount: number; + activeProfileCount: number; + bypassedProfileCount: number; +} + +export interface ImageAnalysisDashboardBackend { + backendId: string; + displayName: string; + model: string; + state: 'ready' | 'starts_on_launch' | 'needs_auth' | 'needs_proxy' | 'review'; + authReadiness: ImageAnalysisStatus['authReadiness']; + authReason: string | null; + proxyReadiness: ImageAnalysisStatus['proxyReadiness']; + proxyReason: string | null; + profilesUsing: number; +} + +export interface ImageAnalysisDashboardProfile { + name: string; + kind: 'profile' | 'variant'; + target: CliTarget; + configured: boolean; + settingsPath: string | null; + backendId: string | null; + backendDisplayName: string | null; + resolutionSource: ImageAnalysisStatus['resolutionSource']; + status: ImageAnalysisStatus['status']; + effectiveRuntimeMode: ImageAnalysisStatus['effectiveRuntimeMode']; + effectiveRuntimeReason: string | null; + currentTargetMode: 'active' | 'bypassed' | 'fallback' | 'setup' | 'disabled' | 'unresolved'; +} + +export interface ImageAnalysisDashboardCatalog { + knownBackends: string[]; + profileNames: string[]; +} + +export interface ImageAnalysisDashboardData { + config: ImageAnalysisSettingsConfig; + summary: ImageAnalysisDashboardSummary; + backends: ImageAnalysisDashboardBackend[]; + profiles: ImageAnalysisDashboardProfile[]; + catalog: ImageAnalysisDashboardCatalog; +} + +export interface UpdateImageAnalysisSettingsPayload { + enabled?: boolean; + timeout?: number; + providerModels?: Record; + fallbackBackend?: string | null; + profileBackends?: Record; +} + export interface Profile { name: string; settingsPath: string; @@ -843,6 +909,14 @@ export const api = { body: JSON.stringify(data), }), }, + imageAnalysis: { + get: () => request('/image-analysis'), + update: (data: UpdateImageAnalysisSettingsPayload) => + request('/image-analysis', { + method: 'PUT', + body: JSON.stringify(data), + }), + }, cliproxy: { list: () => request<{ variants: Variant[] }>('/cliproxy'), getAuthStatus: () => diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index 0c3fb57d..9e9f28ed 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -4,7 +4,16 @@ */ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Globe, Settings2, Server, KeyRound, Brain, Archive, MessageSquare } from 'lucide-react'; +import { + Globe, + Image as ImageIcon, + Settings2, + Server, + KeyRound, + Brain, + Archive, + MessageSquare, +} from 'lucide-react'; import type { SettingsTab } from '../types'; import { useTranslation } from 'react-i18next'; @@ -17,6 +26,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { const { t } = useTranslation(); const tabs = [ { value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe }, + { value: 'imageanalysis' as const, label: 'Image Analysis', icon: ImageIcon }, { value: 'channels' as const, label: 'Channels', icon: MessageSquare }, { value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 }, { value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain }, @@ -27,7 +37,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { return ( onTabChange(v as SettingsTab)}> - + {tabs.map(({ value, label, icon: Icon }) => ( diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 15a05a5f..66315458 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -11,19 +11,21 @@ export function useSettingsTab() { // Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups) const tabParam = searchParams.get('tab')?.toLowerCase(); const activeTab: SettingsTab = - tabParam === 'channels' - ? 'channels' - : tabParam === 'globalenv' - ? 'globalenv' - : tabParam === 'proxy' - ? 'proxy' - : tabParam === 'auth' - ? 'auth' - : tabParam === 'thinking' - ? 'thinking' - : tabParam === 'backups' - ? 'backups' - : 'websearch'; + tabParam === 'imageanalysis' + ? 'imageanalysis' + : tabParam === 'channels' + ? 'channels' + : tabParam === 'globalenv' + ? 'globalenv' + : tabParam === 'proxy' + ? 'proxy' + : tabParam === 'auth' + ? 'auth' + : tabParam === 'thinking' + ? 'thinking' + : tabParam === 'backups' + ? 'backups' + : 'websearch'; const setActiveTab = useCallback( (tab: SettingsTab) => { diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index 5d1bf4a1..fbc63798 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -48,6 +48,7 @@ function lazyWithRetry>(importFn: () => Promise // Lazy-loaded sections with retry capability const WebSearchSection = lazyWithRetry(() => import('./sections/websearch')); +const ImageAnalysisSection = lazyWithRetry(() => import('./sections/image-analysis')); const ChannelsSection = lazyWithRetry(() => import('./sections/channels')); const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section')); const ThinkingSection = lazyWithRetry(() => import('./sections/thinking')); @@ -131,6 +132,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } + {activeTab === 'imageanalysis' && } {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } @@ -155,6 +157,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } + {activeTab === 'imageanalysis' && } {activeTab === 'channels' && } {activeTab === 'globalenv' && } {activeTab === 'thinking' && } diff --git a/ui/src/pages/settings/sections/image-analysis/index.tsx b/ui/src/pages/settings/sections/image-analysis/index.tsx new file mode 100644 index 00000000..acb7796b --- /dev/null +++ b/ui/src/pages/settings/sections/image-analysis/index.tsx @@ -0,0 +1,693 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + AlertCircle, + CheckCircle2, + Image as ImageIcon, + Plus, + RefreshCw, + Save, + Trash2, +} from 'lucide-react'; +import { api, type ImageAnalysisDashboardData } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { useRawConfig } from '../../hooks'; + +interface MappingDraft { + id: string; + profileName: string; + backendId: string; +} + +const NO_BACKEND = '__no_backend__'; + +function isStringRecord(value: unknown): value is Record { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.values(value).every((entry) => typeof entry === 'string') + ); +} + +function isImageAnalysisDashboardData(value: unknown): value is ImageAnalysisDashboardData { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const candidate = value as Partial; + return ( + !!candidate.config && + typeof candidate.config.enabled === 'boolean' && + typeof candidate.config.timeout === 'number' && + isStringRecord(candidate.config.providerModels) && + (candidate.config.fallbackBackend === null || + typeof candidate.config.fallbackBackend === 'string') && + isStringRecord(candidate.config.profileBackends) && + !!candidate.summary && + typeof candidate.summary.state === 'string' && + typeof candidate.summary.title === 'string' && + typeof candidate.summary.detail === 'string' && + Array.isArray(candidate.backends) && + Array.isArray(candidate.profiles) && + !!candidate.catalog && + Array.isArray(candidate.catalog.knownBackends) && + Array.isArray(candidate.catalog.profileNames) + ); +} + +function toMappingDrafts(profileBackends: Record): MappingDraft[] { + return Object.entries(profileBackends) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([profileName, backendId], index) => ({ + id: `${profileName}-${backendId}-${index}`, + profileName, + backendId, + })); +} + +function summaryToneClass(state: ImageAnalysisDashboardData['summary']['state']): string { + switch (state) { + case 'ready': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200'; + case 'partial': + return 'border-amber-500/25 bg-amber-500/10 text-amber-900 dark:text-amber-200'; + case 'needs_setup': + return 'border-rose-500/25 bg-rose-500/10 text-rose-900 dark:text-rose-200'; + case 'disabled': + return 'border-border/80 bg-background/85 text-muted-foreground'; + } +} + +function backendStateClass(state: ImageAnalysisDashboardData['backends'][number]['state']): string { + switch (state) { + case 'ready': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200'; + case 'starts_on_launch': + return 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200'; + case 'needs_auth': + return 'border-rose-500/25 bg-rose-500/10 text-rose-800 dark:text-rose-200'; + case 'needs_proxy': + return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200'; + case 'review': + return 'border-border/80 bg-background/85 text-muted-foreground'; + } +} + +function currentTargetModeLabel( + mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode'] +): string { + switch (mode) { + case 'active': + return 'Active'; + case 'bypassed': + return 'Bypassed'; + case 'fallback': + return 'Native fallback'; + case 'setup': + return 'Needs setup'; + case 'disabled': + return 'Disabled'; + case 'unresolved': + return 'Native only'; + } +} + +function currentTargetModeClass( + mode: ImageAnalysisDashboardData['profiles'][number]['currentTargetMode'] +): string { + switch (mode) { + case 'active': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200'; + case 'bypassed': + return 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200'; + case 'fallback': + case 'setup': + return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200'; + case 'disabled': + case 'unresolved': + return 'border-border/80 bg-background/85 text-muted-foreground'; + } +} + +export default function ImageAnalysisSection() { + const { fetchRawConfig } = useRawConfig(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [enabled, setEnabled] = useState(true); + const [timeout, setTimeout] = useState('60'); + const [fallbackBackend, setFallbackBackend] = useState(''); + const [providerModels, setProviderModels] = useState>({}); + const [mappingDrafts, setMappingDrafts] = useState([]); + + const hydrateDraft = useCallback((nextData: ImageAnalysisDashboardData) => { + setEnabled(nextData.config.enabled); + setTimeout(String(nextData.config.timeout)); + setFallbackBackend(nextData.config.fallbackBackend ?? ''); + setProviderModels( + nextData.catalog.knownBackends.reduce( + (acc, backendId) => { + acc[backendId] = nextData.config.providerModels[backendId] ?? ''; + return acc; + }, + {} as Record + ) + ); + setMappingDrafts(toMappingDrafts(nextData.config.profileBackends)); + }, []); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + const payload = await api.imageAnalysis.get(); + if (!isImageAnalysisDashboardData(payload)) { + throw new Error( + 'Image Analysis settings returned an unexpected response. Restart the dashboard server so the new API route is available.' + ); + } + setData(payload); + hydrateDraft(payload); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load Image Analysis settings.'); + } finally { + setLoading(false); + } + }, [hydrateDraft]); + + useEffect(() => { + void fetchData(); + void fetchRawConfig(); + }, [fetchData, fetchRawConfig]); + + useEffect(() => { + if (!success) return; + const timer = window.setTimeout(() => setSuccess(null), 2500); + return () => window.clearTimeout(timer); + }, [success]); + + const configuredBackendIds = useMemo( + () => + Object.entries(providerModels) + .filter(([, model]) => model.trim().length > 0) + .map(([backendId]) => backendId), + [providerModels] + ); + + useEffect(() => { + if (configuredBackendIds.length === 0) { + setFallbackBackend(''); + return; + } + if (!configuredBackendIds.includes(fallbackBackend)) { + setFallbackBackend(configuredBackendIds[0]); + } + }, [configuredBackendIds, fallbackBackend]); + + const payloadPreview = useMemo(() => { + const nextProviderModels = Object.entries(providerModels).reduce( + (acc, [backendId, model]) => { + const normalizedModel = typeof model === 'string' ? model.trim() : ''; + acc[backendId] = normalizedModel || null; + return acc; + }, + {} as Record + ); + + const nextProfileBackends = mappingDrafts.reduce( + (acc, row) => { + const profileName = row.profileName.trim(); + if (!profileName || !row.backendId) { + return acc; + } + acc[profileName] = row.backendId; + return acc; + }, + {} as Record + ); + + return { + enabled, + timeout, + fallbackBackend, + providerModels: nextProviderModels, + profileBackends: nextProfileBackends, + }; + }, [enabled, fallbackBackend, mappingDrafts, providerModels, timeout]); + + const hasChanges = useMemo(() => { + if (!data) return false; + return ( + JSON.stringify(payloadPreview) !== + JSON.stringify({ + enabled: data.config.enabled, + timeout: String(data.config.timeout), + fallbackBackend: data.config.fallbackBackend ?? '', + providerModels: data.catalog.knownBackends.reduce( + (acc, backendId) => { + acc[backendId] = data.config.providerModels[backendId] ?? null; + return acc; + }, + {} as Record + ), + profileBackends: data.config.profileBackends, + }) + ); + }, [data, payloadPreview]); + + const canSave = configuredBackendIds.length > 0 && Number.isInteger(Number(timeout)); + + const handleRefresh = async () => { + if (loading || saving) return; + setSuccess(null); + await Promise.all([fetchData(), fetchRawConfig()]); + }; + + const handleSave = async () => { + if (!data) return; + const parsedTimeout = Number.parseInt(timeout, 10); + + if (!Number.isInteger(parsedTimeout) || parsedTimeout < 10 || parsedTimeout > 600) { + setError('Timeout must be an integer between 10 and 600 seconds.'); + return; + } + + if (configuredBackendIds.length === 0) { + setError('Keep at least one provider model configured, or disable Image Analysis globally.'); + return; + } + + try { + setSaving(true); + setError(null); + const payload = await api.imageAnalysis.update({ + enabled, + timeout: parsedTimeout, + fallbackBackend: fallbackBackend || null, + providerModels: payloadPreview.providerModels, + profileBackends: payloadPreview.profileBackends, + }); + setData(payload); + hydrateDraft(payload); + setSuccess('Image Analysis settings saved.'); + await fetchRawConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save Image Analysis settings.'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+
+ + Loading Image Analysis settings... +
+
+ ); + } + + if (!data) { + return ( +
+ + + {error ?? 'Failed to load Image Analysis settings.'} + +
+ +
+
+ ); + } + + return ( + +
+ {(error || success) && ( +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + {success} +
+ )} +
+ )} + +
+
+
+
+ +

Image Analysis

+ + {data.summary.title} + +
+

{data.summary.detail}

+
+ +
+ + +
+
+ +
+
+
+ Enabled +
+
+ {enabled ? 'On' : 'Off'} + +
+
+
+
+ Timeout +
+
+ setTimeout(event.target.value)} + inputMode="numeric" + className="h-9" + /> + sec +
+
+
+
+ Fallback backend +
+
+ +
+
+
+
+ Coverage +
+
+ {data.summary.backendCount} backends + {data.summary.mappedProfileCount} mapped + {data.summary.bypassedProfileCount} bypassed +
+
+
+
+ +
+
+
+

Provider models

+

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

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

Profile mappings

+

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

+
+ +
+ + + {data.catalog.profileNames.map((profileName) => ( + + +
+ {mappingDrafts.length === 0 ? ( +
+ No explicit profile mappings saved. Profiles follow provider and fallback + resolution. +
+ ) : ( + mappingDrafts.map((row) => ( +
+ + setMappingDrafts((current) => + current.map((entry) => + entry.id === row.id + ? { ...entry, profileName: event.target.value } + : entry + ) + ) + } + /> + + +
+ )) + )} +
+
+ +
+
+

Profile coverage

+

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

+
+ +
+
+
+ Profile + Target + Backend + Current path +
+
+ {data.profiles.map((profile) => ( +
+
+
{profile.name}
+
+ {profile.kind === 'variant' ? 'CLIProxy variant' : 'Settings profile'} +
+
+
{profile.target}
+
+
+ {profile.backendDisplayName || 'Native file access'} +
+
+ {profile.resolutionSource.replace(/-/g, ' ')} +
+
+
+ + {currentTargetModeLabel(profile.currentTargetMode)} + +
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index de75c527..3396e3ce 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -161,6 +161,7 @@ export interface OfficialChannelsStatus { export type SettingsTab = | 'websearch' + | 'imageanalysis' | 'channels' | 'globalenv' | 'proxy' diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx index 4958cc72..7a33f808 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 @@ -77,158 +77,38 @@ describe('ImageAnalysisStatusSection', () => { vi.restoreAllMocks(); }); - it('renders saved diagnostics for an active backend', () => { + it('renders a compact saved summary with a settings link', () => { render(); expect(screen.getByText('Image Analysis')).toBeInTheDocument(); - expect( - screen.getByText( - /Saved runtime status for this profile\. Config stays in the JSON editor above; auth and proxy readiness are derived at runtime\./i - ) - ).toBeInTheDocument(); expect(screen.getByText('Saved')).toBeInTheDocument(); - expect(screen.getByText('CLIProxy Active')).toBeInTheDocument(); + expect(screen.getByText('CLIProxy active')).toBeInTheDocument(); expect( - screen.getByText( - /Images and PDFs for this profile resolve through Google Gemini via CLIProxy\./i - ) + screen.getByText(/Saved backend: Google Gemini\. Images and PDFs resolve through CLIProxy/i) ).toBeInTheDocument(); - expect(screen.getByText('Configured backend')).toBeInTheDocument(); - expect(screen.getByText('Effective runtime')).toBeInTheDocument(); - expect(screen.getByText('Hook persistence')).toBeInTheDocument(); - expect(screen.getByText('Google Gemini')).toBeInTheDocument(); - expect(screen.getByText('CLIProxy image analysis')).toBeInTheDocument(); - expect(screen.getByText('Hook saved to profile')).toBeInTheDocument(); - expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument(); - expect(screen.getAllByText('Google Gemini ready')).toHaveLength(1); - expect(screen.getByText('Local CLIProxy ready')).toBeInTheDocument(); - expect(screen.getByText('gemini-2.5-flash')).toBeInTheDocument(); - }); - - it('renders mapped status and the explicit mapping explanation', () => { - render( - + expect(screen.getByText('Backend')).toBeInTheDocument(); + expect(screen.getByText('Current target')).toBeInTheDocument(); + expect(screen.getByText('Persistence')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Open Settings/i })).toHaveAttribute( + 'href', + '/settings?tab=imageanalysis' ); - - expect(screen.getByText('CLIProxy Active')).toBeInTheDocument(); - expect( - screen.getByText( - /Images and PDFs for this profile resolve through GitHub Copilot \(OAuth\) via a saved Image Analysis mapping\./i - ) - ).toBeInTheDocument(); - expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); - expect(screen.getByText('Saved Image Analysis mapping')).toBeInTheDocument(); - expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument(); }); - it('renders hook-missing state as native file access until the hook is installed', () => { - render( - - ); + it('shows bypassed mode when the current target is not Claude Code', () => { + render(); - expect(screen.getByText('Setup needed')).toBeInTheDocument(); + expect(screen.getByText('Bypassed')).toBeInTheDocument(); expect( - screen.getByText( - /Images and PDFs are configured for Google Gemini, but Profile hook is missing/i - ) + screen.getByText(/Current target Codex CLI bypasses the hook\. Saved Claude-side backend/i) + ).toBeInTheDocument(); + expect(screen.getByText('Codex CLI')).toBeInTheDocument(); + expect( + screen.getByText(/Current launch path bypasses the Claude Read hook/i) ).toBeInTheDocument(); - expect(screen.getByText('Native file access')).toBeInTheDocument(); - expect(screen.getByTitle(/native file access/i)).toBeInTheDocument(); }); - it('shows auth readiness gaps separately from backend resolution', () => { - render( - - ); - - expect(screen.getByText('Needs auth')).toBeInTheDocument(); - expect( - screen.getByText(/This profile currently falls back to native file access\./i) - ).toBeInTheDocument(); - expect(screen.getByText('Native file access')).toBeInTheDocument(); - expect( - screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length - ).toBeGreaterThanOrEqual(3); - }); - - it('treats an idle local proxy as launchable instead of unavailable', () => { - render( - - ); - - expect(screen.getByText('Starts on launch')).toBeInTheDocument(); - expect( - screen.getByText( - /Images and PDFs for this profile resolve through Google Gemini\. Auth is ready and CCS will start the local CLIProxy runtime when needed\./i - ) - ).toBeInTheDocument(); - expect(screen.getByText('Local CLIProxy idle; starts on launch')).toBeInTheDocument(); - expect(screen.getByText('Auth ready • Local CLIProxy starts on demand')).toBeInTheDocument(); - expect(screen.getByTitle(/start local CLIProxy/i)).toBeInTheDocument(); - }); - - it('shows saved Claude-side diagnostics when the current target bypasses the hook', () => { - render(); - - expect(screen.getByText('Target bypassed')).toBeInTheDocument(); - expect( - screen.getByText( - /Images and PDFs for this profile are configured to resolve through Google Gemini when the profile runs on Claude Code\./i - ) - ).toBeInTheDocument(); - expect( - screen.getByText( - /Current default target: Factory Droid\. The diagnostics below describe the saved Claude-side Image Analysis hook/i - ) - ).toBeInTheDocument(); - expect(screen.getByText('Not active on Factory Droid')).toBeInTheDocument(); - expect( - screen.getByText( - /Factory Droid bypasses the Claude Read hook\. Switch the target back to Claude Code to use the saved backend shown here\./i - ) - ).toBeInTheDocument(); - expect(screen.getByTitle(/Factory Droid launch -> no Claude Read hook/i)).toBeInTheDocument(); - }); - - it('keeps saved Claude-side auth failures visible when another target bypasses the hook', () => { + it('keeps auth failures visible without a long diagnostic wall', () => { render( { effectiveRuntimeReason: 'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.', })} - target="codex" /> ); expect(screen.getByText('Needs auth')).toBeInTheDocument(); expect( - screen.getByText( - /Current default target: Codex CLI\. This launch path bypasses the Claude Read hook, and the saved Claude-side setup for GitHub Copilot \(OAuth\) still falls back to native file access\./i - ) + screen.getByText(/Saved backend: GitHub Copilot \(OAuth\)\. Current runtime falls back/i) ).toBeInTheDocument(); expect( screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length ).toBeGreaterThanOrEqual(1); }); - it('falls back to backend id when a display name is unavailable', () => { - render( - - ); - - expect(screen.getByText('ghcp')).toBeInTheDocument(); - }); - - it('switches the panel to a live preview when the current editor JSON changes', async () => { + it('switches to live preview when editor JSON changes', async () => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -300,7 +164,6 @@ describe('ImageAnalysisStatusSection', () => { backendId: 'ghcp', backendDisplayName: 'GitHub Copilot (OAuth)', model: 'claude-haiku-4.5', - runtimePath: '/api/provider/ghcp', authReadiness: 'ready', authProvider: 'ghcp', authDisplayName: 'GitHub Copilot (OAuth)', @@ -334,13 +197,10 @@ describe('ImageAnalysisStatusSection', () => { }); await waitFor(() => { - expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); + expect(screen.getByText('Live Preview')).toBeInTheDocument(); }); - expect( - screen.getByText( - /Live preview from the current editor state\. Save to persist config changes; auth and proxy readiness stay derived below\./i - ) - ).toBeInTheDocument(); + expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); + expect(screen.getByText(/Preview from the current editor JSON/i)).toBeInTheDocument(); }); it('falls back to saved status messaging when the editor JSON is invalid', async () => { @@ -380,15 +240,12 @@ describe('ImageAnalysisStatusSection', () => { await waitFor(() => { expect( - screen.getByText( - /Showing last saved runtime status\. The live preview resumes when the JSON above is valid again\./i - ) + screen.getByText(/Showing saved status until the JSON above is valid again/i) ).toBeInTheDocument(); }); - expect(screen.getByText('Google Gemini')).toBeInTheDocument(); }); - it('marks the preview as refreshing when a newer editor preview is still loading', async () => { + it('marks the preview as refreshing when a newer preview is still loading', async () => { let secondPreviewResolver: ((value: Response) => void) | null = null; const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -424,7 +281,6 @@ describe('ImageAnalysisStatusSection', () => { backendId: 'ghcp', backendDisplayName: 'GitHub Copilot (OAuth)', model: 'claude-haiku-4.5', - runtimePath: '/api/provider/ghcp', authReadiness: 'ready', authProvider: 'ghcp', authDisplayName: 'GitHub Copilot (OAuth)', @@ -484,9 +340,7 @@ describe('ImageAnalysisStatusSection', () => { await waitFor(() => { expect(screen.getByText('Refreshing')).toBeInTheDocument(); }); - expect( - screen.getByText(/Refreshing the live preview from the current editor state\./i) - ).toBeInTheDocument(); + expect(screen.getByText(/Refreshing from the current editor state/i)).toBeInTheDocument(); secondPreviewResolver?.( createJsonResponse({ @@ -494,7 +348,6 @@ describe('ImageAnalysisStatusSection', () => { backendId: 'codex', backendDisplayName: 'Codex', model: 'gpt-5.4', - runtimePath: '/api/provider/codex', authReadiness: 'ready', authProvider: 'codex', authDisplayName: 'Codex', diff --git a/ui/tests/unit/ui/pages/image-analysis-section.test.tsx b/ui/tests/unit/ui/pages/image-analysis-section.test.tsx new file mode 100644 index 00000000..5b8a8eec --- /dev/null +++ b/ui/tests/unit/ui/pages/image-analysis-section.test.tsx @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; +import ImageAnalysisSection from '@/pages/settings/sections/image-analysis'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('ImageAnalysisSection', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + let payload = { + config: { + enabled: true, + timeout: 60, + providerModels: { + gemini: 'gemini-2.5-flash', + ghcp: 'claude-haiku-4.5', + }, + fallbackBackend: 'gemini', + profileBackends: { + codexProfile: 'ghcp', + }, + }, + summary: { + state: 'partial', + title: 'Partially ready', + detail: + '1 backend still needs auth, runtime, or review before every profile path is healthy.', + backendCount: 2, + mappedProfileCount: 1, + activeProfileCount: 1, + bypassedProfileCount: 1, + }, + backends: [ + { + backendId: 'gemini', + displayName: 'Google Gemini', + model: 'gemini-2.5-flash', + state: 'ready', + authReadiness: 'ready', + authReason: null, + proxyReadiness: 'ready', + proxyReason: null, + profilesUsing: 1, + }, + { + backendId: 'ghcp', + displayName: 'GitHub Copilot (OAuth)', + model: 'claude-haiku-4.5', + state: 'needs_auth', + authReadiness: 'missing', + authReason: 'Run ccs ghcp --auth', + proxyReadiness: 'ready', + proxyReason: null, + profilesUsing: 1, + }, + ], + profiles: [ + { + name: 'glm', + kind: 'profile', + target: 'claude', + configured: true, + settingsPath: '/tmp/glm.settings.json', + backendId: 'gemini', + backendDisplayName: 'Google Gemini', + resolutionSource: 'cliproxy-bridge', + status: 'active', + effectiveRuntimeMode: 'cliproxy-image-analysis', + effectiveRuntimeReason: null, + currentTargetMode: 'active', + }, + { + name: 'codexProfile', + kind: 'profile', + target: 'codex', + configured: true, + settingsPath: '/tmp/codex.settings.json', + backendId: 'ghcp', + backendDisplayName: 'GitHub Copilot (OAuth)', + resolutionSource: 'profile-backend', + status: 'mapped', + effectiveRuntimeMode: 'cliproxy-image-analysis', + effectiveRuntimeReason: null, + currentTargetMode: 'bypassed', + }, + ], + catalog: { + knownBackends: ['gemini', 'ghcp', 'codex'], + profileNames: ['glm', 'codexProfile'], + }, + }; + + fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + const method = init?.method ?? 'GET'; + + if (url === '/api/image-analysis' && method === 'GET') { + return jsonResponse(payload); + } + + if (url === '/api/config/raw' && method === 'GET') { + return new Response('image_analysis:\n enabled: true\n'); + } + + if (url === '/api/image-analysis' && method === 'PUT') { + const body = JSON.parse(String(init?.body ?? '{}')) as { + timeout?: number; + fallbackBackend?: string; + profileBackends?: Record; + providerModels?: Record; + }; + + payload = { + ...payload, + config: { + enabled: true, + timeout: body.timeout ?? payload.config.timeout, + fallbackBackend: body.fallbackBackend ?? payload.config.fallbackBackend, + providerModels: { + gemini: String(body.providerModels?.gemini ?? payload.config.providerModels.gemini), + ghcp: String(body.providerModels?.ghcp ?? payload.config.providerModels.ghcp), + }, + profileBackends: body.profileBackends ?? payload.config.profileBackends, + }, + }; + + return jsonResponse(payload); + } + + return jsonResponse({ error: `Unhandled request: ${method} ${url}` }, 500); + }); + + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders global controls and saves updated config', async () => { + render(, { withSettingsProvider: true }); + + expect(await screen.findByText('Image Analysis')).toBeInTheDocument(); + expect(screen.getByText('Partially ready')).toBeInTheDocument(); + expect(screen.getByText('Provider models')).toBeInTheDocument(); + expect(screen.getByText('Profile mappings')).toBeInTheDocument(); + expect(screen.getByText('Profile coverage')).toBeInTheDocument(); + expect(screen.getByText('Bypassed')).toBeInTheDocument(); + + const timeoutInput = screen.getByDisplayValue('60'); + await userEvent.clear(timeoutInput); + await userEvent.type(timeoutInput, '120'); + + await userEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + '/api/image-analysis', + expect.objectContaining({ + method: 'PUT', + }) + ); + }); + + const putCall = fetchMock.mock.calls.find( + ([url, init]) => + url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT' + ); + expect(putCall).toBeDefined(); + + const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}')); + expect(requestBody).toMatchObject({ + timeout: 120, + fallbackBackend: 'gemini', + profileBackends: { + codexProfile: 'ghcp', + }, + }); + + expect(await screen.findByText('Image Analysis settings saved.')).toBeInTheDocument(); + }); + + it('surfaces a clear retryable error when the backend route is not available yet', async () => { + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + const method = init?.method ?? 'GET'; + + if (url === '/api/image-analysis' && method === 'GET') { + return new Response('', { + status: 200, + headers: { 'Content-Type': 'text/html; charset=UTF-8' }, + }); + } + + if (url === '/api/config/raw' && method === 'GET') { + return new Response('image_analysis:\n enabled: true\n'); + } + + return jsonResponse({ error: `Unhandled request: ${method} ${url}` }, 500); + }); + + render(, { withSettingsProvider: true }); + + expect( + await screen.findByText( + /Image Analysis settings returned an unexpected response\. Restart the dashboard server/i + ) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); +});