mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 14:13:21 +00:00
feat(image-analysis): add dedicated dashboard settings
This commit is contained in:
1 parent
0246e327fe
commit
9d1d281e34
12 files changed
+1851
-447
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user