feat(image-analysis): resolve backend status per profile

This commit is contained in:
Tam Nhu Tran committed 2026-04-01 15:32:37 -04:00
1 parent 8e4f56d8f9
commit ae459fc3d7
17 files changed
+932 -48

No files matched your search

@@ -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}
rawJsonEdits={rawJsonEdits}
settings={settings}
imageAnalysisStatus={data?.imageAnalysisStatus}
onChange={handleRawJsonChange}
missingRequiredFields={missingRequiredFields}
/>
@@ -6,7 +6,9 @@
import { Suspense, lazy } from 'react';
import { Loader2, X, AlertTriangle } from 'lucide-react';
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
import { ImageAnalysisStatusSection } from './image-analysis-status-section';
import type { Settings } from './types';
import type { ImageAnalysisStatus } from '@/lib/api-client';
// Lazy load CodeEditor
const CodeEditor = lazy(() =>
@@ -18,6 +20,7 @@ interface RawEditorSectionProps {
isRawJsonValid: boolean;
rawJsonEdits: string | null;
settings: Settings | undefined;
imageAnalysisStatus?: ImageAnalysisStatus | null;
onChange: (value: string) => void;
missingRequiredFields?: string[];
}
@@ -27,6 +30,7 @@ export function RawEditorSection({
isRawJsonValid,
rawJsonEdits,
settings,
imageAnalysisStatus,
onChange,
missingRequiredFields = [],
}: RawEditorSectionProps) {
@@ -75,6 +79,9 @@ export function RawEditorSection({
/>
</div>
</div>
<div className="mx-6 mb-4">
<ImageAnalysisStatusSection status={imageAnalysisStatus} />
</div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
+2 -1
View File
@@ -2,7 +2,7 @@
* Types for Profile Editor
*/
import type { CliTarget, CliproxyBridgeMetadata } from '@/lib/api-client';
import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/lib/api-client';
export interface Settings {
env?: Record<string, string>;
@@ -14,6 +14,7 @@ export interface SettingsResponse {
mtime: number;
path: string;
cliproxyBridge?: CliproxyBridgeMetadata | null;
imageAnalysisStatus?: ImageAnalysisStatus | null;
}
export interface ProfileEditorProps {
+29
View File
@@ -111,6 +111,35 @@ export interface CliproxyBridgeMetadata {
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 {
name: string;
settingsPath: string;