mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(image-analysis): add dedicated dashboard settings
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user