diff --git a/README.md b/README.md index 85360515..5841ac4b 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ The dashboard provides visual management for all account types: > **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, provision `ccs-websearch.WebSearch` when the managed runtime is available, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed. > Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly. -> **Image-analysis backend visibility:** `ccs config image-analysis --set-fallback ` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend ` and `--clear-profile-backend ` for explicit per-profile mappings. In the dashboard raw JSON editor, CCS now shows a derived `Image-analysis backend` status block below the JSON viewer so you can see the active backend, whether the hook is persisted, and whether the result is runtime-derived rather than saved in the profile JSON. +> **Image-analysis backend visibility:** `ccs config image-analysis --set-fallback ` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend ` and `--clear-profile-backend ` for explicit per-profile mappings. In the dashboard raw JSON editor, CCS now shows a derived `Image-analysis backend` status block below the JSON viewer so you can see the configured backend, whether the hook is persisted, and whether the result is runtime-derived rather than saved in the profile JSON. > **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration. diff --git a/src/ccs.ts b/src/ccs.ts index cd0b6ca1..5a282bd0 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1013,12 +1013,55 @@ async function main(): Promise { } const webSearchEnv = getWebSearchHookEnv(); - const imageAnalysisEnv = getImageAnalysisHookEnv({ + let imageAnalysisEnv = getImageAnalysisHookEnv({ profileName: profileInfo.name, profileType: profileInfo.type, settings, cliproxyBridge, }); + + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; + if ( + resolvedTarget === 'claude' && + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider + ) { + const verboseProxyLaunch = + remainingArgs.includes('--verbose') || + remainingArgs.includes('-v') || + targetRemainingArgs.includes('--verbose') || + targetRemainingArgs.includes('-v'); + + if (!isAuthenticated(imageAnalysisProvider as CLIProxyProvider)) { + 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.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } else { + const ensureServiceResult = await ensureCliproxyService( + CLIPROXY_DEFAULT_PORT, + verboseProxyLaunch + ); + if (!ensureServiceResult.started) { + console.error( + warn( + `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + } + } // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index ca19d5f5..6dcfc8a3 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -292,6 +292,17 @@ function resolveBackend( }; } + const hasDirectAnthropicApiKey = Boolean(settings?.env?.ANTHROPIC_API_KEY?.trim()); + const hasBaseUrl = Boolean(settings?.env?.ANTHROPIC_BASE_URL?.trim()); + if (hasDirectAnthropicApiKey && !hasBaseUrl) { + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'unresolved', + reason: 'Direct Anthropic settings profiles use native file access unless explicitly mapped.', + }; + } + const fallbackBackend = normalizeImageAnalysisBackendId( config.fallback_backend, Object.keys(config.provider_models) @@ -323,7 +334,10 @@ export function resolveImageAnalysisStatus( ? (config.provider_models[resolution.backendId] ?? null) : null; const shouldPersistHook = - config.enabled && context.profileType !== 'default' && context.profileType !== 'account'; + config.enabled && + context.profileType !== 'default' && + context.profileType !== 'account' && + Boolean(resolution.backendId && model); let status: ImageAnalysisStatusCode = 'active'; let reason = resolution.reason; @@ -353,8 +367,16 @@ export function resolveImageAnalysisStatus( (!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.'; + if (!context.cliproxyBridge.usesCurrentTarget && !context.cliproxyBridge.usesCurrentAuthToken) { + reason = + 'Runtime uses the current CLIProxy route and auth token instead of the saved values in this profile.'; + } else if (!context.cliproxyBridge.usesCurrentTarget) { + reason = + 'Runtime uses the current CLIProxy route instead of the saved route in this profile.'; + } else { + reason = + 'Runtime uses the current CLIProxy auth token instead of the saved token in this profile.'; + } } else if (resolution.resolutionSource === 'profile-backend') { status = 'mapped'; } diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index ce3803c3..576fe3ad 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -303,6 +303,13 @@ function resolveImageAnalysisStatusForProfile( }; } +function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) { + const normalizedSettings = canonicalizeProfileSettings(profileOrVariant, settings); + const settingsPath = resolveSettingsPath(profileOrVariant); + + return resolveImageAnalysisStatusForProfile(profileOrVariant, normalizedSettings, settingsPath); +} + function writeSettingsAtomically(settingsPath: string, settings: Settings): void { const tempPath = `${settingsPath}.tmp.${process.pid}`; fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); @@ -428,6 +435,29 @@ 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; + + 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; + } + + 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; diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts index b7146d82..0f49d6b6 100644 --- a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -44,11 +44,17 @@ describe('image-analysis-backend-resolver', () => { expect(status.resolutionSource).toBe('copilot-alias'); }); - it('uses the fallback backend for an unmapped settings profile', () => { + it('uses the fallback backend for an unmapped third-party settings profile', () => { const status = resolveImageAnalysisStatus( { profileName: 'glm', profileType: 'settings', + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_AUTH_TOKEN: 'glm-test-key', + }, + }, }, DEFAULT_IMAGE_ANALYSIS_CONFIG ); @@ -59,6 +65,27 @@ describe('image-analysis-backend-resolver', () => { expect(status.model).toBe('gemini-2.5-flash'); }); + it('keeps direct Anthropic settings profiles on native read unless explicitly mapped', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'claude-direct', + profileType: 'settings', + settings: { + env: { + ANTHROPIC_API_KEY: 'anthropic-test-key', + }, + }, + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(false); + expect(status.backendId).toBeNull(); + expect(status.status).toBe('skipped'); + expect(status.shouldPersistHook).toBe(false); + expect(status.reason).toContain('native file access'); + }); + it('uses explicit profile_backends overrides for custom aliases', () => { const config: ImageAnalysisConfig = { ...DEFAULT_IMAGE_ANALYSIS_CONFIG, @@ -86,6 +113,12 @@ describe('image-analysis-backend-resolver', () => { { profileName: 'glm', profileType: 'settings', + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_AUTH_TOKEN: 'glm-test-key', + }, + }, hookInstalled: false, sharedHookInstalled: true, }, diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts index 229edb86..cae53e5e 100644 --- a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -113,6 +113,33 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json'); }); + it('keeps direct Anthropic settings profiles on native read diagnostics', async () => { + writeJson(path.join(tempHome, '.ccs', 'claude-direct.settings.json'), { + env: { + ANTHROPIC_API_KEY: 'anthropic-test-key', + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/claude-direct/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + status: string; + backendId: string | null; + shouldPersistHook: boolean; + runtimePath: string | null; + reason: string | null; + }; + }; + + expect(body.imageAnalysisStatus.status).toBe('skipped'); + expect(body.imageAnalysisStatus.backendId).toBeNull(); + expect(body.imageAnalysisStatus.shouldPersistHook).toBe(false); + expect(body.imageAnalysisStatus.runtimePath).toBeNull(); + expect(body.imageAnalysisStatus.reason).toContain('native file access'); + }); + it('returns explicit mapped status for custom aliases', async () => { writeJson(path.join(tempHome, '.ccs', 'config.yaml'), { version: 11, @@ -183,4 +210,35 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.persistencePath).toBe(customSettingsPath); expect(body.imageAnalysisStatus.hookInstalled).toBe(true); }); + + it('previews image-analysis status from unsaved editor settings', async () => { + writeProfileSettings(tempHome, 'glm', { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_API_KEY: 'glm-test-key', + }); + + const response = await fetch(`${baseUrl}/api/settings/glm/image-analysis-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/ghcp', + ANTHROPIC_AUTH_TOKEN: 'preview-token', + }, + }, + }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + imageAnalysisStatus: { + backendId: string | null; + resolutionSource: string; + }; + }; + + expect(body.imageAnalysisStatus.backendId).toBe('ghcp'); + expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge'); + }); }); diff --git a/ui/src/components/profiles/editor/image-analysis-status-section.tsx b/ui/src/components/profiles/editor/image-analysis-status-section.tsx index e807a251..ab9aa0ef 100644 --- a/ui/src/components/profiles/editor/image-analysis-status-section.tsx +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -5,6 +5,8 @@ import type { ImageAnalysisStatus } from '@/lib/api-client'; interface ImageAnalysisStatusSectionProps { status?: ImageAnalysisStatus | null; + source?: 'saved' | 'editor'; + previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; } function getBadge(status: ImageAnalysisStatus | null | undefined): { @@ -13,7 +15,7 @@ function getBadge(status: ImageAnalysisStatus | null | undefined): { } { switch (status?.status) { case 'active': - return { label: 'Ready', variant: 'default' }; + return { label: 'Configured', variant: 'default' }; case 'mapped': return { label: 'Saved mapping', variant: 'secondary' }; case 'attention': @@ -36,28 +38,32 @@ function getSummary(status: ImageAnalysisStatus): string { 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.`; + 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 `Ready via ${backendName}, but runtime is using the current CLIProxy route instead of the route saved in this profile.`; + return `Configured via ${backendName}, but ${status.reason || 'runtime details no longer match the saved profile state.'}`; case 'hook-missing': - return `Configured for ${backendName}, but the image-analysis hook is not fully installed yet.`; + 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 `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`; + 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 `Ready via ${backendName} fallback. Images and PDFs are routed through CLIProxy before Claude sees text.`; + return `Configured via ${backendName} fallback. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`; } - return `Ready via ${backendName}. Images and PDFs are routed through CLIProxy before Claude sees text.`; + return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`; } } function getRuntimeLine(status: ImageAnalysisStatus): string { + if (status.status === 'hook-missing') { + return 'Read -> native file access (hook install required)'; + } + if (!status.runtimePath) { return 'Read -> native file access'; } @@ -65,6 +71,25 @@ function getRuntimeLine(status: ImageAnalysisStatus): string { return `Read -> image-analysis hook -> ${status.runtimePath}`; } +function getStatusContext( + source: 'saved' | 'editor', + previewState: ImageAnalysisStatusSectionProps['previewState'] +): string { + if (previewState === 'invalid') { + return 'Showing last saved runtime status. The live preview resumes when the JSON above is valid again.'; + } + + 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 'Saved runtime status for this profile. This section is derived and is not written into the JSON editor above.'; +} + function getPersistenceLine(status: ImageAnalysisStatus): string { if (!status.shouldPersistHook || !status.persistencePath) { return 'Not persisted for this profile type'; @@ -77,7 +102,11 @@ function getPersistenceLine(status: ImageAnalysisStatus): string { return `${status.persistencePath} hook missing`; } -export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectionProps) { +export function ImageAnalysisStatusSection({ + status, + source = 'saved', + previewState = 'saved', +}: ImageAnalysisStatusSectionProps) { if (!status) { return (
@@ -101,7 +130,7 @@ export function ImageAnalysisStatusSection({ status }: ImageAnalysisStatusSectio

Image-analysis backend

- Derived runtime status. This section is not written into the JSON editor above. + {getStatusContext(source, previewState)}

diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index 470de66b..bb4242a2 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -4,7 +4,7 @@ */ /* eslint-disable react-refresh/only-export-components */ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback, useEffect, useDeferredValue } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; @@ -107,6 +107,63 @@ export function ProfileEditor({ return Object.keys(localEdits).length > 0; }, [rawJsonEdits, localEdits, settings]); + const deferredPreviewJson = useDeferredValue(computedRawJsonContent); + const previewSettings = useMemo((): Settings | null => { + if (!computedHasChanges || !computedIsRawJsonValid) { + return null; + } + + try { + return JSON.parse(deferredPreviewJson) as Settings; + } catch { + return null; + } + }, [computedHasChanges, computedIsRawJsonValid, deferredPreviewJson]); + + const { + data: previewStatusResponse, + isFetching: isPreviewStatusFetching, + isError: isPreviewStatusError, + } = useQuery<{ imageAnalysisStatus: SettingsResponse['imageAnalysisStatus'] }>({ + queryKey: ['settings', profileName, 'image-analysis-status-preview', deferredPreviewJson], + enabled: previewSettings !== null, + placeholderData: (previousData) => previousData, + queryFn: async () => { + const res = await fetch(`/api/settings/${profileName}/image-analysis-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: previewSettings }), + }); + + if (!res.ok) { + throw new Error(`Failed to preview image-analysis status: ${res.status}`); + } + + return res.json(); + }, + }); + + const imageAnalysisStatus = + computedHasChanges && computedIsRawJsonValid && !isPreviewStatusError + ? (previewStatusResponse?.imageAnalysisStatus ?? data?.imageAnalysisStatus) + : data?.imageAnalysisStatus; + const imageAnalysisStatusSource = + computedHasChanges && + computedIsRawJsonValid && + !isPreviewStatusError && + previewStatusResponse?.imageAnalysisStatus + ? 'editor' + : 'saved'; + const imageAnalysisStatusPreviewState = !computedHasChanges + ? 'saved' + : !computedIsRawJsonValid + ? 'invalid' + : isPreviewStatusError + ? 'saved' + : isPreviewStatusFetching && !previewStatusResponse?.imageAnalysisStatus + ? 'refreshing' + : 'preview'; + // Check for missing required fields (informational warning) const missingRequiredFields = useMemo(() => { const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; @@ -254,7 +311,9 @@ export function ProfileEditor({ isRawJsonValid={computedIsRawJsonValid} rawJsonEdits={rawJsonEdits} settings={settings} - imageAnalysisStatus={data?.imageAnalysisStatus} + imageAnalysisStatus={imageAnalysisStatus} + imageAnalysisStatusSource={imageAnalysisStatusSource} + imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState} onChange={handleRawJsonChange} missingRequiredFields={missingRequiredFields} /> diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index c077ccee..45902541 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -21,6 +21,8 @@ interface RawEditorSectionProps { rawJsonEdits: string | null; settings: Settings | undefined; imageAnalysisStatus?: ImageAnalysisStatus | null; + imageAnalysisStatusSource?: 'saved' | 'editor'; + imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; onChange: (value: string) => void; missingRequiredFields?: string[]; } @@ -31,6 +33,8 @@ export function RawEditorSection({ rawJsonEdits, settings, imageAnalysisStatus, + imageAnalysisStatusSource = 'saved', + imageAnalysisStatusPreviewState = 'saved', onChange, missingRequiredFields = [], }: RawEditorSectionProps) { @@ -80,7 +84,11 @@ export function RawEditorSection({
- +
{/* Global Env Indicator */}
diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx index dfb07934..87b91b4f 100644 --- a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx +++ b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx @@ -1,8 +1,36 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { ImageAnalysisStatusSection } from '@/components/profiles/editor/image-analysis-status-section'; +import { fireEvent, render, screen, waitFor } from '@tests/setup/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ImageAnalysisStatus } from '@/lib/api-client'; +vi.mock('@/components/shared/code-editor', () => ({ + CodeEditor: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( +