feat(image-analysis): add dedicated dashboard settings

This commit is contained in:
Tam Nhu Tran
2026-04-01 15:32:37 -04:00
parent 0246e327fe
commit 9d1d281e34
12 changed files with 1851 additions and 447 deletions
@@ -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<string, string | null>;
fallbackBackend?: string | null;
profileBackends?: Record<string, string>;
}
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<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
): 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<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
): 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<void> => {
try {
res.json(await buildDashboardPayload());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
router.put('/', async (req: Request, res: Response): Promise<void> => {
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<string, string>
);
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<string, string>;
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;
+2
View File
@@ -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);
@@ -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<void>((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<void>((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.',
});
});
});
@@ -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<ImageAnalysisStatus['resolutionSource'], string> = {
const SOURCE_LABELS: Record<ImageAnalysisStatus['resolutionSource'], string> = {
'cliproxy-provider': 'Direct provider route',
'cliproxy-variant': 'Variant route',
'cliproxy-composite': 'Composite route',
'copilot-alias': 'Copilot alias',
'cliproxy-bridge': 'Derived from profile 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<CliTarget, string> = {
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 (
<div className="rounded-md border bg-muted/20 p-4" aria-live="polite">
<div className="h-4 w-44 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-72 animate-pulse rounded bg-muted" />
<p className="mt-3 text-sm text-muted-foreground">Checking backend status...</p>
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-64 animate-pulse rounded bg-muted" />
</div>
);
}
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 (
<section className="rounded-md border bg-muted/20 p-4">
@@ -313,64 +199,50 @@ export function ImageAnalysisStatusSection({
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={previewBadge.variant} className="h-5 shrink-0 px-1.5 text-[10px]">
<Badge variant={previewBadge.variant} className="h-5 px-1.5 text-[10px]">
{previewBadge.label}
</Badge>
<Badge variant={runtimeBadge.variant} className="h-5 shrink-0 px-1.5 text-[10px]">
<Badge className={cn('h-5 border px-1.5 text-[10px]', runtimeBadge.className)}>
{runtimeBadge.label}
</Badge>
</div>
</div>
<p aria-live="polite" className="mt-3 text-sm leading-6 text-muted-foreground">
{getSummary(status, target)}
</p>
{bypassedOnCurrentTarget && (
<div className="mt-4 flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/10 px-3 py-2 text-sm text-sky-900 dark:text-sky-200">
<Route className="mt-0.5 h-4 w-4 shrink-0 text-sky-600 dark:text-sky-300" />
<span>
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.
</span>
</div>
)}
<p className="mt-3 text-sm leading-6 text-muted-foreground">{getSummary(status, target)}</p>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<div className="rounded-md border bg-background/70 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Configured backend
Backend
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{status.backendDisplayName || status.backendId || 'No backend mapped'}
{status.backendDisplayName || status.backendId || 'Native file access'}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{getConfiguredBackendDetail(status)}
{SOURCE_LABELS[status.resolutionSource]}
</p>
</div>
<div className="rounded-md border bg-background/70 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Effective runtime
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{getEffectiveRuntimeValue(status, target)}
Current target
</div>
<div className="mt-2 text-sm font-medium text-foreground">{TARGET_LABELS[target]}</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{getEffectiveRuntimeDetail(status, target)}
{getTargetDetail(status, target)}
</p>
</div>
<div className="rounded-md border bg-background/70 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Hook persistence
Persistence
</div>
<div className="mt-2 text-sm font-medium text-foreground">
{getPersistenceValue(status)}
</div>
<p
className="mt-1 text-xs leading-5 text-muted-foreground"
title={status.persistencePath || 'Not persisted'}
title={status.persistencePath || 'Not required'}
>
{getPersistenceDetail(status)}
</p>
@@ -382,49 +254,61 @@ export function ImageAnalysisStatusSection({
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Auth
</dt>
<dd className="text-sm text-foreground">{getAuthLine(status)}</dd>
<dd className="text-sm text-foreground">
{status.authReadiness === 'ready'
? `${status.authDisplayName || status.authProvider} ready`
: status.authReadiness === 'missing'
? status.authReason
: status.authReadiness === 'not-needed'
? 'Not required'
: 'Unknown'}
</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Proxy
</dt>
<dd className="text-sm text-foreground">{getProxyLine(status)}</dd>
<dd className="text-sm text-foreground">
{status.proxyReadiness === 'ready'
? 'Local CLIProxy ready'
: status.proxyReadiness === 'remote'
? 'Remote CLIProxy ready'
: status.proxyReadiness === 'stopped'
? 'Local CLIProxy idle'
: status.proxyReadiness === 'not-needed'
? 'Not required'
: status.proxyReason || 'Unknown'}
</dd>
</div>
<div className="space-y-1">
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Model
</dt>
<dd className={cn('text-sm text-foreground', status.model && 'font-mono text-xs')}>
{status.model || status.reason || 'Unavailable'}
{getModelValue(status)}
</dd>
</div>
</dl>
<div className="mt-4 rounded-md border bg-background/60 px-3 py-2">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Runtime path
</div>
<div
className="mt-1 font-mono text-xs leading-5 text-foreground"
title={getRuntimeLine(status, target)}
>
{getRuntimeLine(status, target)}
</div>
</div>
{notice && (
{showNotice && (
<div className="mt-4 flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<span>{notice}</span>
<span>
{status.effectiveRuntimeReason || status.reason || 'Review the saved configuration.'}
</span>
</div>
)}
<div className="mt-4 flex items-center gap-2 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<Route className="h-3.5 w-3.5" />
<span>
This panel covers the Image Analysis hook only. WebSearch stays managed separately and is
not controlled here.
</span>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3">
<p className="text-xs text-muted-foreground">
CRUD and backend routing now live in global Settings.
</p>
<Button size="sm" variant="outline" asChild>
<Link to="/settings?tab=imageanalysis">
Open Settings
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</div>
</section>
);
+74
View File
@@ -148,6 +148,72 @@ export interface ImageAnalysisStatus {
effectiveRuntimeReason: string | null;
}
export interface ImageAnalysisSettingsConfig {
enabled: boolean;
timeout: number;
providerModels: Record<string, string>;
fallbackBackend: string | null;
profileBackends: Record<string, string>;
}
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<string, string | null>;
fallbackBackend?: string | null;
profileBackends?: Record<string, string>;
}
export interface Profile {
name: string;
settingsPath: string;
@@ -843,6 +909,14 @@ export const api = {
body: JSON.stringify(data),
}),
},
imageAnalysis: {
get: () => request<ImageAnalysisDashboardData>('/image-analysis'),
update: (data: UpdateImageAnalysisSettingsPayload) =>
request<ImageAnalysisDashboardData>('/image-analysis', {
method: 'PUT',
body: JSON.stringify(data),
}),
},
cliproxy: {
list: () => request<{ variants: Variant[] }>('/cliproxy'),
getAuthStatus: () =>
@@ -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 (
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
<TabsList className="grid w-full grid-cols-7">
<TabsList className="grid w-full grid-cols-8">
{tabs.map(({ value, label, icon: Icon }) => (
<TabsTrigger key={value} value={value} className="gap-1.5 px-1 text-xs">
<Icon className="h-3.5 w-3.5 shrink-0" />
+15 -13
View File
@@ -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) => {
+3
View File
@@ -48,6 +48,7 @@ function lazyWithRetry<T extends ComponentType<unknown>>(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() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
@@ -155,6 +157,7 @@ function SettingsPageInner() {
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
{activeTab === 'channels' && <ChannelsSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
@@ -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<string, string> {
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<ImageAnalysisDashboardData>;
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<string, string>): 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<ImageAnalysisDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [enabled, setEnabled] = useState(true);
const [timeout, setTimeout] = useState('60');
const [fallbackBackend, setFallbackBackend] = useState('');
const [providerModels, setProviderModels] = useState<Record<string, string>>({});
const [mappingDrafts, setMappingDrafts] = useState<MappingDraft[]>([]);
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<string, string>
)
);
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<string, string | null>
);
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<string, string>
);
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<string, string | null>
),
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 (
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading Image Analysis settings...</span>
</div>
</div>
);
}
if (!data) {
return (
<div className="p-5">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error ?? 'Failed to load Image Analysis settings.'}</AlertDescription>
</Alert>
<div className="mt-4">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="mr-1 h-4 w-4" />
Retry
</Button>
</div>
</div>
);
}
return (
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
{(error || success) && (
<div className="space-y-2">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-emerald-900 dark:text-emerald-200">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">{success}</span>
</div>
)}
</div>
)}
<section className="rounded-xl border bg-background/95 p-4 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-sky-600" />
<h2 className="text-sm font-semibold">Image Analysis</h2>
<Badge className={cn('border', summaryToneClass(data.summary.state))}>
{data.summary.title}
</Badge>
</div>
<p className="max-w-2xl text-sm text-muted-foreground">{data.summary.detail}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading || saving}
>
<RefreshCw className={cn('mr-1 h-4 w-4', loading && 'animate-spin')} />
Refresh
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges || !canSave}>
<Save className="mr-1 h-4 w-4" />
Save changes
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border bg-muted/20 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Enabled
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-sm font-medium">{enabled ? 'On' : 'Off'}</span>
<Switch checked={enabled} onCheckedChange={setEnabled} disabled={saving} />
</div>
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Timeout
</div>
<div className="mt-2 flex items-center gap-2">
<Input
value={timeout}
onChange={(event) => setTimeout(event.target.value)}
inputMode="numeric"
className="h-9"
/>
<span className="text-xs text-muted-foreground">sec</span>
</div>
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Fallback backend
</div>
<div className="mt-2">
<Select
value={fallbackBackend || NO_BACKEND}
onValueChange={(value) => setFallbackBackend(value === NO_BACKEND ? '' : value)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Choose backend" />
</SelectTrigger>
<SelectContent>
{configuredBackendIds.length === 0 ? (
<SelectItem value={NO_BACKEND}>Configure a model first</SelectItem>
) : (
configuredBackendIds.map((backendId) => (
<SelectItem key={backendId} value={backendId}>
{backendId}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Coverage
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="outline">{data.summary.backendCount} backends</Badge>
<Badge variant="outline">{data.summary.mappedProfileCount} mapped</Badge>
<Badge variant="outline">{data.summary.bypassedProfileCount} bypassed</Badge>
</div>
</div>
</div>
</section>
<section className="rounded-xl border bg-background/95 p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">Provider models</h3>
<p className="text-sm text-muted-foreground">
One model per backend. Clear a model to remove that backend from Image Analysis.
</p>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{data.catalog.knownBackends.map((backendId) => {
const backendStatus = data.backends.find((item) => item.backendId === backendId);
const displayName = backendStatus?.displayName || backendId;
return (
<div key={backendId} className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{displayName}</div>
<div className="text-xs text-muted-foreground">{backendId}</div>
</div>
{backendStatus ? (
<Badge className={cn('border', backendStateClass(backendStatus.state))}>
{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'}
</Badge>
) : (
<Badge variant="outline">Inactive</Badge>
)}
</div>
<Input
className="mt-3 h-9"
placeholder="Set vision model"
value={providerModels[backendId] ?? ''}
onChange={(event) =>
setProviderModels((current) => ({
...current,
[backendId]: event.target.value,
}))
}
/>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>
Auth:{' '}
{backendStatus?.authReadiness === 'ready'
? 'ready'
: backendStatus?.authReadiness === 'missing'
? 'missing'
: 'n/a'}
</span>
<span>
Proxy:{' '}
{backendStatus?.proxyReadiness === 'remote'
? 'remote'
: backendStatus?.proxyReadiness === 'stopped'
? 'idle'
: (backendStatus?.proxyReadiness ?? 'n/a')}
</span>
<span>{backendStatus?.profilesUsing ?? 0} profiles use this backend</span>
</div>
</div>
);
})}
</div>
</section>
<section className="rounded-xl border bg-background/95 p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">Profile mappings</h3>
<p className="text-sm text-muted-foreground">
Use explicit mappings only when a profile should bypass the normal backend
resolution.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setMappingDrafts((current) => [
...current,
{
id: `mapping-${Date.now()}`,
profileName: '',
backendId: configuredBackendIds[0] ?? '',
},
])
}
disabled={configuredBackendIds.length === 0}
>
<Plus className="mr-1 h-4 w-4" />
Add mapping
</Button>
</div>
<datalist id="image-analysis-profile-suggestions">
{data.catalog.profileNames.map((profileName) => (
<option key={profileName} value={profileName} />
))}
</datalist>
<div className="mt-4 space-y-3">
{mappingDrafts.length === 0 ? (
<div className="rounded-lg border border-dashed px-4 py-5 text-sm text-muted-foreground">
No explicit profile mappings saved. Profiles follow provider and fallback
resolution.
</div>
) : (
mappingDrafts.map((row) => (
<div
key={row.id}
className="grid gap-3 rounded-lg border bg-muted/20 p-3 md:grid-cols-[1.4fr_1fr_auto]"
>
<Input
value={row.profileName}
list="image-analysis-profile-suggestions"
placeholder="Profile or variant name"
onChange={(event) =>
setMappingDrafts((current) =>
current.map((entry) =>
entry.id === row.id
? { ...entry, profileName: event.target.value }
: entry
)
)
}
/>
<Select
value={row.backendId || NO_BACKEND}
onValueChange={(value) =>
setMappingDrafts((current) =>
current.map((entry) =>
entry.id === row.id
? { ...entry, backendId: value === NO_BACKEND ? '' : value }
: entry
)
)
}
>
<SelectTrigger>
<SelectValue placeholder="Choose backend" />
</SelectTrigger>
<SelectContent>
{configuredBackendIds.length === 0 ? (
<SelectItem value={NO_BACKEND}>Configure a model first</SelectItem>
) : (
configuredBackendIds.map((backendId) => (
<SelectItem key={backendId} value={backendId}>
{backendId}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
setMappingDrafts((current) => current.filter((entry) => entry.id !== row.id))
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
</div>
</section>
<section className="rounded-xl border bg-background/95 p-4 shadow-sm">
<div>
<h3 className="text-sm font-semibold">Profile coverage</h3>
<p className="text-sm text-muted-foreground">
Quick read-only view of which saved profiles can use Image Analysis on their current
target.
</p>
</div>
<div className="mt-4 overflow-x-auto rounded-lg border">
<div className="min-w-[640px]">
<div className="grid grid-cols-[1.5fr_100px_1.2fr_120px] gap-3 bg-muted/40 px-3 py-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
<span>Profile</span>
<span>Target</span>
<span>Backend</span>
<span>Current path</span>
</div>
<div className="divide-y">
{data.profiles.map((profile) => (
<div
key={`${profile.kind}-${profile.name}`}
className="grid grid-cols-[1.5fr_100px_1.2fr_120px] gap-3 px-3 py-3 text-sm"
>
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{profile.name}</div>
<div className="truncate text-xs text-muted-foreground">
{profile.kind === 'variant' ? 'CLIProxy variant' : 'Settings profile'}
</div>
</div>
<div className="text-muted-foreground">{profile.target}</div>
<div className="min-w-0">
<div className="truncate">
{profile.backendDisplayName || 'Native file access'}
</div>
<div className="truncate text-xs text-muted-foreground">
{profile.resolutionSource.replace(/-/g, ' ')}
</div>
</div>
<div>
<Badge
className={cn('border', currentTargetModeClass(profile.currentTargetMode))}
>
{currentTargetModeLabel(profile.currentTargetMode)}
</Badge>
</div>
</div>
))}
</div>
</div>
</div>
</section>
</div>
</ScrollArea>
);
}
+1
View File
@@ -161,6 +161,7 @@ export interface OfficialChannelsStatus {
export type SettingsTab =
| 'websearch'
| 'imageanalysis'
| 'channels'
| 'globalenv'
| 'proxy'
@@ -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(<ImageAnalysisStatusSection status={createStatus()} />);
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(
<ImageAnalysisStatusSection
status={createStatus({
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
resolutionSource: 'profile-backend',
authReadiness: 'ready',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
})}
/>
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(
<ImageAnalysisStatusSection
status={createStatus({
status: 'hook-missing',
reason: 'Profile hook is missing from the persisted settings file.',
effectiveRuntimeMode: 'native-read',
effectiveRuntimeReason: 'Profile hook is missing from the persisted settings file.',
})}
/>
);
it('shows bypassed mode when the current target is not Claude Code', () => {
render(<ImageAnalysisStatusSection status={createStatus()} target="codex" />);
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(
<ImageAnalysisStatusSection
status={createStatus({
backendId: 'ghcp',
backendDisplayName: 'GitHub Copilot (OAuth)',
model: 'claude-haiku-4.5',
runtimePath: '/api/provider/ghcp',
authReadiness: 'missing',
authProvider: 'ghcp',
authDisplayName: 'GitHub Copilot (OAuth)',
authReason:
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
effectiveRuntimeMode: 'native-read',
effectiveRuntimeReason:
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
})}
/>
);
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(
<ImageAnalysisStatusSection
status={createStatus({
proxyReadiness: 'stopped',
proxyReason:
'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.',
})}
/>
);
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(<ImageAnalysisStatusSection status={createStatus()} target="droid" />);
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(
<ImageAnalysisStatusSection
status={createStatus({
@@ -243,35 +123,19 @@ describe('ImageAnalysisStatusSection', () => {
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(
<ImageAnalysisStatusSection
status={createStatus({
backendDisplayName: null,
backendId: 'ghcp',
})}
/>
);
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',
@@ -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<typeof vi.fn>;
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<string, string>;
providerModels?: Record<string, string | null>;
};
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(<ImageAnalysisSection />, { 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('<!doctype html><html></html>', {
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(<ImageAnalysisSection />, { 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();
});
});