mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
fix(image-analysis): surface runtime readiness
This commit is contained in:
+14
-4
@@ -33,7 +33,11 @@ import {
|
||||
} from './utils/websearch-manager';
|
||||
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import { getImageAnalysisHookEnv, installImageAnalyzerHook } from './utils/hooks';
|
||||
import {
|
||||
getImageAnalysisHookEnv,
|
||||
installImageAnalyzerHook,
|
||||
resolveImageAnalysisRuntimeStatus,
|
||||
} from './utils/hooks';
|
||||
import { fail, info, warn } from './utils/ui';
|
||||
import { isCopilotSubcommandToken } from './copilot/constants';
|
||||
import {
|
||||
@@ -1013,6 +1017,12 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
settings,
|
||||
cliproxyBridge,
|
||||
});
|
||||
let imageAnalysisEnv = getImageAnalysisHookEnv({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
@@ -1032,10 +1042,10 @@ async function main(): Promise<void> {
|
||||
targetRemainingArgs.includes('--verbose') ||
|
||||
targetRemainingArgs.includes('-v');
|
||||
|
||||
if (!isAuthenticated(imageAnalysisProvider as CLIProxyProvider)) {
|
||||
if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') {
|
||||
console.error(
|
||||
info(
|
||||
`Image analysis via ${imageAnalysisProvider} is configured, but CLIProxy auth is missing. This session will use native Read. Run "ccs ${imageAnalysisProvider} --auth" to enable it.`
|
||||
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.`
|
||||
)
|
||||
);
|
||||
imageAnalysisEnv = {
|
||||
@@ -1043,7 +1053,7 @@ async function main(): Promise<void> {
|
||||
CCS_CURRENT_PROVIDER: '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
} else {
|
||||
} else if (imageAnalysisStatus.proxyReadiness === 'stopped') {
|
||||
const ensureServiceResult = await ensureCliproxyService(
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
verboseProxyLaunch
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { CopilotConfig } from '../config/unified-config-types';
|
||||
import { getGlobalEnvConfig } from '../config/unified-config-loader';
|
||||
import { ensureCliproxyService } from '../cliproxy';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
|
||||
import { isDaemonRunning, startDaemon } from './copilot-daemon';
|
||||
import { ensureCopilotApi } from './copilot-package-manager';
|
||||
@@ -20,9 +22,20 @@ import {
|
||||
createWebSearchTraceContext,
|
||||
syncWebSearchMcpToConfigDir,
|
||||
} from '../utils/websearch-manager';
|
||||
import { getImageAnalysisHookEnv } from '../utils/hooks';
|
||||
import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks';
|
||||
import { stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||
|
||||
interface CopilotImageAnalysisDeps {
|
||||
ensureCliproxyService: typeof ensureCliproxyService;
|
||||
getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv;
|
||||
resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus;
|
||||
}
|
||||
|
||||
interface CopilotImageAnalysisResolution {
|
||||
env: Record<string, string>;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full copilot status (auth + daemon).
|
||||
*/
|
||||
@@ -75,6 +88,62 @@ export function generateCopilotEnv(
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCopilotImageAnalysisEnv(
|
||||
verbose = false,
|
||||
deps: Partial<CopilotImageAnalysisDeps> = {}
|
||||
): Promise<CopilotImageAnalysisResolution> {
|
||||
const resolvedDeps: CopilotImageAnalysisDeps = {
|
||||
ensureCliproxyService,
|
||||
getImageAnalysisHookEnv,
|
||||
resolveImageAnalysisRuntimeStatus,
|
||||
...deps,
|
||||
};
|
||||
|
||||
const env = resolvedDeps.getImageAnalysisHookEnv({
|
||||
profileName: 'copilot',
|
||||
profileType: 'copilot',
|
||||
});
|
||||
const provider = env['CCS_CURRENT_PROVIDER'];
|
||||
if (env['CCS_IMAGE_ANALYSIS_SKIP'] === '1' || !provider) {
|
||||
return { env, warning: null };
|
||||
}
|
||||
|
||||
const status = await resolvedDeps.resolveImageAnalysisRuntimeStatus({
|
||||
profileName: 'copilot',
|
||||
profileType: 'copilot',
|
||||
});
|
||||
|
||||
if (status.effectiveRuntimeMode === 'native-read') {
|
||||
return {
|
||||
env: {
|
||||
...env,
|
||||
CCS_CURRENT_PROVIDER: '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
},
|
||||
warning: `${status.effectiveRuntimeReason || `Image analysis via ${provider} is unavailable.`} This session will use native Read.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.proxyReadiness === 'stopped') {
|
||||
const ensureServiceResult = await resolvedDeps.ensureCliproxyService(
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
verbose
|
||||
);
|
||||
if (!ensureServiceResult.started) {
|
||||
return {
|
||||
env: {
|
||||
...env,
|
||||
CCS_CURRENT_PROVIDER: '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
},
|
||||
warning: `Image analysis via ${provider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { env, warning: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Claude Code with copilot-api proxy.
|
||||
*
|
||||
@@ -165,10 +234,8 @@ export async function executeCopilotProfile(
|
||||
|
||||
// Merge with current environment (global env first, copilot overrides, then hook env vars)
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const imageAnalysisEnv = getImageAnalysisHookEnv({
|
||||
profileName: 'copilot',
|
||||
profileType: 'copilot',
|
||||
});
|
||||
const { env: imageAnalysisEnv, warning: imageAnalysisWarning } =
|
||||
await resolveCopilotImageAnalysisEnv();
|
||||
const env = stripClaudeCodeEnv({
|
||||
...process.env,
|
||||
...globalEnv,
|
||||
@@ -179,6 +246,9 @@ export async function executeCopilotProfile(
|
||||
});
|
||||
|
||||
console.log(info(`Using GitHub Copilot proxy (model: ${normalizedConfig.model})`));
|
||||
if (imageAnalysisWarning) {
|
||||
console.log(info(imageAnalysisWarning));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
syncWebSearchMcpToConfigDir(claudeConfigDir);
|
||||
|
||||
@@ -33,6 +33,16 @@ export type ImageAnalysisStatusCode =
|
||||
| 'skipped'
|
||||
| 'hook-missing';
|
||||
|
||||
export type ImageAnalysisAuthReadiness = 'not-needed' | 'ready' | 'missing' | 'unknown';
|
||||
export type ImageAnalysisProxyReadiness =
|
||||
| 'not-needed'
|
||||
| 'ready'
|
||||
| 'remote'
|
||||
| 'stopped'
|
||||
| 'unavailable'
|
||||
| 'unknown';
|
||||
export type ImageAnalysisEffectiveRuntimeMode = 'cliproxy-image-analysis' | 'native-read';
|
||||
|
||||
export interface ImageAnalysisResolutionContext {
|
||||
profileName: string;
|
||||
profileType?: ProfileType;
|
||||
@@ -61,6 +71,14 @@ export interface ImageAnalysisStatus {
|
||||
usesCurrentAuthToken: boolean | null;
|
||||
hookInstalled: boolean | null;
|
||||
sharedHookInstalled: boolean | null;
|
||||
authReadiness: ImageAnalysisAuthReadiness;
|
||||
authProvider: string | null;
|
||||
authDisplayName: string | null;
|
||||
authReason: string | null;
|
||||
proxyReadiness: ImageAnalysisProxyReadiness;
|
||||
proxyReason: string | null;
|
||||
effectiveRuntimeMode: ImageAnalysisEffectiveRuntimeMode;
|
||||
effectiveRuntimeReason: string | null;
|
||||
}
|
||||
|
||||
function resolveProviderFromBaseUrl(baseUrl: unknown): string | null {
|
||||
@@ -397,5 +415,34 @@ export function resolveImageAnalysisStatus(
|
||||
usesCurrentAuthToken: context.cliproxyBridge?.usesCurrentAuthToken ?? null,
|
||||
hookInstalled: context.hookInstalled ?? null,
|
||||
sharedHookInstalled: context.sharedHookInstalled ?? null,
|
||||
authReadiness:
|
||||
resolution.backendId && model && isCLIProxyProvider(resolution.backendId)
|
||||
? 'unknown'
|
||||
: 'not-needed',
|
||||
authProvider:
|
||||
resolution.backendId && isCLIProxyProvider(resolution.backendId)
|
||||
? resolution.backendId
|
||||
: null,
|
||||
authDisplayName:
|
||||
resolution.backendId && isCLIProxyProvider(resolution.backendId)
|
||||
? getProviderDisplayName(resolution.backendId)
|
||||
: null,
|
||||
authReason:
|
||||
resolution.backendId && model && isCLIProxyProvider(resolution.backendId)
|
||||
? 'Auth readiness has not been verified yet.'
|
||||
: null,
|
||||
proxyReadiness: resolution.backendId && model ? 'unknown' : 'not-needed',
|
||||
proxyReason:
|
||||
resolution.backendId && model
|
||||
? 'CLIProxy runtime readiness has not been verified yet.'
|
||||
: null,
|
||||
effectiveRuntimeMode:
|
||||
config.enabled && resolution.backendId && model && status !== 'hook-missing'
|
||||
? 'cliproxy-image-analysis'
|
||||
: 'native-read',
|
||||
effectiveRuntimeReason:
|
||||
status === 'hook-missing' || !config.enabled || !resolution.backendId || !model
|
||||
? reason
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { getAuthStatus, initializeAccounts, type AuthStatus } from '../../cliproxy/auth-handler';
|
||||
import { fetchRemoteAuthStatus, type RemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||
import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||
import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||
import { isCliproxyRunning } from '../../cliproxy/stats-fetcher';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import {
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
type ImageAnalysisConfig,
|
||||
} from '../../config/unified-config-types';
|
||||
import {
|
||||
resolveImageAnalysisStatus,
|
||||
type ImageAnalysisResolutionContext,
|
||||
type ImageAnalysisStatus,
|
||||
} from './image-analysis-backend-resolver';
|
||||
|
||||
interface ImageAnalysisRuntimeStatusDeps {
|
||||
fetchRemoteAuthStatus: (target: ProxyTarget) => Promise<RemoteAuthStatus[]>;
|
||||
getAuthStatus: (provider: CLIProxyProvider) => AuthStatus;
|
||||
getProxyTarget: () => ProxyTarget;
|
||||
initializeAccounts: () => void;
|
||||
isCliproxyRunning: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const defaultDeps: ImageAnalysisRuntimeStatusDeps = {
|
||||
fetchRemoteAuthStatus,
|
||||
getAuthStatus,
|
||||
getProxyTarget,
|
||||
initializeAccounts,
|
||||
isCliproxyRunning: () => isCliproxyRunning(),
|
||||
};
|
||||
|
||||
async function resolveAuthReadiness(
|
||||
status: ImageAnalysisStatus,
|
||||
deps: ImageAnalysisRuntimeStatusDeps
|
||||
): Promise<
|
||||
Pick<ImageAnalysisStatus, 'authReadiness' | 'authProvider' | 'authDisplayName' | 'authReason'>
|
||||
> {
|
||||
if (!status.backendId || !status.model || !isCLIProxyProvider(status.backendId)) {
|
||||
return {
|
||||
authReadiness: 'not-needed',
|
||||
authProvider: null,
|
||||
authDisplayName: null,
|
||||
authReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const authProvider = status.backendId;
|
||||
const authDisplayName = getProviderDisplayName(authProvider);
|
||||
|
||||
try {
|
||||
let authenticated = false;
|
||||
const target = deps.getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
const remoteStatuses = await deps.fetchRemoteAuthStatus(target);
|
||||
authenticated = remoteStatuses.some(
|
||||
(entry) => entry.provider === authProvider && entry.authenticated
|
||||
);
|
||||
} else {
|
||||
deps.initializeAccounts();
|
||||
authenticated = deps.getAuthStatus(authProvider).authenticated;
|
||||
}
|
||||
|
||||
return {
|
||||
authReadiness: authenticated ? 'ready' : 'missing',
|
||||
authProvider,
|
||||
authDisplayName,
|
||||
authReason: authenticated
|
||||
? null
|
||||
: `${authDisplayName} auth is missing. Run "ccs ${authProvider} --auth" to enable image analysis.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authReadiness: 'unknown',
|
||||
authProvider,
|
||||
authDisplayName,
|
||||
authReason: `CCS could not verify ${authDisplayName} auth readiness: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveProxyReadiness(
|
||||
status: ImageAnalysisStatus,
|
||||
deps: ImageAnalysisRuntimeStatusDeps
|
||||
): Promise<Pick<ImageAnalysisStatus, 'proxyReadiness' | 'proxyReason'>> {
|
||||
if (!status.backendId || !status.model) {
|
||||
return {
|
||||
proxyReadiness: 'not-needed',
|
||||
proxyReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const target = deps.getProxyTarget();
|
||||
const reachable = await deps.isCliproxyRunning();
|
||||
if (target.isRemote) {
|
||||
return {
|
||||
proxyReadiness: reachable ? 'remote' : 'unavailable',
|
||||
proxyReason: reachable
|
||||
? `Remote CLIProxy target ${target.host}:${target.port} is reachable.`
|
||||
: `Remote CLIProxy target ${target.host}:${target.port} is unreachable.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
proxyReadiness: reachable ? 'ready' : 'stopped',
|
||||
proxyReason: reachable
|
||||
? 'Local CLIProxy service is reachable.'
|
||||
: 'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEffectiveRuntime(
|
||||
status: ImageAnalysisStatus
|
||||
): Pick<ImageAnalysisStatus, 'effectiveRuntimeMode' | 'effectiveRuntimeReason'> {
|
||||
if (!status.enabled || !status.backendId || !status.model) {
|
||||
return {
|
||||
effectiveRuntimeMode: 'native-read',
|
||||
effectiveRuntimeReason: status.reason,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.status === 'hook-missing') {
|
||||
return {
|
||||
effectiveRuntimeMode: 'native-read',
|
||||
effectiveRuntimeReason: status.reason,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.authReadiness === 'missing' || status.authReadiness === 'unknown') {
|
||||
return {
|
||||
effectiveRuntimeMode: 'native-read',
|
||||
effectiveRuntimeReason: status.authReason,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.proxyReadiness === 'unavailable' || status.proxyReadiness === 'unknown') {
|
||||
return {
|
||||
effectiveRuntimeMode: 'native-read',
|
||||
effectiveRuntimeReason: status.proxyReason,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||
effectiveRuntimeReason: status.status === 'attention' ? status.reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hydrateImageAnalysisRuntimeStatus(
|
||||
baseStatus: ImageAnalysisStatus,
|
||||
deps: Partial<ImageAnalysisRuntimeStatusDeps> = {}
|
||||
): Promise<ImageAnalysisStatus> {
|
||||
const resolvedDeps = { ...defaultDeps, ...deps };
|
||||
const authStatus = await resolveAuthReadiness(baseStatus, resolvedDeps);
|
||||
const proxyStatus = await resolveProxyReadiness(baseStatus, resolvedDeps);
|
||||
const mergedStatus = {
|
||||
...baseStatus,
|
||||
...authStatus,
|
||||
...proxyStatus,
|
||||
};
|
||||
|
||||
return {
|
||||
...mergedStatus,
|
||||
...resolveEffectiveRuntime(mergedStatus),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveImageAnalysisRuntimeStatus(
|
||||
context: ImageAnalysisResolutionContext,
|
||||
config: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
deps: Partial<ImageAnalysisRuntimeStatusDeps> = {}
|
||||
): Promise<ImageAnalysisStatus> {
|
||||
const baseStatus = resolveImageAnalysisStatus(context, config);
|
||||
return hydrateImageAnalysisRuntimeStatus(baseStatus, deps);
|
||||
}
|
||||
@@ -14,6 +14,10 @@ export {
|
||||
type ImageAnalysisResolutionContext,
|
||||
type ImageAnalysisStatus,
|
||||
} from './image-analysis-backend-resolver';
|
||||
export {
|
||||
hydrateImageAnalysisRuntimeStatus,
|
||||
resolveImageAnalysisRuntimeStatus,
|
||||
} from './image-analysis-runtime-status';
|
||||
export {
|
||||
getImageAnalyzerHookPath,
|
||||
getImageAnalyzerHookConfig,
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
hasImageAnalysisProfileHook,
|
||||
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
|
||||
import { resolveImageAnalysisStatus } from '../../utils/hooks';
|
||||
import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks';
|
||||
|
||||
const router = Router();
|
||||
const MODEL_ENV_KEYS = [
|
||||
@@ -269,16 +269,16 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting
|
||||
return changed ? next : settings;
|
||||
}
|
||||
|
||||
function resolveImageAnalysisStatusForProfile(
|
||||
async function resolveImageAnalysisStatusForProfile(
|
||||
profileOrVariant: string,
|
||||
settings: Settings,
|
||||
settingsPath: string
|
||||
) {
|
||||
): Promise<Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>> {
|
||||
const variants = listVariants();
|
||||
const variant = variants[profileOrVariant];
|
||||
const cliproxyProvider = resolveProviderForProfile(profileOrVariant);
|
||||
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
|
||||
const status = resolveImageAnalysisStatus(
|
||||
const status = await resolveImageAnalysisRuntimeStatus(
|
||||
{
|
||||
profileName: profileOrVariant,
|
||||
profileType: cliproxyProvider ? 'cliproxy' : 'settings',
|
||||
@@ -303,7 +303,7 @@ function resolveImageAnalysisStatusForProfile(
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) {
|
||||
async function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) {
|
||||
const normalizedSettings = canonicalizeProfileSettings(profileOrVariant, settings);
|
||||
const settingsPath = resolveSettingsPath(profileOrVariant);
|
||||
|
||||
@@ -377,7 +377,7 @@ function maskApiKeys(settings: Settings): Settings {
|
||||
/**
|
||||
* GET /api/settings/:profile - Get settings with masked API keys
|
||||
*/
|
||||
router.get('/:profile', (req: Request, res: Response): void => {
|
||||
router.get('/:profile', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const settingsPath = resolveSettingsPath(profile);
|
||||
@@ -397,7 +397,11 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath),
|
||||
imageAnalysisStatus: await resolveImageAnalysisStatusForProfile(
|
||||
profile,
|
||||
settings,
|
||||
settingsPath
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
@@ -407,7 +411,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
||||
/**
|
||||
* GET /api/settings/:profile/raw - Get full settings (for editing)
|
||||
*/
|
||||
router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
router.get('/:profile/raw', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
@@ -428,7 +432,11 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath),
|
||||
imageAnalysisStatus: await resolveImageAnalysisStatusForProfile(
|
||||
profile,
|
||||
settings,
|
||||
settingsPath
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
@@ -438,25 +446,28 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
/**
|
||||
* POST /api/settings/:profile/image-analysis-status - Preview image analysis status from editor JSON
|
||||
*/
|
||||
router.post('/:profile/image-analysis-status', (req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
router.post(
|
||||
'/:profile/image-analysis-status',
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const { settings } = req.body;
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const { settings } = req.body;
|
||||
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
res.status(400).json({ error: 'settings object is required in request body' });
|
||||
return;
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
res.status(400).json({ error: 'settings object is required in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
imageAnalysisStatus: await resolvePreviewImageAnalysisStatus(profile, settings as Settings),
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
|
||||
res.json({
|
||||
imageAnalysisStatus: resolvePreviewImageAnalysisStatus(profile, settings as Settings),
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
/** Required env vars for CLIProxy providers to function */
|
||||
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { generateCopilotEnv } from '../../../src/copilot/copilot-executor';
|
||||
import {
|
||||
generateCopilotEnv,
|
||||
resolveCopilotImageAnalysisEnv,
|
||||
} from '../../../src/copilot/copilot-executor';
|
||||
import type { CopilotConfig } from '../../../src/config/unified-config-types';
|
||||
|
||||
const baseConfig: CopilotConfig = {
|
||||
@@ -50,4 +53,94 @@ describe('generateCopilotEnv', () => {
|
||||
const env = generateCopilotEnv(baseConfig);
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to native read when copilot image analysis auth is missing', async () => {
|
||||
const result = await resolveCopilotImageAnalysisEnv(false, {
|
||||
getImageAnalysisHookEnv: () => ({
|
||||
CCS_CURRENT_PROVIDER: 'ghcp',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
}),
|
||||
resolveImageAnalysisRuntimeStatus: async () => ({
|
||||
enabled: true,
|
||||
supported: true,
|
||||
status: 'active',
|
||||
backendId: 'ghcp',
|
||||
backendDisplayName: 'GitHub Copilot (OAuth)',
|
||||
model: 'claude-haiku-4.5',
|
||||
resolutionSource: 'copilot-alias',
|
||||
reason: null,
|
||||
shouldPersistHook: true,
|
||||
persistencePath: 'copilot.settings.json',
|
||||
runtimePath: '/api/provider/ghcp',
|
||||
usesCurrentTarget: true,
|
||||
usesCurrentAuthToken: true,
|
||||
hookInstalled: true,
|
||||
sharedHookInstalled: true,
|
||||
authReadiness: 'missing',
|
||||
authProvider: 'ghcp',
|
||||
authDisplayName: 'GitHub Copilot (OAuth)',
|
||||
authReason:
|
||||
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
|
||||
proxyReadiness: 'stopped',
|
||||
proxyReason:
|
||||
'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.',
|
||||
effectiveRuntimeMode: 'native-read',
|
||||
effectiveRuntimeReason:
|
||||
'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.env.CCS_CURRENT_PROVIDER).toBe('');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1');
|
||||
expect(result.warning).toContain('ccs ghcp --auth');
|
||||
});
|
||||
|
||||
it('starts local CLIProxy on demand when copilot image analysis is launchable', async () => {
|
||||
let ensureCalls = 0;
|
||||
const result = await resolveCopilotImageAnalysisEnv(false, {
|
||||
getImageAnalysisHookEnv: () => ({
|
||||
CCS_CURRENT_PROVIDER: 'ghcp',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
}),
|
||||
resolveImageAnalysisRuntimeStatus: async () => ({
|
||||
enabled: true,
|
||||
supported: true,
|
||||
status: 'active',
|
||||
backendId: 'ghcp',
|
||||
backendDisplayName: 'GitHub Copilot (OAuth)',
|
||||
model: 'claude-haiku-4.5',
|
||||
resolutionSource: 'copilot-alias',
|
||||
reason: null,
|
||||
shouldPersistHook: true,
|
||||
persistencePath: 'copilot.settings.json',
|
||||
runtimePath: '/api/provider/ghcp',
|
||||
usesCurrentTarget: true,
|
||||
usesCurrentAuthToken: true,
|
||||
hookInstalled: true,
|
||||
sharedHookInstalled: true,
|
||||
authReadiness: 'ready',
|
||||
authProvider: 'ghcp',
|
||||
authDisplayName: 'GitHub Copilot (OAuth)',
|
||||
authReason: null,
|
||||
proxyReadiness: 'stopped',
|
||||
proxyReason:
|
||||
'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.',
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||
effectiveRuntimeReason: null,
|
||||
}),
|
||||
ensureCliproxyService: async () => {
|
||||
ensureCalls += 1;
|
||||
return {
|
||||
started: true,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(ensureCalls).toBe(1);
|
||||
expect(result.env.CCS_CURRENT_PROVIDER).toBe('ghcp');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { hydrateImageAnalysisRuntimeStatus } from '../../../../src/utils/hooks/image-analysis-runtime-status';
|
||||
import type { ImageAnalysisStatus } from '../../../../src/utils/hooks/image-analysis-backend-resolver';
|
||||
|
||||
function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalysisStatus {
|
||||
return {
|
||||
enabled: true,
|
||||
supported: true,
|
||||
status: 'active',
|
||||
backendId: 'ghcp',
|
||||
backendDisplayName: 'GitHub Copilot (OAuth)',
|
||||
model: 'claude-haiku-4.5',
|
||||
resolutionSource: 'profile-backend',
|
||||
reason: null,
|
||||
shouldPersistHook: true,
|
||||
persistencePath: '/tmp/orq.settings.json',
|
||||
runtimePath: '/api/provider/ghcp',
|
||||
usesCurrentTarget: true,
|
||||
usesCurrentAuthToken: true,
|
||||
hookInstalled: true,
|
||||
sharedHookInstalled: true,
|
||||
authReadiness: 'unknown',
|
||||
authProvider: 'ghcp',
|
||||
authDisplayName: 'GitHub Copilot (OAuth)',
|
||||
authReason: 'Auth readiness has not been verified yet.',
|
||||
proxyReadiness: 'unknown',
|
||||
proxyReason: 'CLIProxy runtime readiness has not been verified yet.',
|
||||
effectiveRuntimeMode: 'native-read',
|
||||
effectiveRuntimeReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('image-analysis-runtime-status', () => {
|
||||
it('falls back to native read when provider auth is missing', async () => {
|
||||
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
|
||||
getProxyTarget: () => ({
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
}),
|
||||
initializeAccounts: () => {},
|
||||
getAuthStatus: () => ({
|
||||
provider: 'ghcp',
|
||||
authenticated: false,
|
||||
tokenDir: '/tmp/auth',
|
||||
tokenFiles: [],
|
||||
accounts: [],
|
||||
defaultAccount: undefined,
|
||||
}),
|
||||
isCliproxyRunning: async () => true,
|
||||
});
|
||||
|
||||
expect(status.authReadiness).toBe('missing');
|
||||
expect(status.effectiveRuntimeMode).toBe('native-read');
|
||||
expect(status.effectiveRuntimeReason).toContain('ccs ghcp --auth');
|
||||
});
|
||||
|
||||
it('marks an idle local proxy as launchable when auth is ready', async () => {
|
||||
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
|
||||
getProxyTarget: () => ({
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
}),
|
||||
initializeAccounts: () => {},
|
||||
getAuthStatus: () => ({
|
||||
provider: 'ghcp',
|
||||
authenticated: true,
|
||||
tokenDir: '/tmp/auth',
|
||||
tokenFiles: ['github-copilot-test.json'],
|
||||
accounts: [],
|
||||
defaultAccount: undefined,
|
||||
}),
|
||||
isCliproxyRunning: async () => false,
|
||||
});
|
||||
|
||||
expect(status.authReadiness).toBe('ready');
|
||||
expect(status.proxyReadiness).toBe('stopped');
|
||||
expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis');
|
||||
});
|
||||
|
||||
it('treats an unreachable remote proxy as unavailable', async () => {
|
||||
const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), {
|
||||
getProxyTarget: () => ({
|
||||
host: 'remote.example',
|
||||
port: 443,
|
||||
protocol: 'https',
|
||||
authToken: 'token',
|
||||
managementKey: 'secret',
|
||||
isRemote: true,
|
||||
}),
|
||||
fetchRemoteAuthStatus: async () => [
|
||||
{
|
||||
provider: 'ghcp',
|
||||
displayName: 'GitHub Copilot (OAuth)',
|
||||
authenticated: true,
|
||||
tokenFiles: 1,
|
||||
accounts: [],
|
||||
defaultAccount: null,
|
||||
source: 'remote',
|
||||
},
|
||||
],
|
||||
isCliproxyRunning: async () => false,
|
||||
});
|
||||
|
||||
expect(status.authReadiness).toBe('ready');
|
||||
expect(status.proxyReadiness).toBe('unavailable');
|
||||
expect(status.effectiveRuntimeMode).toBe('native-read');
|
||||
expect(status.effectiveRuntimeReason).toContain('remote.example:443');
|
||||
});
|
||||
|
||||
it('keeps hook-missing on native read even when auth and proxy are ready', async () => {
|
||||
const status = await hydrateImageAnalysisRuntimeStatus(
|
||||
createStatus({
|
||||
status: 'hook-missing',
|
||||
reason: 'Profile hook is missing from the persisted settings file.',
|
||||
}),
|
||||
{
|
||||
getProxyTarget: () => ({
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
}),
|
||||
initializeAccounts: () => {},
|
||||
getAuthStatus: () => ({
|
||||
provider: 'ghcp',
|
||||
authenticated: true,
|
||||
tokenDir: '/tmp/auth',
|
||||
tokenFiles: ['github-copilot-test.json'],
|
||||
accounts: [],
|
||||
defaultAccount: undefined,
|
||||
}),
|
||||
isCliproxyRunning: async () => true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(status.authReadiness).toBe('ready');
|
||||
expect(status.proxyReadiness).toBe('ready');
|
||||
expect(status.effectiveRuntimeMode).toBe('native-read');
|
||||
expect(status.effectiveRuntimeReason).toContain('Profile hook is missing');
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,8 @@ describe('settings-routes image-analysis status', () => {
|
||||
resolutionSource: string;
|
||||
model: string | null;
|
||||
persistencePath: string | null;
|
||||
authReadiness: string;
|
||||
effectiveRuntimeMode: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,6 +113,8 @@ describe('settings-routes image-analysis status', () => {
|
||||
expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend');
|
||||
expect(body.imageAnalysisStatus.model).toBe('gemini-2.5-flash');
|
||||
expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json');
|
||||
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
|
||||
expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read');
|
||||
});
|
||||
|
||||
it('keeps direct Anthropic settings profiles on native read diagnostics', async () => {
|
||||
@@ -130,6 +134,8 @@ describe('settings-routes image-analysis status', () => {
|
||||
shouldPersistHook: boolean;
|
||||
runtimePath: string | null;
|
||||
reason: string | null;
|
||||
authReadiness: string;
|
||||
proxyReadiness: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -138,6 +144,8 @@ describe('settings-routes image-analysis status', () => {
|
||||
expect(body.imageAnalysisStatus.shouldPersistHook).toBe(false);
|
||||
expect(body.imageAnalysisStatus.runtimePath).toBeNull();
|
||||
expect(body.imageAnalysisStatus.reason).toContain('native file access');
|
||||
expect(body.imageAnalysisStatus.authReadiness).toBe('not-needed');
|
||||
expect(body.imageAnalysisStatus.proxyReadiness).toBe('not-needed');
|
||||
});
|
||||
|
||||
it('returns explicit mapped status for custom aliases', async () => {
|
||||
@@ -169,6 +177,8 @@ describe('settings-routes image-analysis status', () => {
|
||||
backendId: string | null;
|
||||
resolutionSource: string;
|
||||
model: string | null;
|
||||
authReadiness: string;
|
||||
effectiveRuntimeMode: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -176,6 +186,8 @@ describe('settings-routes image-analysis status', () => {
|
||||
expect(body.imageAnalysisStatus.backendId).toBe('ghcp');
|
||||
expect(body.imageAnalysisStatus.resolutionSource).toBe('profile-backend');
|
||||
expect(body.imageAnalysisStatus.model).toBe('claude-haiku-4.5');
|
||||
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
|
||||
expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read');
|
||||
});
|
||||
|
||||
it('uses the configured custom settings path for status and persistence diagnostics', async () => {
|
||||
@@ -235,10 +247,12 @@ describe('settings-routes image-analysis status', () => {
|
||||
imageAnalysisStatus: {
|
||||
backendId: string | null;
|
||||
resolutionSource: string;
|
||||
authReadiness: string;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.imageAnalysisStatus.backendId).toBe('ghcp');
|
||||
expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge');
|
||||
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,66 +9,103 @@ interface ImageAnalysisStatusSectionProps {
|
||||
previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
|
||||
}
|
||||
|
||||
function getBadge(status: ImageAnalysisStatus | null | undefined): {
|
||||
label: string;
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
} {
|
||||
switch (status?.status) {
|
||||
case 'active':
|
||||
return { label: 'Configured', 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 getBadge(status: ImageAnalysisStatus | null | undefined) {
|
||||
if (!status) return { label: 'Checking', 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: status.resolutionSource === 'profile-backend' ? 'Ready via mapping' : 'Ready',
|
||||
variant: 'default' as const,
|
||||
};
|
||||
}
|
||||
if (
|
||||
status.authReadiness === 'unknown' ||
|
||||
status.proxyReadiness === 'unknown' ||
|
||||
status.status === 'attention'
|
||||
) {
|
||||
return { label: 'Needs review', variant: 'outline' as const };
|
||||
}
|
||||
if (status.status === 'skipped' && status.reason?.includes('native file access')) {
|
||||
return { label: 'Native Claude', variant: 'secondary' as const };
|
||||
}
|
||||
return { label: 'Native Read', variant: 'outline' as const };
|
||||
}
|
||||
|
||||
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 `Configured via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config when image analysis is available for the session.`;
|
||||
case 'attention':
|
||||
return `Configured via ${backendName}, but ${status.reason || 'runtime details no longer match the saved profile state.'}`;
|
||||
case 'hook-missing':
|
||||
return `Configured for ${backendName}, but ${status.reason || '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 `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
|
||||
}
|
||||
|
||||
if (status.resolutionSource === 'fallback-backend') {
|
||||
return `Configured via ${backendName} fallback. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
|
||||
}
|
||||
|
||||
return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`;
|
||||
if (status.status === 'disabled') {
|
||||
return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off.";
|
||||
}
|
||||
|
||||
if (!status.backendId) {
|
||||
return status.reason || "This profile uses Claude's built-in file reading.";
|
||||
}
|
||||
|
||||
if (status.status === 'hook-missing') {
|
||||
return `Configured for ${backendName}, but ${status.reason || 'the image-analysis hook is not fully installed yet.'}`;
|
||||
}
|
||||
|
||||
if (status.effectiveRuntimeMode === 'native-read') {
|
||||
return `Configured via ${backendName}, but ${status.effectiveRuntimeReason || status.reason || 'runtime readiness could not be confirmed.'}`;
|
||||
}
|
||||
|
||||
if (status.proxyReadiness === 'stopped') {
|
||||
return `Configured via ${backendName}. Auth is ready and CCS will start the local CLIProxy runtime on launch, so image and PDF reads still use CLIProxy.`;
|
||||
}
|
||||
|
||||
if (status.resolutionSource === 'profile-backend') {
|
||||
return `Configured via saved ${backendName} mapping. Auth and runtime are ready, so image and PDF reads use CLIProxy.`;
|
||||
}
|
||||
|
||||
if (status.status === 'attention' && status.reason) {
|
||||
return `Configured via ${backendName}. Image and PDF reads use CLIProxy, but ${status.reason}`;
|
||||
}
|
||||
|
||||
return `Configured via ${backendName}. Image and PDF reads use CLIProxy for this profile.`;
|
||||
}
|
||||
|
||||
function getRuntimeLine(status: ImageAnalysisStatus): string {
|
||||
if (status.status === 'hook-missing') {
|
||||
return 'Read -> native file access (hook install required)';
|
||||
}
|
||||
|
||||
if (!status.runtimePath) {
|
||||
if (status.effectiveRuntimeMode === 'native-read') {
|
||||
return 'Read -> native file access';
|
||||
}
|
||||
|
||||
return `Read -> image-analysis hook -> ${status.runtimePath}`;
|
||||
if (status.proxyReadiness === 'stopped') {
|
||||
return 'Read -> image-analysis hook -> start local CLIProxy';
|
||||
}
|
||||
|
||||
if (status.proxyReadiness === 'remote') {
|
||||
return 'Read -> image-analysis hook -> remote CLIProxy';
|
||||
}
|
||||
|
||||
return `Read -> image-analysis hook -> ${status.runtimePath || 'CLIProxy'}`;
|
||||
}
|
||||
|
||||
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.';
|
||||
}
|
||||
|
||||
function getStatusContext(
|
||||
@@ -78,28 +115,21 @@ function getStatusContext(
|
||||
if (previewState === 'invalid') {
|
||||
return 'Showing last saved runtime status. The live preview resumes when the JSON above is valid again.';
|
||||
}
|
||||
|
||||
if (previewState === 'refreshing') {
|
||||
return 'Refreshing the live preview from the current editor state.';
|
||||
}
|
||||
|
||||
if (source === 'editor') {
|
||||
return 'Live preview from the current editor state. Save to persist these backend and hook changes.';
|
||||
return 'Live preview from the current editor state. Save to persist config changes; auth and proxy readiness stay derived below.';
|
||||
}
|
||||
|
||||
return 'Saved runtime status for this profile. This section is derived and is not written into the JSON editor above.';
|
||||
return 'Saved runtime status for this profile. Config stays in the JSON editor above; auth and proxy readiness are derived at runtime.';
|
||||
}
|
||||
|
||||
function getPersistenceLine(status: ImageAnalysisStatus): string {
|
||||
if (!status.shouldPersistHook || !status.persistencePath) {
|
||||
if (!status.shouldPersistHook || !status.persistencePath)
|
||||
return 'Not persisted for this profile type';
|
||||
}
|
||||
|
||||
if (status.hookInstalled) {
|
||||
return `${status.persistencePath} hook`;
|
||||
}
|
||||
|
||||
return `${status.persistencePath} hook missing`;
|
||||
return status.hookInstalled
|
||||
? `${status.persistencePath} hook`
|
||||
: `${status.persistencePath} hook missing`;
|
||||
}
|
||||
|
||||
export function ImageAnalysisStatusSection({
|
||||
@@ -118,8 +148,12 @@ export function ImageAnalysisStatusSection({
|
||||
}
|
||||
|
||||
const badge = getBadge(status);
|
||||
const detailLabel = status.supported ? 'Model' : 'Reason';
|
||||
const detailValue = status.supported ? status.model : status.reason || 'Unavailable';
|
||||
const notice =
|
||||
status.effectiveRuntimeMode === 'native-read'
|
||||
? status.effectiveRuntimeReason
|
||||
: status.status === 'attention' || status.status === 'hook-missing'
|
||||
? status.reason
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="rounded-md border bg-muted/20 p-4">
|
||||
@@ -149,7 +183,6 @@ export function ImageAnalysisStatusSection({
|
||||
</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
|
||||
@@ -161,7 +194,18 @@ export function ImageAnalysisStatusSection({
|
||||
{getRuntimeLine(status)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Auth
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground">{getAuthLine(status)}</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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Persistence
|
||||
@@ -173,21 +217,20 @@ export function ImageAnalysisStatusSection({
|
||||
{getPersistenceLine(status)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{detailLabel}
|
||||
Model
|
||||
</dt>
|
||||
<dd className={cn('text-sm text-foreground', status.supported && 'font-mono text-xs')}>
|
||||
{detailValue}
|
||||
<dd className={cn('text-sm text-foreground', status.model && 'font-mono text-xs')}>
|
||||
{status.model || status.reason || 'Unavailable'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{(status.status === 'attention' || status.status === 'hook-missing') && (
|
||||
{notice && (
|
||||
<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>
|
||||
<span>{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -138,6 +138,14 @@ export interface ImageAnalysisStatus {
|
||||
usesCurrentAuthToken: boolean | null;
|
||||
hookInstalled: boolean | null;
|
||||
sharedHookInstalled: boolean | null;
|
||||
authReadiness: 'not-needed' | 'ready' | 'missing' | 'unknown';
|
||||
authProvider: string | null;
|
||||
authDisplayName: string | null;
|
||||
authReason: string | null;
|
||||
proxyReadiness: 'not-needed' | 'ready' | 'remote' | 'stopped' | 'unavailable' | 'unknown';
|
||||
proxyReason: string | null;
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis' | 'native-read';
|
||||
effectiveRuntimeReason: string | null;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
|
||||
@@ -48,6 +48,14 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
|
||||
usesCurrentAuthToken: true,
|
||||
hookInstalled: true,
|
||||
sharedHookInstalled: true,
|
||||
authReadiness: 'ready',
|
||||
authProvider: 'gemini',
|
||||
authDisplayName: 'Google Gemini',
|
||||
authReason: null,
|
||||
proxyReadiness: 'ready',
|
||||
proxyReason: 'Local CLIProxy service is reachable.',
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||
effectiveRuntimeReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -75,13 +83,17 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
expect(screen.getByText('Image-analysis backend')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Saved runtime status for this profile\. This section is derived and is not written into the JSON editor above\./i
|
||||
/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('Configured')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Configured via Google Gemini\./i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Ready')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Configured via Google Gemini\. Image and PDF reads use CLIProxy/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Google Gemini')).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();
|
||||
});
|
||||
|
||||
@@ -89,19 +101,22 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
render(
|
||||
<ImageAnalysisStatusSection
|
||||
status={createStatus({
|
||||
status: 'mapped',
|
||||
backendId: 'ghcp',
|
||||
backendDisplayName: 'GitHub Copilot (OAuth)',
|
||||
model: 'claude-haiku-4.5',
|
||||
resolutionSource: 'profile-backend',
|
||||
reason: 'Using explicit profile mapping.',
|
||||
authReadiness: 'ready',
|
||||
authProvider: 'ghcp',
|
||||
authDisplayName: 'GitHub Copilot (OAuth)',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Saved mapping')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ready via mapping')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Configured via saved GitHub Copilot \(OAuth\) mapping/i)
|
||||
screen.getByText(
|
||||
/Configured via saved GitHub Copilot \(OAuth\) mapping\. Auth and runtime are ready/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
|
||||
expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument();
|
||||
@@ -113,6 +128,8 @@ describe('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.',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
@@ -121,31 +138,55 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
expect(
|
||||
screen.getByText(/Configured for Google Gemini, but Profile hook is missing/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTitle(/native file access \(hook install required\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByTitle(/native file access/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the resolver reason for attention states like auth-token drift', () => {
|
||||
it('shows auth readiness gaps separately from backend resolution', () => {
|
||||
render(
|
||||
<ImageAnalysisStatusSection
|
||||
status={createStatus({
|
||||
status: 'attention',
|
||||
reason:
|
||||
'Runtime uses the current CLIProxy auth token instead of the saved token in this profile.',
|
||||
usesCurrentTarget: true,
|
||||
usesCurrentAuthToken: false,
|
||||
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 review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs auth')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Configured via Google Gemini, but Runtime uses the current CLIProxy auth token/i
|
||||
/Configured via GitHub Copilot \(OAuth\), but GitHub Copilot \(OAuth\) auth is missing/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i)).toHaveLength(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.getAllByText(/current CLIProxy auth token instead of the saved token/i)
|
||||
).toHaveLength(2);
|
||||
screen.getByText(/Auth is ready and CCS will start the local CLIProxy runtime on launch/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Local CLIProxy idle; starts on launch')).toBeInTheDocument();
|
||||
expect(screen.getByTitle(/start local CLIProxy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches the panel to a live preview when the current editor JSON changes', async () => {
|
||||
@@ -178,6 +219,9 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
backendDisplayName: 'GitHub Copilot (OAuth)',
|
||||
model: 'claude-haiku-4.5',
|
||||
runtimePath: '/api/provider/ghcp',
|
||||
authReadiness: 'ready',
|
||||
authProvider: 'ghcp',
|
||||
authDisplayName: 'GitHub Copilot (OAuth)',
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -212,7 +256,7 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
});
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Live preview from the current editor state\. Save to persist these backend and hook changes\./i
|
||||
/Live preview from the current editor state\. Save to persist config changes; auth and proxy readiness stay derived below\./i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user