mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-11 14:22:06 +00:00
feat(image-analysis): resolve backend status per profile
This commit is contained in:
@@ -64,6 +64,35 @@ export interface CliproxyBridgeMetadata {
|
|||||||
usesCurrentAuthToken: boolean;
|
usesCurrentAuthToken: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageAnalysisProfileStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
supported: boolean;
|
||||||
|
status: 'active' | 'mapped' | 'attention' | 'disabled' | 'skipped' | 'hook-missing';
|
||||||
|
backendId: string | null;
|
||||||
|
backendDisplayName: string | null;
|
||||||
|
model: string | null;
|
||||||
|
resolutionSource:
|
||||||
|
| 'cliproxy-provider'
|
||||||
|
| 'cliproxy-variant'
|
||||||
|
| 'cliproxy-composite'
|
||||||
|
| 'copilot-alias'
|
||||||
|
| 'cliproxy-bridge'
|
||||||
|
| 'profile-backend'
|
||||||
|
| 'fallback-backend'
|
||||||
|
| 'disabled'
|
||||||
|
| 'unsupported-profile'
|
||||||
|
| 'unresolved'
|
||||||
|
| 'missing-model';
|
||||||
|
reason: string | null;
|
||||||
|
shouldPersistHook: boolean;
|
||||||
|
persistencePath: string | null;
|
||||||
|
runtimePath: string | null;
|
||||||
|
usesCurrentTarget: boolean | null;
|
||||||
|
usesCurrentAuthToken: boolean | null;
|
||||||
|
hookInstalled: boolean | null;
|
||||||
|
sharedHookInstalled: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolvedCliproxyBridgeProfile {
|
export interface ResolvedCliproxyBridgeProfile {
|
||||||
name: string;
|
name: string;
|
||||||
provider: CLIProxyProvider;
|
provider: CLIProxyProvider;
|
||||||
|
|||||||
+28
-8
@@ -33,7 +33,7 @@ import {
|
|||||||
} from './utils/websearch-manager';
|
} from './utils/websearch-manager';
|
||||||
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
||||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
|
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
|
||||||
import { getImageAnalysisHookEnv } from './utils/hooks';
|
import { getImageAnalysisHookEnv, installImageAnalyzerHook } from './utils/hooks';
|
||||||
import { fail, info, warn } from './utils/ui';
|
import { fail, info, warn } from './utils/ui';
|
||||||
import { isCopilotSubcommandToken } from './copilot/constants';
|
import { isCopilotSubcommandToken } from './copilot/constants';
|
||||||
import {
|
import {
|
||||||
@@ -682,10 +682,15 @@ async function main(): Promise<void> {
|
|||||||
if (resolvedTarget === 'claude') {
|
if (resolvedTarget === 'claude') {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
}
|
}
|
||||||
// Inject Image Analyzer hook into profile settings before launch
|
|
||||||
ensureImageAnalyzerHooks(profileInfo.name);
|
|
||||||
|
|
||||||
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
|
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
|
||||||
|
// Inject Image Analyzer hook into profile settings before launch
|
||||||
|
ensureImageAnalyzerHooks({
|
||||||
|
profileName: profileInfo.name,
|
||||||
|
profileType: profileInfo.type,
|
||||||
|
cliproxyProvider: provider,
|
||||||
|
isComposite: profileInfo.isComposite,
|
||||||
|
settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined,
|
||||||
|
});
|
||||||
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
|
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
|
||||||
const variantPort = profileInfo.port; // variant-specific port for isolation
|
const variantPort = profileInfo.port; // variant-specific port for isolation
|
||||||
const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT;
|
const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT;
|
||||||
@@ -839,8 +844,12 @@ async function main(): Promise<void> {
|
|||||||
} else if (profileInfo.type === 'copilot') {
|
} else if (profileInfo.type === 'copilot') {
|
||||||
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
|
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
installImageAnalyzerHook();
|
||||||
// Inject Image Analyzer hook into profile settings before launch
|
// Inject Image Analyzer hook into profile settings before launch
|
||||||
ensureImageAnalyzerHooks(profileInfo.name);
|
ensureImageAnalyzerHooks({
|
||||||
|
profileName: profileInfo.name,
|
||||||
|
profileType: profileInfo.type,
|
||||||
|
});
|
||||||
|
|
||||||
const { executeCopilotProfile } = await import('./copilot');
|
const { executeCopilotProfile } = await import('./copilot');
|
||||||
const copilotConfig = profileInfo.copilotConfig;
|
const copilotConfig = profileInfo.copilotConfig;
|
||||||
@@ -871,9 +880,8 @@ async function main(): Promise<void> {
|
|||||||
// Settings-based profiles (glm, glmt) are third-party providers
|
// Settings-based profiles (glm, glmt) are third-party providers
|
||||||
if (resolvedTarget === 'claude') {
|
if (resolvedTarget === 'claude') {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
installImageAnalyzerHook();
|
||||||
}
|
}
|
||||||
// Inject Image Analyzer hook into profile settings before launch
|
|
||||||
ensureImageAnalyzerHooks(profileInfo.name);
|
|
||||||
|
|
||||||
// Display WebSearch status (single line, equilibrium UX)
|
// Display WebSearch status (single line, equilibrium UX)
|
||||||
displayWebSearchStatus();
|
displayWebSearchStatus();
|
||||||
@@ -902,6 +910,13 @@ async function main(): Promise<void> {
|
|||||||
: getSettingsPath(profileInfo.name));
|
: getSettingsPath(profileInfo.name));
|
||||||
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
|
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
|
||||||
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
|
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
|
||||||
|
ensureImageAnalyzerHooks({
|
||||||
|
profileName: profileInfo.name,
|
||||||
|
profileType: profileInfo.type,
|
||||||
|
settingsPath: expandedSettingsPath,
|
||||||
|
settings,
|
||||||
|
cliproxyBridge,
|
||||||
|
});
|
||||||
if (resolvedTarget !== 'claude') {
|
if (resolvedTarget !== 'claude') {
|
||||||
const compatibility = evaluateTargetRuntimeCompatibility({
|
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||||
target: resolvedTarget,
|
target: resolvedTarget,
|
||||||
@@ -998,7 +1013,12 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const webSearchEnv = getWebSearchHookEnv();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name);
|
const imageAnalysisEnv = getImageAnalysisHookEnv({
|
||||||
|
profileName: profileInfo.name,
|
||||||
|
profileType: profileInfo.type,
|
||||||
|
settings,
|
||||||
|
cliproxyBridge,
|
||||||
|
});
|
||||||
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
||||||
const globalEnvConfig = getGlobalEnvConfig();
|
const globalEnvConfig = getGlobalEnvConfig();
|
||||||
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||||
|
|||||||
@@ -161,7 +161,12 @@ export function createSettingsFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inject Image Analyzer hooks into variant settings
|
// Inject Image Analyzer hooks into variant settings
|
||||||
ensureImageAnalyzerHooks(`${provider}-${name}`);
|
ensureImageAnalyzerHooks({
|
||||||
|
profileName: `${provider}-${name}`,
|
||||||
|
profileType: 'cliproxy',
|
||||||
|
cliproxyProvider: provider,
|
||||||
|
settingsPath,
|
||||||
|
});
|
||||||
|
|
||||||
return settingsPath;
|
return settingsPath;
|
||||||
}
|
}
|
||||||
@@ -195,7 +200,12 @@ export function createSettingsFileUnified(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inject Image Analyzer hooks into variant settings
|
// Inject Image Analyzer hooks into variant settings
|
||||||
ensureImageAnalyzerHooks(`${provider}-${name}`);
|
ensureImageAnalyzerHooks({
|
||||||
|
profileName: `${provider}-${name}`,
|
||||||
|
profileType: 'cliproxy',
|
||||||
|
cliproxyProvider: provider,
|
||||||
|
settingsPath,
|
||||||
|
});
|
||||||
|
|
||||||
return settingsPath;
|
return settingsPath;
|
||||||
}
|
}
|
||||||
@@ -284,7 +294,6 @@ export function createCompositeSettingsFile(
|
|||||||
ensureDir(settingsDir);
|
ensureDir(settingsDir);
|
||||||
writeSettings(settingsPath, settings);
|
writeSettings(settingsPath, settings);
|
||||||
|
|
||||||
// Hook injectors target ~/.ccs/<profile>.settings.json; only run for default path.
|
|
||||||
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
|
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
|
||||||
try {
|
try {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
@@ -292,7 +301,13 @@ export function createCompositeSettingsFile(
|
|||||||
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
ensureImageAnalyzerHooks(`composite-${name}`);
|
ensureImageAnalyzerHooks({
|
||||||
|
profileName: `composite-${name}`,
|
||||||
|
profileType: 'cliproxy',
|
||||||
|
cliproxyProvider: tiers[defaultTier].provider,
|
||||||
|
isComposite: true,
|
||||||
|
settingsPath,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return settingsPath;
|
return settingsPath;
|
||||||
|
|||||||
@@ -18,13 +18,19 @@ import {
|
|||||||
mapExternalProviderName,
|
mapExternalProviderName,
|
||||||
} from '../cliproxy/provider-capabilities';
|
} from '../cliproxy/provider-capabilities';
|
||||||
import { extractOption, hasAnyFlag } from './arg-extractor';
|
import { extractOption, hasAnyFlag } from './arg-extractor';
|
||||||
|
import { normalizeImageAnalysisBackendId } from '../utils/hooks';
|
||||||
|
|
||||||
interface ImageAnalysisCommandOptions {
|
interface ImageAnalysisCommandOptions {
|
||||||
enable?: boolean;
|
enable?: boolean;
|
||||||
disable?: boolean;
|
disable?: boolean;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
setModel?: { provider: string; model: string };
|
setModel?: { provider: string; model: string };
|
||||||
|
setFallback?: string;
|
||||||
|
setProfileBackend?: { profile: string; backend: string };
|
||||||
|
clearProfileBackend?: string;
|
||||||
setModelError?: string;
|
setModelError?: string;
|
||||||
|
setFallbackError?: string;
|
||||||
|
setProfileBackendError?: string;
|
||||||
help?: boolean;
|
help?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +68,36 @@ function parseArgs(args: string[]): ImageAnalysisCommandOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setFallbackIdx = args.indexOf('--set-fallback');
|
||||||
|
if (setFallbackIdx !== -1) {
|
||||||
|
const backend = args[setFallbackIdx + 1];
|
||||||
|
if (backend && !backend.startsWith('-')) {
|
||||||
|
options.setFallback = backend;
|
||||||
|
} else {
|
||||||
|
options.setFallbackError = '--set-fallback requires <backend>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setProfileBackendIdx = args.indexOf('--set-profile-backend');
|
||||||
|
if (setProfileBackendIdx !== -1) {
|
||||||
|
const profile = args[setProfileBackendIdx + 1];
|
||||||
|
const backend = args[setProfileBackendIdx + 2];
|
||||||
|
if (profile && backend && !profile.startsWith('-') && !backend.startsWith('-')) {
|
||||||
|
options.setProfileBackend = { profile, backend };
|
||||||
|
} else {
|
||||||
|
options.setProfileBackendError = '--set-profile-backend requires <profile> <backend>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearProfileBackend = extractOption(args, ['--clear-profile-backend']);
|
||||||
|
if (clearProfileBackend.found) {
|
||||||
|
if (clearProfileBackend.value && !clearProfileBackend.value.startsWith('-')) {
|
||||||
|
options.clearProfileBackend = clearProfileBackend.value;
|
||||||
|
} else {
|
||||||
|
options.setProfileBackendError = '--clear-profile-backend requires <profile>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +118,13 @@ function showHelp(): void {
|
|||||||
console.log(` ${color('--disable', 'command')} Disable image analysis`);
|
console.log(` ${color('--disable', 'command')} Disable image analysis`);
|
||||||
console.log(` ${color('--timeout <seconds>', 'command')} Set analysis timeout (10-600)`);
|
console.log(` ${color('--timeout <seconds>', 'command')} Set analysis timeout (10-600)`);
|
||||||
console.log(` ${color('--set-model <p> <m>', 'command')} Set model for provider`);
|
console.log(` ${color('--set-model <p> <m>', 'command')} Set model for provider`);
|
||||||
|
console.log(` ${color('--set-fallback <backend>', 'command')} Set fallback backend`);
|
||||||
|
console.log(
|
||||||
|
` ${color('--set-profile-backend <p> <b>', 'command')} Map a profile alias to a backend`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ${color('--clear-profile-backend <p>', 'command')} Remove a saved profile mapping`
|
||||||
|
);
|
||||||
console.log(` ${color('--help, -h', 'command')} Show this help`);
|
console.log(` ${color('--help, -h', 'command')} Show this help`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
@@ -157,6 +200,15 @@ function showStatus(): void {
|
|||||||
console.log(subheader('Configuration:'));
|
console.log(subheader('Configuration:'));
|
||||||
console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`);
|
console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`);
|
||||||
console.log(` Section: ${dim('image_analysis')}`);
|
console.log(` Section: ${dim('image_analysis')}`);
|
||||||
|
console.log(` Fallback backend: ${color(config.fallback_backend || 'none', 'command')}`);
|
||||||
|
const profileBackends = Object.entries(config.profile_backends ?? {});
|
||||||
|
if (profileBackends.length > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log(subheader('Profile Backends:'));
|
||||||
|
for (const [profile, backend] of profileBackends) {
|
||||||
|
console.log(` ${color(profile.padEnd(16), 'command')} ${backend}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Troubleshooting hint if disabled
|
// Troubleshooting hint if disabled
|
||||||
@@ -181,6 +233,16 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.setFallbackError) {
|
||||||
|
console.error(fail(options.setFallbackError));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.setProfileBackendError) {
|
||||||
|
console.error(fail(options.setProfileBackendError));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate conflicting flags (Edge case #2: --enable + --disable conflict)
|
// Validate conflicting flags (Edge case #2: --enable + --disable conflict)
|
||||||
if (options.enable && options.disable) {
|
if (options.enable && options.disable) {
|
||||||
console.error(fail('Cannot use --enable and --disable together'));
|
console.error(fail('Cannot use --enable and --disable together'));
|
||||||
@@ -229,6 +291,51 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
|
|||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.setFallback) {
|
||||||
|
const normalizedBackend = normalizeImageAnalysisBackendId(
|
||||||
|
options.setFallback,
|
||||||
|
Object.keys(imageConfig.provider_models)
|
||||||
|
);
|
||||||
|
if (!normalizedBackend) {
|
||||||
|
console.error(fail(`Invalid fallback backend: ${options.setFallback}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
imageConfig.fallback_backend = normalizedBackend;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.setProfileBackend) {
|
||||||
|
const profileName = options.setProfileBackend.profile.trim();
|
||||||
|
const normalizedBackend = normalizeImageAnalysisBackendId(
|
||||||
|
options.setProfileBackend.backend,
|
||||||
|
Object.keys(imageConfig.provider_models)
|
||||||
|
);
|
||||||
|
if (!profileName) {
|
||||||
|
console.error(fail('Profile name cannot be empty'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!normalizedBackend) {
|
||||||
|
console.error(fail(`Invalid backend: ${options.setProfileBackend.backend}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
imageConfig.profile_backends = {
|
||||||
|
...(imageConfig.profile_backends ?? {}),
|
||||||
|
[profileName]: normalizedBackend,
|
||||||
|
};
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.clearProfileBackend) {
|
||||||
|
const profileName = options.clearProfileBackend.trim().toLowerCase();
|
||||||
|
const nextProfileBackends = Object.fromEntries(
|
||||||
|
Object.entries(imageConfig.profile_backends ?? {}).filter(
|
||||||
|
([name]) => name.trim().toLowerCase() !== profileName
|
||||||
|
)
|
||||||
|
);
|
||||||
|
imageConfig.profile_backends = nextProfileBackends;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
updateUnifiedConfig({ image_analysis: imageConfig });
|
updateUnifiedConfig({ image_analysis: imageConfig });
|
||||||
console.log(ok('Configuration updated'));
|
console.log(ok('Configuration updated'));
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
normalizeOfficialChannelIds,
|
normalizeOfficialChannelIds,
|
||||||
resolveLegacyDiscordSelection,
|
resolveLegacyDiscordSelection,
|
||||||
} from '../channels/official-channels-runtime';
|
} from '../channels/official-channels-runtime';
|
||||||
|
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
|
||||||
|
|
||||||
const CONFIG_YAML = 'config.yaml';
|
const CONFIG_YAML = 'config.yaml';
|
||||||
const CONFIG_JSON = 'config.json';
|
const CONFIG_JSON = 'config.json';
|
||||||
@@ -556,12 +557,16 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
|||||||
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
|
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
|
||||||
},
|
},
|
||||||
// Image analysis config - enabled by default for CLIProxy providers
|
// Image analysis config - enabled by default for CLIProxy providers
|
||||||
image_analysis: {
|
image_analysis: canonicalizeImageAnalysisConfig({
|
||||||
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
||||||
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
|
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
|
||||||
provider_models:
|
provider_models:
|
||||||
partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
|
partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
|
||||||
},
|
fallback_backend:
|
||||||
|
partial.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
|
||||||
|
profile_backends:
|
||||||
|
partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1267,12 +1272,16 @@ export function getDashboardAuthConfig(): DashboardAuthConfig {
|
|||||||
export function getImageAnalysisConfig(): ImageAnalysisConfig {
|
export function getImageAnalysisConfig(): ImageAnalysisConfig {
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
|
||||||
return {
|
return canonicalizeImageAnalysisConfig({
|
||||||
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
||||||
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
|
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
|
||||||
provider_models:
|
provider_models:
|
||||||
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
|
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
|
||||||
};
|
fallback_backend:
|
||||||
|
config.image_analysis?.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
|
||||||
|
profile_backends:
|
||||||
|
config.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -759,6 +759,10 @@ export interface ImageAnalysisConfig {
|
|||||||
timeout: number;
|
timeout: number;
|
||||||
/** Provider-to-model mapping for vision analysis */
|
/** Provider-to-model mapping for vision analysis */
|
||||||
provider_models: Record<string, string>;
|
provider_models: Record<string, string>;
|
||||||
|
/** Fallback backend used when a profile does not resolve to a provider-specific backend */
|
||||||
|
fallback_backend?: string;
|
||||||
|
/** Explicit profile-name-to-backend overrides for settings/custom aliases */
|
||||||
|
profile_backends?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -780,6 +784,8 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
|
|||||||
iflow: 'qwen3-vl-plus',
|
iflow: 'qwen3-vl-plus',
|
||||||
kimi: 'vision-model',
|
kimi: 'vision-model',
|
||||||
},
|
},
|
||||||
|
fallback_backend: 'gemini',
|
||||||
|
profile_backends: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -165,7 +165,10 @@ export async function executeCopilotProfile(
|
|||||||
|
|
||||||
// Merge with current environment (global env first, copilot overrides, then hook env vars)
|
// Merge with current environment (global env first, copilot overrides, then hook env vars)
|
||||||
const webSearchEnv = getWebSearchHookEnv();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
const imageAnalysisEnv = getImageAnalysisHookEnv('copilot');
|
const imageAnalysisEnv = getImageAnalysisHookEnv({
|
||||||
|
profileName: 'copilot',
|
||||||
|
profileType: 'copilot',
|
||||||
|
});
|
||||||
const env = stripClaudeCodeEnv({
|
const env = stripClaudeCodeEnv({
|
||||||
...process.env,
|
...process.env,
|
||||||
...globalEnv,
|
...globalEnv,
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||||
|
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||||
|
import {
|
||||||
|
resolveImageAnalysisStatus,
|
||||||
|
type ImageAnalysisResolutionContext,
|
||||||
|
} from './image-analysis-backend-resolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize provider_models map to env var format: provider:model,provider:model
|
* Serialize provider_models map to env var format: provider:model,provider:model
|
||||||
@@ -22,21 +27,30 @@ function serializeProviderModels(providerModels: Record<string, string>): string
|
|||||||
* Get image analysis hook environment variables.
|
* Get image analysis hook environment variables.
|
||||||
* These env vars control the hook's behavior via Claude Code hook system.
|
* These env vars control the hook's behavior via Claude Code hook system.
|
||||||
*
|
*
|
||||||
* @param provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex')
|
* @param input - Current runtime context
|
||||||
* @returns Environment variables for image analysis hook
|
* @returns Environment variables for image analysis hook
|
||||||
*/
|
*/
|
||||||
export function getImageAnalysisHookEnv(provider?: string): Record<string, string> {
|
export function getImageAnalysisHookEnv(
|
||||||
|
input?: string | ImageAnalysisResolutionContext
|
||||||
|
): Record<string, string> {
|
||||||
const config = getImageAnalysisConfig();
|
const config = getImageAnalysisConfig();
|
||||||
|
const context =
|
||||||
// Check if current provider has a vision model configured
|
typeof input === 'string'
|
||||||
const hasVisionModel = provider && config.provider_models[provider];
|
? {
|
||||||
const skipImageAnalysis = !config.enabled || !hasVisionModel;
|
profileName: input,
|
||||||
|
cliproxyProvider: mapExternalProviderName(input) ?? undefined,
|
||||||
|
}
|
||||||
|
: input;
|
||||||
|
const status = context
|
||||||
|
? resolveImageAnalysisStatus(context, config)
|
||||||
|
: resolveImageAnalysisStatus({ profileName: '' }, config);
|
||||||
|
const skipImageAnalysis = !status.supported;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
|
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
|
||||||
CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60),
|
CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60),
|
||||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models),
|
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models),
|
||||||
CCS_CURRENT_PROVIDER: provider || '',
|
CCS_CURRENT_PROVIDER: status.backendId || '',
|
||||||
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
|
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||||
|
type ImageAnalysisConfig,
|
||||||
|
} from '../../config/unified-config-types';
|
||||||
|
import {
|
||||||
|
getProviderDisplayName,
|
||||||
|
isCLIProxyProvider,
|
||||||
|
mapExternalProviderName,
|
||||||
|
} from '../../cliproxy/provider-capabilities';
|
||||||
|
import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer';
|
||||||
|
import type { CliproxyBridgeMetadata } from '../../api/services/profile-types';
|
||||||
|
import type { Settings } from '../../types/config';
|
||||||
|
import type { ProfileType } from '../../types/profile';
|
||||||
|
|
||||||
|
export type ImageAnalysisResolutionSource =
|
||||||
|
| 'cliproxy-provider'
|
||||||
|
| 'cliproxy-variant'
|
||||||
|
| 'cliproxy-composite'
|
||||||
|
| 'copilot-alias'
|
||||||
|
| 'cliproxy-bridge'
|
||||||
|
| 'profile-backend'
|
||||||
|
| 'fallback-backend'
|
||||||
|
| 'disabled'
|
||||||
|
| 'unsupported-profile'
|
||||||
|
| 'unresolved'
|
||||||
|
| 'missing-model';
|
||||||
|
|
||||||
|
export type ImageAnalysisStatusCode =
|
||||||
|
| 'active'
|
||||||
|
| 'mapped'
|
||||||
|
| 'attention'
|
||||||
|
| 'disabled'
|
||||||
|
| 'skipped'
|
||||||
|
| 'hook-missing';
|
||||||
|
|
||||||
|
export interface ImageAnalysisResolutionContext {
|
||||||
|
profileName: string;
|
||||||
|
profileType?: ProfileType;
|
||||||
|
settingsPath?: string | null;
|
||||||
|
cliproxyProvider?: string | null;
|
||||||
|
isComposite?: boolean;
|
||||||
|
settings?: Pick<Settings, 'env'> | null;
|
||||||
|
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||||
|
hookInstalled?: boolean;
|
||||||
|
sharedHookInstalled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageAnalysisStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
supported: boolean;
|
||||||
|
status: ImageAnalysisStatusCode;
|
||||||
|
backendId: string | null;
|
||||||
|
backendDisplayName: string | null;
|
||||||
|
model: string | null;
|
||||||
|
resolutionSource: ImageAnalysisResolutionSource;
|
||||||
|
reason: string | null;
|
||||||
|
shouldPersistHook: boolean;
|
||||||
|
persistencePath: string | null;
|
||||||
|
runtimePath: string | null;
|
||||||
|
usesCurrentTarget: boolean | null;
|
||||||
|
usesCurrentAuthToken: boolean | null;
|
||||||
|
hookInstalled: boolean | null;
|
||||||
|
sharedHookInstalled: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProviderFromBaseUrl(baseUrl: unknown): string | null {
|
||||||
|
if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(baseUrl);
|
||||||
|
const extracted = extractProviderFromPathname(parsed.pathname);
|
||||||
|
return extracted ? mapExternalProviderName(extracted) : null;
|
||||||
|
} catch {
|
||||||
|
const extracted = extractProviderFromPathname(baseUrl);
|
||||||
|
return extracted ? mapExternalProviderName(extracted) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCaseInsensitiveKey(
|
||||||
|
entries: Record<string, string> | undefined,
|
||||||
|
requestedKey: string
|
||||||
|
): string | null {
|
||||||
|
if (!entries) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRequestedKey = requestedKey.trim().toLowerCase();
|
||||||
|
for (const key of Object.keys(entries)) {
|
||||||
|
if (key.trim().toLowerCase() === normalizedRequestedKey) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeImageAnalysisBackendId(
|
||||||
|
value: string | null | undefined,
|
||||||
|
knownBackends: Iterable<string> = []
|
||||||
|
): string | null {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const canonicalProvider = mapExternalProviderName(trimmed.toLowerCase());
|
||||||
|
if (canonicalProvider) {
|
||||||
|
return canonicalProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownBackendList = Array.from(knownBackends);
|
||||||
|
const exactKey = knownBackendList.find((backend) => backend === trimmed);
|
||||||
|
if (exactKey) {
|
||||||
|
return exactKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseInsensitiveKey = knownBackendList.find(
|
||||||
|
(backend) => backend.trim().toLowerCase() === trimmed.toLowerCase()
|
||||||
|
);
|
||||||
|
if (caseInsensitiveKey) {
|
||||||
|
return caseInsensitiveKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeImageAnalysisConfig(config: ImageAnalysisConfig): ImageAnalysisConfig {
|
||||||
|
const normalizedProviderModels = Object.entries(config.provider_models ?? {}).reduce(
|
||||||
|
(acc, [backend, model]) => {
|
||||||
|
const normalizedBackend = normalizeImageAnalysisBackendId(
|
||||||
|
backend,
|
||||||
|
Object.keys(DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models)
|
||||||
|
);
|
||||||
|
if (!normalizedBackend || typeof model !== 'string' || model.trim().length === 0) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[normalizedBackend] = model.trim();
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedFallbackBackend =
|
||||||
|
normalizeImageAnalysisBackendId(
|
||||||
|
config.fallback_backend ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend,
|
||||||
|
Object.keys(normalizedProviderModels)
|
||||||
|
) ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.fallback_backend;
|
||||||
|
|
||||||
|
const normalizedProfileBackends = Object.entries(config.profile_backends ?? {}).reduce(
|
||||||
|
(acc, [profileName, backend]) => {
|
||||||
|
const trimmedProfileName = profileName.trim();
|
||||||
|
const normalizedBackend = normalizeImageAnalysisBackendId(
|
||||||
|
backend,
|
||||||
|
Object.keys(normalizedProviderModels)
|
||||||
|
);
|
||||||
|
if (!trimmedProfileName || !normalizedBackend) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[trimmedProfileName] = normalizedBackend;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: config.enabled,
|
||||||
|
timeout: config.timeout,
|
||||||
|
provider_models: normalizedProviderModels,
|
||||||
|
fallback_backend: normalizedFallbackBackend,
|
||||||
|
profile_backends: normalizedProfileBackends,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredProfileBackend(
|
||||||
|
profileName: string,
|
||||||
|
config: ImageAnalysisConfig
|
||||||
|
): string | null {
|
||||||
|
if (!config.profile_backends) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactKey = config.profile_backends[profileName];
|
||||||
|
if (exactKey) {
|
||||||
|
return normalizeImageAnalysisBackendId(exactKey, Object.keys(config.provider_models));
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedKey = findCaseInsensitiveKey(config.profile_backends, profileName);
|
||||||
|
if (!matchedKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeImageAnalysisBackendId(
|
||||||
|
config.profile_backends[matchedKey],
|
||||||
|
Object.keys(config.provider_models)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackendDisplayName(backendId: string | null): string | null {
|
||||||
|
if (!backendId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isCLIProxyProvider(backendId) ? getProviderDisplayName(backendId) : backendId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimePath(backendId: string | null): string | null {
|
||||||
|
if (!backendId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/api/provider/${backendId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackend(
|
||||||
|
context: ImageAnalysisResolutionContext,
|
||||||
|
config: ImageAnalysisConfig
|
||||||
|
): Pick<ImageAnalysisStatus, 'backendId' | 'backendDisplayName' | 'resolutionSource' | 'reason'> {
|
||||||
|
const { profileName, profileType, cliproxyProvider, isComposite, cliproxyBridge, settings } =
|
||||||
|
context;
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return {
|
||||||
|
backendId: null,
|
||||||
|
backendDisplayName: null,
|
||||||
|
resolutionSource: 'disabled',
|
||||||
|
reason: 'Disabled globally.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileType === 'default' || profileType === 'account') {
|
||||||
|
return {
|
||||||
|
backendId: null,
|
||||||
|
backendDisplayName: null,
|
||||||
|
resolutionSource: 'unsupported-profile',
|
||||||
|
reason: 'This profile type is not currently covered by image-analysis runtime.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileType === 'copilot' || profileName === 'copilot') {
|
||||||
|
const backendId = normalizeImageAnalysisBackendId('ghcp', Object.keys(config.provider_models));
|
||||||
|
return {
|
||||||
|
backendId,
|
||||||
|
backendDisplayName: getBackendDisplayName(backendId),
|
||||||
|
resolutionSource: 'copilot-alias',
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCliproxyProvider = normalizeImageAnalysisBackendId(
|
||||||
|
cliproxyProvider,
|
||||||
|
Object.keys(config.provider_models)
|
||||||
|
);
|
||||||
|
if (normalizedCliproxyProvider) {
|
||||||
|
return {
|
||||||
|
backendId: normalizedCliproxyProvider,
|
||||||
|
backendDisplayName: getBackendDisplayName(normalizedCliproxyProvider),
|
||||||
|
resolutionSource:
|
||||||
|
isComposite || profileName.startsWith('composite-')
|
||||||
|
? 'cliproxy-composite'
|
||||||
|
: profileName === normalizedCliproxyProvider
|
||||||
|
? 'cliproxy-provider'
|
||||||
|
: 'cliproxy-variant',
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridgeBackend = normalizeImageAnalysisBackendId(
|
||||||
|
cliproxyBridge?.provider ??
|
||||||
|
resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL ?? undefined),
|
||||||
|
Object.keys(config.provider_models)
|
||||||
|
);
|
||||||
|
if (bridgeBackend) {
|
||||||
|
return {
|
||||||
|
backendId: bridgeBackend,
|
||||||
|
backendDisplayName: getBackendDisplayName(bridgeBackend),
|
||||||
|
resolutionSource: 'cliproxy-bridge',
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedBackend = resolveConfiguredProfileBackend(profileName, config);
|
||||||
|
if (mappedBackend) {
|
||||||
|
return {
|
||||||
|
backendId: mappedBackend,
|
||||||
|
backendDisplayName: getBackendDisplayName(mappedBackend),
|
||||||
|
resolutionSource: 'profile-backend',
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackBackend = normalizeImageAnalysisBackendId(
|
||||||
|
config.fallback_backend,
|
||||||
|
Object.keys(config.provider_models)
|
||||||
|
);
|
||||||
|
if (fallbackBackend) {
|
||||||
|
return {
|
||||||
|
backendId: fallbackBackend,
|
||||||
|
backendDisplayName: getBackendDisplayName(fallbackBackend),
|
||||||
|
resolutionSource: 'fallback-backend',
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
backendId: null,
|
||||||
|
backendDisplayName: null,
|
||||||
|
resolutionSource: 'unresolved',
|
||||||
|
reason: 'No supported backend could be resolved.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveImageAnalysisStatus(
|
||||||
|
context: ImageAnalysisResolutionContext,
|
||||||
|
rawConfig: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG
|
||||||
|
): ImageAnalysisStatus {
|
||||||
|
const config = canonicalizeImageAnalysisConfig(rawConfig);
|
||||||
|
const resolution = resolveBackend(context, config);
|
||||||
|
const model = resolution.backendId
|
||||||
|
? (config.provider_models[resolution.backendId] ?? null)
|
||||||
|
: null;
|
||||||
|
const shouldPersistHook =
|
||||||
|
config.enabled && context.profileType !== 'default' && context.profileType !== 'account';
|
||||||
|
|
||||||
|
let status: ImageAnalysisStatusCode = 'active';
|
||||||
|
let reason = resolution.reason;
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
status = 'disabled';
|
||||||
|
reason ??=
|
||||||
|
'This profile falls back to native Read because image analysis is turned off in CCS config.';
|
||||||
|
} else if (!resolution.backendId) {
|
||||||
|
status = 'skipped';
|
||||||
|
reason ??= 'No supported backend could be resolved.';
|
||||||
|
} else if (!model) {
|
||||||
|
status = 'skipped';
|
||||||
|
reason = 'Resolved backend has no image-analysis model configured.';
|
||||||
|
} else if (
|
||||||
|
shouldPersistHook &&
|
||||||
|
(context.hookInstalled === false || context.sharedHookInstalled === false)
|
||||||
|
) {
|
||||||
|
status = 'hook-missing';
|
||||||
|
reason =
|
||||||
|
context.sharedHookInstalled === false
|
||||||
|
? 'Shared image-analysis hook is not installed.'
|
||||||
|
: 'Profile hook is missing from the persisted settings file.';
|
||||||
|
} else if (
|
||||||
|
resolution.resolutionSource === 'cliproxy-bridge' &&
|
||||||
|
context.cliproxyBridge &&
|
||||||
|
(!context.cliproxyBridge.usesCurrentTarget || !context.cliproxyBridge.usesCurrentAuthToken)
|
||||||
|
) {
|
||||||
|
status = 'attention';
|
||||||
|
reason =
|
||||||
|
'Active, but runtime uses the current CLIProxy target instead of the stale route saved in this profile.';
|
||||||
|
} else if (resolution.resolutionSource === 'profile-backend') {
|
||||||
|
status = 'mapped';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: config.enabled,
|
||||||
|
supported: Boolean(config.enabled && resolution.backendId && model),
|
||||||
|
status,
|
||||||
|
backendId: resolution.backendId,
|
||||||
|
backendDisplayName: resolution.backendDisplayName,
|
||||||
|
model,
|
||||||
|
resolutionSource: resolution.resolutionSource,
|
||||||
|
reason,
|
||||||
|
shouldPersistHook,
|
||||||
|
persistencePath: shouldPersistHook ? `${context.profileName}.settings.json` : null,
|
||||||
|
runtimePath: getRuntimePath(resolution.backendId),
|
||||||
|
usesCurrentTarget: context.cliproxyBridge?.usesCurrentTarget ?? null,
|
||||||
|
usesCurrentAuthToken: context.cliproxyBridge?.usesCurrentAuthToken ?? null,
|
||||||
|
hookInstalled: context.hookInstalled ?? null,
|
||||||
|
sharedHookInstalled: context.sharedHookInstalled ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Injects image analyzer hooks into per-profile settings files.
|
* Injects image analyzer hooks into per-profile settings files.
|
||||||
* This replaces the global ~/.claude/settings.json approach.
|
* This replaces the global ~/.claude/settings.json approach.
|
||||||
*
|
*
|
||||||
* Injects for profiles configured in image_analysis.provider_models.
|
* Injects for profiles that resolve to a supported image-analysis backend.
|
||||||
*
|
*
|
||||||
* @module utils/hooks/image-analyzer-profile-injector
|
* @module utils/hooks/image-analyzer-profile-injector
|
||||||
*/
|
*/
|
||||||
@@ -18,9 +18,13 @@ import {
|
|||||||
} from './image-analyzer-hook-configuration';
|
} from './image-analyzer-hook-configuration';
|
||||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||||
import { getCcsDir } from '../config-manager';
|
import { getCcsDir } from '../config-manager';
|
||||||
|
import {
|
||||||
|
resolveImageAnalysisStatus,
|
||||||
|
type ImageAnalysisResolutionContext,
|
||||||
|
} from './image-analysis-backend-resolver';
|
||||||
|
|
||||||
// Valid profile name pattern (alphanumeric, dash, underscore only)
|
// Valid profile name pattern (alphanumeric, dot, dash, underscore only)
|
||||||
const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
|
const VALID_PROFILE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get migration marker path (respects CCS_HOME for test isolation)
|
* Get migration marker path (respects CCS_HOME for test isolation)
|
||||||
@@ -51,6 +55,39 @@ function hasCcsHook(settings: Record<string, unknown>): boolean {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getImageAnalysisProfileSettingsPath(
|
||||||
|
profileName: string,
|
||||||
|
settingsPath?: string | null
|
||||||
|
): string {
|
||||||
|
if (typeof settingsPath === 'string' && settingsPath.trim().length > 0) {
|
||||||
|
return settingsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(getCcsDir(), `${profileName}.settings.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasImageAnalysisProfileHook(
|
||||||
|
profileName: string,
|
||||||
|
settingsPath?: string | null
|
||||||
|
): boolean {
|
||||||
|
if (!VALID_PROFILE_NAME.test(profileName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSettingsPath = getImageAnalysisProfileSettingsPath(profileName, settingsPath);
|
||||||
|
if (!fs.existsSync(resolvedSettingsPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(resolvedSettingsPath, 'utf8');
|
||||||
|
const settings = JSON.parse(content) as Record<string, unknown>;
|
||||||
|
return hasCcsHook(settings);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time migration marker management
|
* One-time migration marker management
|
||||||
*/
|
*/
|
||||||
@@ -79,13 +116,14 @@ function migrateGlobalHook(): void {
|
|||||||
/**
|
/**
|
||||||
* Ensure image analyzer hook is configured in profile's settings file
|
* Ensure image analyzer hook is configured in profile's settings file
|
||||||
*
|
*
|
||||||
* Only injects for CLIProxy profiles with vision support (agy, gemini).
|
* @param input - Profile name or pre-resolved runtime context
|
||||||
*
|
|
||||||
* @param profileName - Name of the profile (e.g., 'agy', 'gemini')
|
|
||||||
* @returns true if hook is configured (existing or newly added)
|
* @returns true if hook is configured (existing or newly added)
|
||||||
*/
|
*/
|
||||||
export function ensureProfileHooks(profileName: string): boolean {
|
export function ensureProfileHooks(input: string | ImageAnalysisResolutionContext): boolean {
|
||||||
try {
|
try {
|
||||||
|
const context = typeof input === 'string' ? { profileName: input } : input;
|
||||||
|
const profileName = context.profileName;
|
||||||
|
|
||||||
// Validate profile name to prevent path traversal
|
// Validate profile name to prevent path traversal
|
||||||
if (!VALID_PROFILE_NAME.test(profileName)) {
|
if (!VALID_PROFILE_NAME.test(profileName)) {
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
@@ -95,16 +133,8 @@ export function ensureProfileHooks(profileName: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageConfig = getImageAnalysisConfig();
|
const imageConfig = getImageAnalysisConfig();
|
||||||
|
const status = resolveImageAnalysisStatus(context, imageConfig);
|
||||||
// Only inject for profiles that have a model mapping in provider_models
|
if (!status.supported || !status.shouldPersistHook) {
|
||||||
// This allows dynamic extension without hardcoding profile names
|
|
||||||
const configuredProviders = Object.keys(imageConfig.provider_models);
|
|
||||||
if (!configuredProviders.includes(profileName)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if image analysis is disabled
|
|
||||||
if (!imageConfig.enabled) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +149,7 @@ export function ensureProfileHooks(profileName: string): boolean {
|
|||||||
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsPath = path.join(ccsDir, `${profileName}.settings.json`);
|
const settingsPath = getImageAnalysisProfileSettingsPath(profileName, context.settingsPath);
|
||||||
|
|
||||||
// Read existing settings or create empty
|
// Read existing settings or create empty
|
||||||
let settings: Record<string, unknown> = {};
|
let settings: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env';
|
export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env';
|
||||||
|
export {
|
||||||
|
canonicalizeImageAnalysisConfig,
|
||||||
|
resolveImageAnalysisStatus,
|
||||||
|
normalizeImageAnalysisBackendId,
|
||||||
|
type ImageAnalysisResolutionContext,
|
||||||
|
type ImageAnalysisStatus,
|
||||||
|
} from './image-analysis-backend-resolver';
|
||||||
export {
|
export {
|
||||||
getImageAnalyzerHookPath,
|
getImageAnalyzerHookPath,
|
||||||
getImageAnalyzerHookConfig,
|
getImageAnalyzerHookConfig,
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
|
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as lockfile from 'proper-lockfile';
|
import * as lockfile from 'proper-lockfile';
|
||||||
import { getCcsDir, loadSettings } from '../../utils/config-manager';
|
import { getCcsDir, loadConfigSafe, loadSettings } from '../../utils/config-manager';
|
||||||
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
|
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
|
||||||
import { listVariants } from '../../cliproxy/services/variant-service';
|
import { listVariants } from '../../cliproxy/services/variant-service';
|
||||||
import {
|
import {
|
||||||
@@ -21,17 +20,28 @@ import {
|
|||||||
import { regenerateConfig } from '../../cliproxy/config-generator';
|
import { regenerateConfig } from '../../cliproxy/config-generator';
|
||||||
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
||||||
import { resolveCliproxyBridgeMetadata } from '../../api/services';
|
import { resolveCliproxyBridgeMetadata } from '../../api/services';
|
||||||
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
import {
|
||||||
|
getImageAnalysisConfig,
|
||||||
|
loadOrCreateUnifiedConfig,
|
||||||
|
mutateUnifiedConfig,
|
||||||
|
} from '../../config/unified-config-loader';
|
||||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||||
import type { Settings } from '../../types/config';
|
import type { Settings } from '../../types/config';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||||
|
import { expandPath } from '../../utils/helpers';
|
||||||
import {
|
import {
|
||||||
canonicalizeModelIdForProvider,
|
canonicalizeModelIdForProvider,
|
||||||
extractProviderFromPathname,
|
extractProviderFromPathname,
|
||||||
getDeniedModelIdReasonForProvider,
|
getDeniedModelIdReasonForProvider,
|
||||||
} from '../../cliproxy/model-id-normalizer';
|
} from '../../cliproxy/model-id-normalizer';
|
||||||
import { createRouteErrorHelpers } from './route-helpers';
|
import { createRouteErrorHelpers } from './route-helpers';
|
||||||
|
import {
|
||||||
|
getImageAnalysisProfileSettingsPath,
|
||||||
|
hasImageAnalysisProfileHook,
|
||||||
|
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
||||||
|
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
|
||||||
|
import { resolveImageAnalysisStatus } from '../../utils/hooks';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const MODEL_ENV_KEYS = [
|
const MODEL_ENV_KEYS = [
|
||||||
@@ -94,8 +104,16 @@ function resolveSettingsPath(profileOrVariant: string): string {
|
|||||||
const variants = listVariants();
|
const variants = listVariants();
|
||||||
const variant = variants[profileOrVariant];
|
const variant = variants[profileOrVariant];
|
||||||
if (variant?.settings) {
|
if (variant?.settings) {
|
||||||
// Variant settings path (e.g., ~/.ccs/agy-g3.settings.json)
|
return path.resolve(expandPath(variant.settings));
|
||||||
return resolvePathWithin(resolvedCcsDir, variant.settings.replace(/^~/, os.homedir()));
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant];
|
||||||
|
if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) {
|
||||||
|
return path.resolve(expandPath(configuredSettingsPath));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the conventional ~/.ccs/<profile>.settings.json path below.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular profile settings
|
// Regular profile settings
|
||||||
@@ -251,6 +269,40 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting
|
|||||||
return changed ? next : settings;
|
return changed ? next : settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveImageAnalysisStatusForProfile(
|
||||||
|
profileOrVariant: string,
|
||||||
|
settings: Settings,
|
||||||
|
settingsPath: string
|
||||||
|
) {
|
||||||
|
const variants = listVariants();
|
||||||
|
const variant = variants[profileOrVariant];
|
||||||
|
const cliproxyProvider = resolveProviderForProfile(profileOrVariant);
|
||||||
|
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
|
||||||
|
const status = resolveImageAnalysisStatus(
|
||||||
|
{
|
||||||
|
profileName: profileOrVariant,
|
||||||
|
profileType: cliproxyProvider ? 'cliproxy' : 'settings',
|
||||||
|
cliproxyProvider,
|
||||||
|
isComposite: Boolean(
|
||||||
|
variant && 'type' in variant && (variant as { type?: string }).type === 'composite'
|
||||||
|
),
|
||||||
|
settingsPath,
|
||||||
|
settings,
|
||||||
|
cliproxyBridge,
|
||||||
|
hookInstalled: hasImageAnalysisProfileHook(profileOrVariant, settingsPath),
|
||||||
|
sharedHookInstalled: hasImageAnalyzerHook(),
|
||||||
|
},
|
||||||
|
getImageAnalysisConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
persistencePath: status.shouldPersistHook
|
||||||
|
? getImageAnalysisProfileSettingsPath(profileOrVariant, settingsPath)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
|
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
|
||||||
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||||
@@ -338,6 +390,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
|||||||
mtime: stat.mtime.getTime(),
|
mtime: stat.mtime.getTime(),
|
||||||
path: settingsPath,
|
path: settingsPath,
|
||||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||||
|
imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
respondInternalError(res, error, 'Internal server error.');
|
respondInternalError(res, error, 'Internal server error.');
|
||||||
@@ -368,6 +421,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
|||||||
mtime: stat.mtime.getTime(),
|
mtime: stat.mtime.getTime(),
|
||||||
path: settingsPath,
|
path: settingsPath,
|
||||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||||
|
imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
respondInternalError(res, error, 'Internal server error.');
|
respondInternalError(res, error, 'Internal server error.');
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { AlertTriangle, Image as ImageIcon, Route } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ImageAnalysisStatus } from '@/lib/api-client';
|
||||||
|
|
||||||
|
interface ImageAnalysisStatusSectionProps {
|
||||||
|
status?: ImageAnalysisStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBadge(status: ImageAnalysisStatus | null | undefined): {
|
||||||
|
label: string;
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
} {
|
||||||
|
switch (status?.status) {
|
||||||
|
case 'active':
|
||||||
|
return { label: 'Ready', variant: 'default' };
|
||||||
|
case 'mapped':
|
||||||
|
return { label: 'Saved mapping', variant: 'secondary' };
|
||||||
|
case 'attention':
|
||||||
|
return { label: 'Needs review', variant: 'outline' };
|
||||||
|
case 'disabled':
|
||||||
|
return { label: 'Disabled', variant: 'outline' };
|
||||||
|
case 'hook-missing':
|
||||||
|
return { label: 'Setup needed', variant: 'destructive' };
|
||||||
|
case 'skipped':
|
||||||
|
return { label: 'Not available', variant: 'outline' };
|
||||||
|
default:
|
||||||
|
return { label: 'Checking', variant: 'outline' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummary(status: ImageAnalysisStatus): string {
|
||||||
|
const backendName = status.backendDisplayName || status.backendId || 'this backend';
|
||||||
|
|
||||||
|
switch (status.status) {
|
||||||
|
case 'disabled':
|
||||||
|
return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off.";
|
||||||
|
case 'mapped':
|
||||||
|
return `Ready via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config.`;
|
||||||
|
case 'attention':
|
||||||
|
return `Ready via ${backendName}, but runtime is using the current CLIProxy route instead of the route saved in this profile.`;
|
||||||
|
case 'hook-missing':
|
||||||
|
return `Configured for ${backendName}, but the image-analysis hook is not fully installed yet.`;
|
||||||
|
case 'skipped':
|
||||||
|
return status.reason || 'Skipped for this profile.';
|
||||||
|
case 'active':
|
||||||
|
default:
|
||||||
|
if (status.resolutionSource === 'cliproxy-bridge') {
|
||||||
|
return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.resolutionSource === 'fallback-backend') {
|
||||||
|
return `Ready via ${backendName} fallback. Images and PDFs are routed through CLIProxy before Claude sees text.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimeLine(status: ImageAnalysisStatus): string {
|
||||||
|
if (!status.runtimePath) {
|
||||||
|
return 'Read -> native file access';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Read -> image-analysis hook -> ${status.runtimePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersistenceLine(status: ImageAnalysisStatus): string {
|
||||||
|
if (!status.shouldPersistHook || !status.persistencePath) {
|
||||||
|
return 'Not persisted for this profile type';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.hookInstalled) {
|
||||||
|
return `${status.persistencePath} hook`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${status.persistencePath} hook missing`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectionProps) {
|
||||||
|
if (!status) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = getBadge(status);
|
||||||
|
const detailLabel = status.supported ? 'Model' : 'Reason';
|
||||||
|
const detailValue = status.supported ? status.model : status.reason || 'Unavailable';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-md border bg-muted/20 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-4 w-4 text-sky-600" />
|
||||||
|
<h3 className="text-sm font-semibold">Image-analysis backend</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Derived runtime status. This section is not written into the JSON editor above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={badge.variant} className="shrink-0 text-[11px]">
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p aria-live="polite" className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||||
|
{getSummary(status)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl className="mt-4 grid gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Backend
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-medium">{status.backendDisplayName || 'Unresolved'}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Runtime
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className="font-mono text-xs leading-5 text-foreground"
|
||||||
|
title={getRuntimeLine(status)}
|
||||||
|
>
|
||||||
|
{getRuntimeLine(status)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Persistence
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className="font-mono text-xs leading-5 text-foreground"
|
||||||
|
title={status.persistencePath || 'Not persisted'}
|
||||||
|
>
|
||||||
|
{getPersistenceLine(status)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{detailLabel}
|
||||||
|
</dt>
|
||||||
|
<dd className={cn('text-sm text-foreground', status.supported && 'font-mono text-xs')}>
|
||||||
|
{detailValue}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{(status.status === 'attention' || status.status === 'hook-missing') && (
|
||||||
|
<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>{status.reason}</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>
|
||||||
|
WebSearch stays managed separately and is not controlled by this backend status.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -254,6 +254,7 @@ export function ProfileEditor({
|
|||||||
isRawJsonValid={computedIsRawJsonValid}
|
isRawJsonValid={computedIsRawJsonValid}
|
||||||
rawJsonEdits={rawJsonEdits}
|
rawJsonEdits={rawJsonEdits}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
imageAnalysisStatus={data?.imageAnalysisStatus}
|
||||||
onChange={handleRawJsonChange}
|
onChange={handleRawJsonChange}
|
||||||
missingRequiredFields={missingRequiredFields}
|
missingRequiredFields={missingRequiredFields}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
import { Suspense, lazy } from 'react';
|
import { Suspense, lazy } from 'react';
|
||||||
import { Loader2, X, AlertTriangle } from 'lucide-react';
|
import { Loader2, X, AlertTriangle } from 'lucide-react';
|
||||||
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
||||||
|
import { ImageAnalysisStatusSection } from './image-analysis-status-section';
|
||||||
import type { Settings } from './types';
|
import type { Settings } from './types';
|
||||||
|
import type { ImageAnalysisStatus } from '@/lib/api-client';
|
||||||
|
|
||||||
// Lazy load CodeEditor
|
// Lazy load CodeEditor
|
||||||
const CodeEditor = lazy(() =>
|
const CodeEditor = lazy(() =>
|
||||||
@@ -18,6 +20,7 @@ interface RawEditorSectionProps {
|
|||||||
isRawJsonValid: boolean;
|
isRawJsonValid: boolean;
|
||||||
rawJsonEdits: string | null;
|
rawJsonEdits: string | null;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
|
imageAnalysisStatus?: ImageAnalysisStatus | null;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
missingRequiredFields?: string[];
|
missingRequiredFields?: string[];
|
||||||
}
|
}
|
||||||
@@ -27,6 +30,7 @@ export function RawEditorSection({
|
|||||||
isRawJsonValid,
|
isRawJsonValid,
|
||||||
rawJsonEdits,
|
rawJsonEdits,
|
||||||
settings,
|
settings,
|
||||||
|
imageAnalysisStatus,
|
||||||
onChange,
|
onChange,
|
||||||
missingRequiredFields = [],
|
missingRequiredFields = [],
|
||||||
}: RawEditorSectionProps) {
|
}: RawEditorSectionProps) {
|
||||||
@@ -75,6 +79,9 @@ export function RawEditorSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mx-6 mb-4">
|
||||||
|
<ImageAnalysisStatusSection status={imageAnalysisStatus} />
|
||||||
|
</div>
|
||||||
{/* Global Env Indicator */}
|
{/* Global Env Indicator */}
|
||||||
<div className="mx-6 mb-4">
|
<div className="mx-6 mb-4">
|
||||||
<div className="border rounded-md overflow-hidden">
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Types for Profile Editor
|
* Types for Profile Editor
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CliTarget, CliproxyBridgeMetadata } from '@/lib/api-client';
|
import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/lib/api-client';
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
@@ -14,6 +14,7 @@ export interface SettingsResponse {
|
|||||||
mtime: number;
|
mtime: number;
|
||||||
path: string;
|
path: string;
|
||||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||||
|
imageAnalysisStatus?: ImageAnalysisStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileEditorProps {
|
export interface ProfileEditorProps {
|
||||||
|
|||||||
@@ -111,6 +111,35 @@ export interface CliproxyBridgeMetadata {
|
|||||||
usesCurrentAuthToken: boolean;
|
usesCurrentAuthToken: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageAnalysisStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
supported: boolean;
|
||||||
|
status: 'active' | 'mapped' | 'attention' | 'disabled' | 'skipped' | 'hook-missing';
|
||||||
|
backendId: string | null;
|
||||||
|
backendDisplayName: string | null;
|
||||||
|
model: string | null;
|
||||||
|
resolutionSource:
|
||||||
|
| 'cliproxy-provider'
|
||||||
|
| 'cliproxy-variant'
|
||||||
|
| 'cliproxy-composite'
|
||||||
|
| 'copilot-alias'
|
||||||
|
| 'cliproxy-bridge'
|
||||||
|
| 'profile-backend'
|
||||||
|
| 'fallback-backend'
|
||||||
|
| 'disabled'
|
||||||
|
| 'unsupported-profile'
|
||||||
|
| 'unresolved'
|
||||||
|
| 'missing-model';
|
||||||
|
reason: string | null;
|
||||||
|
shouldPersistHook: boolean;
|
||||||
|
persistencePath: string | null;
|
||||||
|
runtimePath: string | null;
|
||||||
|
usesCurrentTarget: boolean | null;
|
||||||
|
usesCurrentAuthToken: boolean | null;
|
||||||
|
hookInstalled: boolean | null;
|
||||||
|
sharedHookInstalled: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
name: string;
|
name: string;
|
||||||
settingsPath: string;
|
settingsPath: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user