mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 14:16:16 +00:00
feat(image): add native-read controls and autosave settings
- add per-profile native image preferences and native-capability detection - redesign Settings -> Image around compact sections with Web-style autosave - expose richer backend/profile status and update Gemini Flash defaults
This commit is contained in:
1 parent
9d1d281e34
commit
982ffc5895
30 files changed
+1893
-708
No files matched your search
@@ -1,7 +1,8 @@
|
||||
import { AlertTriangle, ArrowUpRight, Image as ImageIcon } from 'lucide-react';
|
||||
import { ArrowUpRight, Image as ImageIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CliTarget, ImageAnalysisStatus } from '@/lib/api-client';
|
||||
|
||||
@@ -10,158 +11,122 @@ interface ImageAnalysisStatusSectionProps {
|
||||
target?: CliTarget;
|
||||
source?: 'saved' | 'editor';
|
||||
previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
|
||||
nativeReadPreferenceOverride?: boolean;
|
||||
onToggleNativeRead?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<ImageAnalysisStatus['resolutionSource'], string> = {
|
||||
'cliproxy-provider': 'Direct provider route',
|
||||
'cliproxy-variant': 'Variant route',
|
||||
'cliproxy-composite': 'Composite route',
|
||||
'copilot-alias': 'Copilot alias',
|
||||
'cliproxy-bridge': 'Derived from profile route',
|
||||
'profile-backend': 'Explicit profile mapping',
|
||||
'fallback-backend': 'Fallback backend',
|
||||
disabled: 'Disabled globally',
|
||||
'unsupported-profile': 'Unsupported profile type',
|
||||
unresolved: 'No backend mapped',
|
||||
'missing-model': 'Missing model',
|
||||
};
|
||||
|
||||
const TARGET_LABELS: Record<CliTarget, string> = {
|
||||
claude: 'Claude Code',
|
||||
droid: 'Factory Droid',
|
||||
codex: 'Codex CLI',
|
||||
};
|
||||
|
||||
function getPreviewBadge(
|
||||
function getPreviewLabel(
|
||||
source: 'saved' | 'editor',
|
||||
previewState: ImageAnalysisStatusSectionProps['previewState']
|
||||
) {
|
||||
if (previewState === 'refreshing') {
|
||||
return { label: 'Refreshing', variant: 'outline' as const };
|
||||
}
|
||||
if (source === 'editor') {
|
||||
return { label: 'Live Preview', variant: 'secondary' as const };
|
||||
}
|
||||
return { label: 'Saved', variant: 'outline' as const };
|
||||
if (previewState === 'refreshing') return 'Refreshing preview';
|
||||
if (previewState === 'invalid') return 'Saved status';
|
||||
return source === 'editor' ? 'Live preview' : 'Saved status';
|
||||
}
|
||||
|
||||
function getRuntimeBadge(status: ImageAnalysisStatus, target: CliTarget) {
|
||||
function getHeaderLabel(status: ImageAnalysisStatus, target: CliTarget): string {
|
||||
if (status.status === 'disabled') return 'Disabled globally';
|
||||
if (target !== 'claude') return `${TARGET_LABELS[target]} bypasses the hook`;
|
||||
if (status.nativeReadPreference) return 'Native image reading';
|
||||
if (status.status === 'hook-missing') return 'Setup needed';
|
||||
if (status.authReadiness === 'missing') return 'Needs auth';
|
||||
if (status.proxyReadiness === 'unavailable') return 'Needs proxy';
|
||||
if (status.effectiveRuntimeMode === 'native-read') return 'Native fallback';
|
||||
return 'Transformer ready';
|
||||
}
|
||||
|
||||
function getHeaderBadge(
|
||||
status: ImageAnalysisStatus,
|
||||
target: CliTarget
|
||||
): {
|
||||
label: string;
|
||||
className: string;
|
||||
} {
|
||||
if (status.status === 'disabled') {
|
||||
return {
|
||||
label: 'Disabled',
|
||||
className: 'border-border/80 bg-background/85 text-muted-foreground',
|
||||
};
|
||||
}
|
||||
if (status.status === 'hook-missing') {
|
||||
return {
|
||||
label: 'Setup needed',
|
||||
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
|
||||
};
|
||||
}
|
||||
if (status.authReadiness === 'missing') {
|
||||
return {
|
||||
label: 'Needs auth',
|
||||
className: 'border-rose-500/25 bg-rose-500/10 text-rose-800 dark:text-rose-200',
|
||||
};
|
||||
}
|
||||
if (status.proxyReadiness === 'unavailable') {
|
||||
return {
|
||||
label: 'Needs proxy',
|
||||
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
|
||||
};
|
||||
}
|
||||
if (target !== 'claude') {
|
||||
return {
|
||||
label: 'Bypassed',
|
||||
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
|
||||
};
|
||||
}
|
||||
if (status.effectiveRuntimeMode === 'native-read') {
|
||||
if (status.nativeReadPreference) {
|
||||
return {
|
||||
label: 'Native fallback',
|
||||
className: 'border-border/80 bg-background/85 text-muted-foreground',
|
||||
label: 'Native',
|
||||
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
|
||||
};
|
||||
}
|
||||
if (status.proxyReadiness === 'stopped') {
|
||||
if (status.status === 'hook-missing' || status.authReadiness === 'missing') {
|
||||
return {
|
||||
label: 'Starts on launch',
|
||||
className: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
|
||||
label: status.status === 'hook-missing' ? 'Setup' : 'Auth',
|
||||
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
|
||||
};
|
||||
}
|
||||
if (status.proxyReadiness === 'unavailable') {
|
||||
return {
|
||||
label: 'Proxy',
|
||||
className: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'CLIProxy active',
|
||||
label: 'Ready',
|
||||
className: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
|
||||
};
|
||||
}
|
||||
|
||||
function getStatusContext(
|
||||
source: 'saved' | 'editor',
|
||||
previewState: ImageAnalysisStatusSectionProps['previewState']
|
||||
) {
|
||||
if (previewState === 'invalid') {
|
||||
return 'Showing saved status until the JSON above is valid again.';
|
||||
function getToggleSummary(status: ImageAnalysisStatus, target: CliTarget): string {
|
||||
if (status.nativeReadPreference) {
|
||||
if (status.profileModel && status.nativeImageCapable) {
|
||||
return `${status.profileModel} looks image-ready. CCS will bypass the transformer here.`;
|
||||
}
|
||||
if (status.profileModel) {
|
||||
return `CCS will prefer native reading for ${status.profileModel}.`;
|
||||
}
|
||||
return 'CCS will prefer native image reading for this profile.';
|
||||
}
|
||||
if (previewState === 'refreshing') {
|
||||
return 'Refreshing from the current editor state.';
|
||||
|
||||
if (!status.backendDisplayName && target === 'claude') {
|
||||
return 'This profile currently stays on native file access.';
|
||||
}
|
||||
return source === 'editor'
|
||||
? 'Preview from the current editor JSON.'
|
||||
: 'Saved status for this profile.';
|
||||
|
||||
if (!status.backendDisplayName) {
|
||||
return `Saved Claude-side image routing is inactive while ${TARGET_LABELS[target]} is selected.`;
|
||||
}
|
||||
|
||||
const modelSuffix = status.model ? ` · ${status.model}` : '';
|
||||
return `Transformer route: ${status.backendDisplayName}${modelSuffix}.`;
|
||||
}
|
||||
|
||||
function getSummary(status: ImageAnalysisStatus, target: CliTarget): string {
|
||||
const backendName = status.backendDisplayName || status.backendId || 'native file access';
|
||||
function getExceptionalNote(status: ImageAnalysisStatus, target: CliTarget): string | null {
|
||||
if (status.status === 'disabled') {
|
||||
return 'Image Analysis is disabled globally for this profile.';
|
||||
}
|
||||
if (!status.backendId) {
|
||||
return target === 'claude'
|
||||
? 'This profile currently uses native file access for images and PDFs.'
|
||||
: `Current target ${TARGET_LABELS[target]} bypasses the hook and no saved backend is mapped.`;
|
||||
return 'Image is disabled globally in CCS settings.';
|
||||
}
|
||||
if (target !== 'claude') {
|
||||
return status.effectiveRuntimeMode === 'native-read'
|
||||
? `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side setup for ${backendName} currently falls back to native file access.`
|
||||
: `Current target ${TARGET_LABELS[target]} bypasses the hook. Saved Claude-side backend: ${backendName}.`;
|
||||
return `Current target ${TARGET_LABELS[target]} bypasses the Claude Read hook.`;
|
||||
}
|
||||
if (status.effectiveRuntimeMode === 'native-read') {
|
||||
return `Saved backend: ${backendName}. Current runtime falls back to native file access.`;
|
||||
}
|
||||
return `Saved backend: ${backendName}. Images and PDFs resolve through CLIProxy on this target.`;
|
||||
}
|
||||
|
||||
function getTargetDetail(status: ImageAnalysisStatus, target: CliTarget): string {
|
||||
if (target !== 'claude') {
|
||||
return 'Current launch path bypasses the Claude Read hook.';
|
||||
if (status.nativeReadPreference) {
|
||||
return status.nativeImageCapable === true ? null : status.nativeImageReason;
|
||||
}
|
||||
if (status.status === 'hook-missing') {
|
||||
return 'Hook must be persisted before this profile can use Image Analysis.';
|
||||
return 'Persist the profile hook before transformer routing can run here.';
|
||||
}
|
||||
if (status.effectiveRuntimeMode === 'native-read') {
|
||||
return status.effectiveRuntimeReason || status.reason || 'Using native file access.';
|
||||
if (status.authReadiness === 'missing') {
|
||||
return status.authReason;
|
||||
}
|
||||
if (status.proxyReadiness === 'stopped') {
|
||||
return 'Auth is ready. Local CLIProxy will start on demand.';
|
||||
if (status.proxyReadiness === 'unavailable') {
|
||||
return status.proxyReason;
|
||||
}
|
||||
return 'Current target can use the saved backend.';
|
||||
}
|
||||
|
||||
function getPersistenceValue(status: ImageAnalysisStatus): string {
|
||||
if (!status.shouldPersistHook || !status.persistencePath) {
|
||||
return 'Not required';
|
||||
}
|
||||
return status.hookInstalled ? 'Hook saved' : 'Hook missing';
|
||||
}
|
||||
|
||||
function getPersistenceDetail(status: ImageAnalysisStatus): string {
|
||||
if (!status.shouldPersistHook || !status.persistencePath) {
|
||||
return 'No profile-level hook persistence required.';
|
||||
}
|
||||
return status.persistencePath;
|
||||
}
|
||||
|
||||
function getModelValue(status: ImageAnalysisStatus): string {
|
||||
return status.model || status.reason || 'Unavailable';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ImageAnalysisStatusSection({
|
||||
@@ -169,147 +134,88 @@ export function ImageAnalysisStatusSection({
|
||||
target = 'claude',
|
||||
source = 'saved',
|
||||
previewState = 'saved',
|
||||
nativeReadPreferenceOverride,
|
||||
onToggleNativeRead,
|
||||
}: ImageAnalysisStatusSectionProps) {
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 p-4" aria-live="polite">
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-64 animate-pulse rounded bg-muted" />
|
||||
<div className="rounded-2xl border bg-muted/20 px-4 py-3" aria-live="polite">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-52 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const previewBadge = getPreviewBadge(source, previewState);
|
||||
const runtimeBadge = getRuntimeBadge(status, target);
|
||||
const showNotice =
|
||||
status.status === 'hook-missing' ||
|
||||
status.authReadiness === 'missing' ||
|
||||
status.proxyReadiness === 'unavailable';
|
||||
const nativeReadChecked = nativeReadPreferenceOverride ?? status.nativeReadPreference;
|
||||
const effectiveStatus = { ...status, nativeReadPreference: nativeReadChecked };
|
||||
const headerBadge = getHeaderBadge(effectiveStatus, target);
|
||||
const note = getExceptionalNote(effectiveStatus, target);
|
||||
const capabilityLabel = status.nativeImageCapable
|
||||
? 'Verified'
|
||||
: status.profileModel
|
||||
? 'Unknown'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="rounded-md border bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<section className="rounded-2xl border bg-background/95 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-sky-600" />
|
||||
<h3 className="text-sm font-semibold">Image Analysis</h3>
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">Image</h3>
|
||||
<Badge className={cn('h-5 border px-1.5 text-[10px]', headerBadge.className)}>
|
||||
{headerBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getPreviewLabel(source, previewState)} · {getHeaderLabel(effectiveStatus, target)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{getStatusContext(source, previewState)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={previewBadge.variant} className="h-5 px-1.5 text-[10px]">
|
||||
{previewBadge.label}
|
||||
</Badge>
|
||||
<Badge className={cn('h-5 border px-1.5 text-[10px]', runtimeBadge.className)}>
|
||||
{runtimeBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">{getSummary(status, target)}</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-md border bg-background/70 p-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Backend
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">
|
||||
{status.backendDisplayName || status.backendId || 'Native file access'}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{SOURCE_LABELS[status.resolutionSource]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background/70 p-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Current target
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">{TARGET_LABELS[target]}</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{getTargetDetail(status, target)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background/70 p-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Persistence
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-foreground">
|
||||
{getPersistenceValue(status)}
|
||||
</div>
|
||||
<p
|
||||
className="mt-1 text-xs leading-5 text-muted-foreground"
|
||||
title={status.persistencePath || 'Not required'}
|
||||
>
|
||||
{getPersistenceDetail(status)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
<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">
|
||||
{status.authReadiness === 'ready'
|
||||
? `${status.authDisplayName || status.authProvider} ready`
|
||||
: status.authReadiness === 'missing'
|
||||
? status.authReason
|
||||
: status.authReadiness === 'not-needed'
|
||||
? 'Not required'
|
||||
: 'Unknown'}
|
||||
</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">
|
||||
{status.proxyReadiness === 'ready'
|
||||
? 'Local CLIProxy ready'
|
||||
: status.proxyReadiness === 'remote'
|
||||
? 'Remote CLIProxy ready'
|
||||
: status.proxyReadiness === 'stopped'
|
||||
? 'Local CLIProxy idle'
|
||||
: status.proxyReadiness === 'not-needed'
|
||||
? 'Not required'
|
||||
: status.proxyReason || 'Unknown'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Model
|
||||
</dt>
|
||||
<dd className={cn('text-sm text-foreground', status.model && 'font-mono text-xs')}>
|
||||
{getModelValue(status)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{showNotice && (
|
||||
<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.effectiveRuntimeReason || status.reason || 'Review the saved configuration.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CRUD and backend routing now live in global Settings.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/settings?tab=imageanalysis">
|
||||
<Button size="sm" variant="outline" className="h-8 shrink-0" asChild>
|
||||
<Link to="/settings?tab=image">
|
||||
Open Settings
|
||||
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-xl border bg-muted/15 px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-medium text-foreground">Use native image reading</div>
|
||||
{capabilityLabel && (
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||
{capabilityLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{getToggleSummary(effectiveStatus, target)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={nativeReadChecked}
|
||||
onCheckedChange={onToggleNativeRead}
|
||||
disabled={!onToggleNativeRead}
|
||||
aria-label="Use native image reading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<div className="mt-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
{note}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,31 @@ export function ProfileEditor({
|
||||
setRawJsonEdits(value);
|
||||
}, []);
|
||||
|
||||
const updateNativeImageRead = useCallback(
|
||||
(enabled: boolean) => {
|
||||
const nextSettings = { ...(currentSettings ?? {}) } as Settings;
|
||||
const currentCcsImage =
|
||||
nextSettings.ccs_image && typeof nextSettings.ccs_image === 'object'
|
||||
? { ...nextSettings.ccs_image }
|
||||
: {};
|
||||
|
||||
if (enabled) {
|
||||
currentCcsImage.native_read = true;
|
||||
} else {
|
||||
delete currentCcsImage.native_read;
|
||||
}
|
||||
|
||||
if (Object.keys(currentCcsImage).length > 0) {
|
||||
nextSettings.ccs_image = currentCcsImage;
|
||||
} else {
|
||||
delete nextSettings.ccs_image;
|
||||
}
|
||||
|
||||
setRawJsonEdits(JSON.stringify(nextSettings, null, 2));
|
||||
},
|
||||
[currentSettings]
|
||||
);
|
||||
|
||||
// Sync Visual Editor changes to Raw JSON
|
||||
const updateEnvValue = (key: string, value: string) => {
|
||||
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
|
||||
@@ -165,6 +190,7 @@ export function ProfileEditor({
|
||||
(!previewStatusResponse?.imageAnalysisStatus || isPreviewStatusPlaceholderData)
|
||||
? 'refreshing'
|
||||
: 'preview';
|
||||
const nativeReadPreferenceOverride = currentSettings?.ccs_image?.native_read === true;
|
||||
|
||||
// Check for missing required fields (informational warning)
|
||||
const missingRequiredFields = useMemo(() => {
|
||||
@@ -318,6 +344,8 @@ export function ProfileEditor({
|
||||
imageAnalysisStatus={imageAnalysisStatus}
|
||||
imageAnalysisStatusSource={imageAnalysisStatusSource}
|
||||
imageAnalysisStatusPreviewState={imageAnalysisStatusPreviewState}
|
||||
nativeReadPreferenceOverride={nativeReadPreferenceOverride}
|
||||
onToggleNativeRead={updateNativeImageRead}
|
||||
onChange={handleRawJsonChange}
|
||||
missingRequiredFields={missingRequiredFields}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,8 @@ interface RawEditorSectionProps {
|
||||
imageAnalysisStatus?: ImageAnalysisStatus | null;
|
||||
imageAnalysisStatusSource?: 'saved' | 'editor';
|
||||
imageAnalysisStatusPreviewState?: 'saved' | 'preview' | 'refreshing' | 'invalid';
|
||||
nativeReadPreferenceOverride?: boolean;
|
||||
onToggleNativeRead?: (enabled: boolean) => void;
|
||||
onChange: (value: string) => void;
|
||||
missingRequiredFields?: string[];
|
||||
}
|
||||
@@ -37,6 +39,8 @@ export function RawEditorSection({
|
||||
imageAnalysisStatus,
|
||||
imageAnalysisStatusSource = 'saved',
|
||||
imageAnalysisStatusPreviewState = 'saved',
|
||||
nativeReadPreferenceOverride,
|
||||
onToggleNativeRead,
|
||||
onChange,
|
||||
missingRequiredFields = [],
|
||||
}: RawEditorSectionProps) {
|
||||
@@ -91,6 +95,8 @@ export function RawEditorSection({
|
||||
target={profileTarget}
|
||||
source={imageAnalysisStatusSource}
|
||||
previewState={imageAnalysisStatusPreviewState}
|
||||
nativeReadPreferenceOverride={nativeReadPreferenceOverride}
|
||||
onToggleNativeRead={onToggleNativeRead}
|
||||
/>
|
||||
</div>
|
||||
{/* Global Env Indicator */}
|
||||
|
||||
@@ -6,6 +6,9 @@ import type { CliTarget, CliproxyBridgeMetadata, ImageAnalysisStatus } from '@/l
|
||||
|
||||
export interface Settings {
|
||||
env?: Record<string, string>;
|
||||
ccs_image?: {
|
||||
native_read?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface ImageAnalysisStatus {
|
||||
| 'cliproxy-bridge'
|
||||
| 'profile-backend'
|
||||
| 'fallback-backend'
|
||||
| 'native-compatible'
|
||||
| 'disabled'
|
||||
| 'unsupported-profile'
|
||||
| 'unresolved'
|
||||
@@ -146,6 +147,10 @@ export interface ImageAnalysisStatus {
|
||||
proxyReason: string | null;
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis' | 'native-read';
|
||||
effectiveRuntimeReason: string | null;
|
||||
profileModel: string | null;
|
||||
nativeReadPreference: boolean;
|
||||
nativeImageCapable: boolean | null;
|
||||
nativeImageReason: string | null;
|
||||
}
|
||||
|
||||
export interface ImageAnalysisSettingsConfig {
|
||||
@@ -164,6 +169,7 @@ export interface ImageAnalysisDashboardSummary {
|
||||
mappedProfileCount: number;
|
||||
activeProfileCount: number;
|
||||
bypassedProfileCount: number;
|
||||
nativeProfileCount: number;
|
||||
}
|
||||
|
||||
export interface ImageAnalysisDashboardBackend {
|
||||
@@ -190,7 +196,18 @@ export interface ImageAnalysisDashboardProfile {
|
||||
status: ImageAnalysisStatus['status'];
|
||||
effectiveRuntimeMode: ImageAnalysisStatus['effectiveRuntimeMode'];
|
||||
effectiveRuntimeReason: string | null;
|
||||
currentTargetMode: 'active' | 'bypassed' | 'fallback' | 'setup' | 'disabled' | 'unresolved';
|
||||
currentTargetMode:
|
||||
| 'active'
|
||||
| 'bypassed'
|
||||
| 'fallback'
|
||||
| 'setup'
|
||||
| 'disabled'
|
||||
| 'native'
|
||||
| 'unresolved';
|
||||
profileModel: string | null;
|
||||
nativeReadPreference: boolean;
|
||||
nativeImageCapable: boolean | null;
|
||||
nativeImageReason: string | null;
|
||||
}
|
||||
|
||||
export interface ImageAnalysisDashboardCatalog {
|
||||
|
||||
@@ -150,19 +150,19 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
default: 'gemini-3.1-pro-preview',
|
||||
opus: 'gemini-3.1-pro-preview',
|
||||
sonnet: 'gemini-3.1-pro-preview',
|
||||
haiku: 'gemini-3-flash-preview',
|
||||
haiku: 'gemini-3-1-flash-preview',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash-preview',
|
||||
id: 'gemini-3-1-flash-preview',
|
||||
name: 'Gemini Flash',
|
||||
description: 'Resolves to the best advertised Gemini Flash preview via Antigravity',
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: 'gemini-3-flash-preview',
|
||||
default: 'gemini-3-1-flash-preview',
|
||||
opus: 'gemini-3.1-pro-preview',
|
||||
sonnet: 'gemini-3.1-pro-preview',
|
||||
haiku: 'gemini-3-flash-preview',
|
||||
haiku: 'gemini-3-1-flash-preview',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -26,7 +26,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
const tabs = [
|
||||
{ value: 'websearch' as const, label: t('settingsTabs.web'), icon: Globe },
|
||||
{ value: 'imageanalysis' as const, label: 'Image Analysis', icon: ImageIcon },
|
||||
{ value: 'image' as const, label: 'Image', icon: ImageIcon },
|
||||
{ value: 'channels' as const, label: 'Channels', icon: MessageSquare },
|
||||
{ value: 'globalenv' as const, label: t('settingsTabs.env'), icon: Settings2 },
|
||||
{ value: 'thinking' as const, label: t('settingsTabs.think'), icon: Brain },
|
||||
@@ -39,7 +39,7 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
|
||||
<TabsList className="grid w-full grid-cols-8">
|
||||
{tabs.map(({ value, label, icon: Icon }) => (
|
||||
<TabsTrigger key={value} value={value} className="gap-1.5 px-1 text-xs">
|
||||
<TabsTrigger key={value} value={value} className="gap-1.5 px-2 text-xs">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -11,8 +11,8 @@ export function useSettingsTab() {
|
||||
// Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups)
|
||||
const tabParam = searchParams.get('tab')?.toLowerCase();
|
||||
const activeTab: SettingsTab =
|
||||
tabParam === 'imageanalysis'
|
||||
? 'imageanalysis'
|
||||
tabParam === 'imageanalysis' || tabParam === 'image'
|
||||
? 'image'
|
||||
: tabParam === 'channels'
|
||||
? 'channels'
|
||||
: tabParam === 'globalenv'
|
||||
|
||||
@@ -132,7 +132,7 @@ function SettingsPageInner() {
|
||||
<SectionErrorBoundary>
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
|
||||
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||
{activeTab === 'channels' && <ChannelsSection />}
|
||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||
{activeTab === 'thinking' && <ThinkingSection />}
|
||||
@@ -146,7 +146,7 @@ function SettingsPageInner() {
|
||||
{/* Desktop View - Side-by-side panels */}
|
||||
<PanelGroup direction="horizontal" className="h-full hidden md:flex">
|
||||
{/* Left Panel - Settings Controls */}
|
||||
<Panel defaultSize={40} minSize={30} maxSize={55}>
|
||||
<Panel defaultSize={46} minSize={36} maxSize={62}>
|
||||
<div className="h-full border-r flex flex-col bg-muted/30 relative">
|
||||
{/* Header with Tabs */}
|
||||
<div className="p-5 border-b bg-background">
|
||||
@@ -157,7 +157,7 @@ function SettingsPageInner() {
|
||||
<SectionErrorBoundary>
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'imageanalysis' && <ImageAnalysisSection />}
|
||||
{activeTab === 'image' && <ImageAnalysisSection />}
|
||||
{activeTab === 'channels' && <ChannelsSection />}
|
||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||
{activeTab === 'thinking' && <ThinkingSection />}
|
||||
@@ -175,7 +175,7 @@ function SettingsPageInner() {
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Right Panel - Config Viewer */}
|
||||
<Panel defaultSize={60} minSize={35}>
|
||||
<Panel defaultSize={54} minSize={35}>
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-background flex items-center justify-between">
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -161,7 +161,7 @@ export interface OfficialChannelsStatus {
|
||||
|
||||
export type SettingsTab =
|
||||
| 'websearch'
|
||||
| 'imageanalysis'
|
||||
| 'image'
|
||||
| 'channels'
|
||||
| 'globalenv'
|
||||
| 'proxy'
|
||||
|
||||
@@ -38,7 +38,7 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
|
||||
status: 'active',
|
||||
backendId: 'gemini',
|
||||
backendDisplayName: 'Google Gemini',
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
resolutionSource: 'cliproxy-bridge',
|
||||
reason: null,
|
||||
shouldPersistHook: true,
|
||||
@@ -56,6 +56,10 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
|
||||
proxyReason: 'Local CLIProxy service is reachable.',
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||
effectiveRuntimeReason: null,
|
||||
profileModel: 'gemini-3-flash-preview',
|
||||
nativeReadPreference: false,
|
||||
nativeImageCapable: true,
|
||||
nativeImageReason: 'gemini-3-flash-preview can read images natively.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -80,18 +84,16 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
it('renders a compact saved summary with a settings link', () => {
|
||||
render(<ImageAnalysisStatusSection status={createStatus()} />);
|
||||
|
||||
expect(screen.getByText('Image Analysis')).toBeInTheDocument();
|
||||
expect(screen.getByText('Saved')).toBeInTheDocument();
|
||||
expect(screen.getByText('CLIProxy active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Image')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Saved status · Transformer ready/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Ready')).toBeInTheDocument();
|
||||
expect(screen.getByText('Use native image reading')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Saved backend: Google Gemini\. Images and PDFs resolve through CLIProxy/i)
|
||||
screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Backend')).toBeInTheDocument();
|
||||
expect(screen.getByText('Current target')).toBeInTheDocument();
|
||||
expect(screen.getByText('Persistence')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Open Settings/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings?tab=imageanalysis'
|
||||
'/settings?tab=image'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -99,12 +101,12 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
render(<ImageAnalysisStatusSection status={createStatus()} target="codex" />);
|
||||
|
||||
expect(screen.getByText('Bypassed')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Saved status · Codex CLI bypasses the hook/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Current target Codex CLI bypasses the hook\. Saved Claude-side backend/i)
|
||||
screen.getByText(/Transformer route: Google Gemini · gemini-3-flash-preview\./i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Codex CLI')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Current launch path bypasses the Claude Read hook/i)
|
||||
screen.getByText(/Current target Codex CLI bypasses the Claude Read hook/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -114,6 +116,8 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
status={createStatus({
|
||||
backendId: 'ghcp',
|
||||
backendDisplayName: 'GitHub Copilot (OAuth)',
|
||||
model: 'claude-haiku-4.5',
|
||||
profileModel: 'claude-haiku-4.5',
|
||||
authReadiness: 'missing',
|
||||
authProvider: 'ghcp',
|
||||
authDisplayName: 'GitHub Copilot (OAuth)',
|
||||
@@ -126,15 +130,94 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Needs auth')).toBeInTheDocument();
|
||||
expect(screen.getByText('Auth')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Saved backend: GitHub Copilot \(OAuth\)\. Current runtime falls back/i)
|
||||
screen.getByText(/Transformer route: GitHub Copilot \(OAuth\) · claude-haiku-4.5\./i)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i).length
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('calls the toggle handler immediately for native image reading', () => {
|
||||
const onToggleNativeRead = vi.fn();
|
||||
|
||||
render(
|
||||
<ImageAnalysisStatusSection status={createStatus()} onToggleNativeRead={onToggleNativeRead} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Use native image reading/i }));
|
||||
|
||||
expect(onToggleNativeRead).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('writes the native image preference into the raw settings json', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/api/settings/glm/raw')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
profile: 'glm',
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'saved-token',
|
||||
},
|
||||
},
|
||||
mtime: 1,
|
||||
path: '/tmp/glm.settings.json',
|
||||
imageAnalysisStatus: createStatus(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/api/settings/glm/image-analysis-status')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
imageAnalysisStatus: createStatus({
|
||||
backendId: null,
|
||||
backendDisplayName: null,
|
||||
model: null,
|
||||
resolutionSource: 'native-compatible',
|
||||
supported: false,
|
||||
shouldPersistHook: false,
|
||||
runtimePath: null,
|
||||
authReadiness: 'not-needed',
|
||||
authProvider: null,
|
||||
authDisplayName: null,
|
||||
authReason: null,
|
||||
proxyReadiness: 'not-needed',
|
||||
proxyReason: null,
|
||||
effectiveRuntimeMode: 'native-read',
|
||||
nativeReadPreference: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
})
|
||||
);
|
||||
|
||||
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||
|
||||
await screen.findByText(/Transformer route: Google Gemini/i);
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Use native image reading/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText('raw config editor') as HTMLTextAreaElement).value).toContain(
|
||||
'"ccs_image"'
|
||||
);
|
||||
});
|
||||
expect((screen.getByLabelText('raw config editor') as HTMLTextAreaElement).value).toContain(
|
||||
'"native_read": true'
|
||||
);
|
||||
});
|
||||
|
||||
it('switches to live preview when editor JSON changes', async () => {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
@@ -179,7 +262,7 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
|
||||
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||
|
||||
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||
target: {
|
||||
@@ -197,10 +280,9 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Live Preview')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Live preview/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Preview from the current editor JSON/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to saved status messaging when the editor JSON is invalid', async () => {
|
||||
@@ -232,16 +314,14 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
|
||||
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||
|
||||
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||
target: { value: '{' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Showing saved status until the JSON above is valid again/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Saved status/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -303,7 +383,7 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
|
||||
render(<ProfileEditor profileName="glm" profileTarget="claude" />);
|
||||
|
||||
expect(await screen.findByText('Google Gemini')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Google Gemini/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||
target: {
|
||||
@@ -320,7 +400,7 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/GitHub Copilot \(OAuth\)/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('raw config editor'), {
|
||||
target: {
|
||||
@@ -338,9 +418,8 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Refreshing')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Refreshing preview/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Refreshing from the current editor state/i)).toBeInTheDocument();
|
||||
|
||||
secondPreviewResolver?.(
|
||||
createJsonResponse({
|
||||
@@ -356,7 +435,7 @@ describe('ImageAnalysisStatusSection', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Codex')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Codex/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ describe('ImageAnalysisSection', () => {
|
||||
enabled: true,
|
||||
timeout: 60,
|
||||
providerModels: {
|
||||
gemini: 'gemini-2.5-flash',
|
||||
gemini: 'gemini-3-flash-preview',
|
||||
ghcp: 'claude-haiku-4.5',
|
||||
},
|
||||
fallbackBackend: 'gemini',
|
||||
@@ -30,17 +30,18 @@ describe('ImageAnalysisSection', () => {
|
||||
state: 'partial',
|
||||
title: 'Partially ready',
|
||||
detail:
|
||||
'1 backend still needs auth, runtime, or review before every profile path is healthy.',
|
||||
'1 profile routes through Image on the current Claude target path. 1 prefer native image reading.',
|
||||
backendCount: 2,
|
||||
mappedProfileCount: 1,
|
||||
activeProfileCount: 1,
|
||||
bypassedProfileCount: 1,
|
||||
nativeProfileCount: 1,
|
||||
},
|
||||
backends: [
|
||||
{
|
||||
backendId: 'gemini',
|
||||
displayName: 'Google Gemini',
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
state: 'ready',
|
||||
authReadiness: 'ready',
|
||||
authReason: null,
|
||||
@@ -74,6 +75,10 @@ describe('ImageAnalysisSection', () => {
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||
effectiveRuntimeReason: null,
|
||||
currentTargetMode: 'active',
|
||||
profileModel: 'gemini-3-flash-preview',
|
||||
nativeReadPreference: false,
|
||||
nativeImageCapable: true,
|
||||
nativeImageReason: 'gemini-3-flash-preview can read images natively.',
|
||||
},
|
||||
{
|
||||
name: 'codexProfile',
|
||||
@@ -88,6 +93,10 @@ describe('ImageAnalysisSection', () => {
|
||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
||||
effectiveRuntimeReason: null,
|
||||
currentTargetMode: 'bypassed',
|
||||
profileModel: 'claude-haiku-4.5',
|
||||
nativeReadPreference: true,
|
||||
nativeImageCapable: true,
|
||||
nativeImageReason: 'claude-haiku-4.5 can read images natively.',
|
||||
},
|
||||
],
|
||||
catalog: {
|
||||
@@ -110,21 +119,32 @@ describe('ImageAnalysisSection', () => {
|
||||
|
||||
if (url === '/api/image-analysis' && method === 'PUT') {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||
enabled?: boolean;
|
||||
timeout?: number;
|
||||
fallbackBackend?: string;
|
||||
profileBackends?: Record<string, string>;
|
||||
providerModels?: Record<string, string | null>;
|
||||
};
|
||||
const providerModels = body.providerModels ?? {};
|
||||
|
||||
payload = {
|
||||
...payload,
|
||||
config: {
|
||||
enabled: true,
|
||||
enabled: body.enabled ?? payload.config.enabled,
|
||||
timeout: body.timeout ?? payload.config.timeout,
|
||||
fallbackBackend: body.fallbackBackend ?? payload.config.fallbackBackend,
|
||||
fallbackBackend:
|
||||
'fallbackBackend' in body
|
||||
? (body.fallbackBackend ?? null)
|
||||
: payload.config.fallbackBackend,
|
||||
providerModels: {
|
||||
gemini: String(body.providerModels?.gemini ?? payload.config.providerModels.gemini),
|
||||
ghcp: String(body.providerModels?.ghcp ?? payload.config.providerModels.ghcp),
|
||||
gemini:
|
||||
'gemini' in providerModels
|
||||
? (providerModels.gemini ?? '')
|
||||
: payload.config.providerModels.gemini,
|
||||
ghcp:
|
||||
'ghcp' in providerModels
|
||||
? (providerModels.ghcp ?? '')
|
||||
: payload.config.providerModels.ghcp,
|
||||
},
|
||||
profileBackends: body.profileBackends ?? payload.config.profileBackends,
|
||||
},
|
||||
@@ -146,18 +166,19 @@ describe('ImageAnalysisSection', () => {
|
||||
it('renders global controls and saves updated config', async () => {
|
||||
render(<ImageAnalysisSection />, { withSettingsProvider: true });
|
||||
|
||||
expect(await screen.findByText('Image Analysis')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Image')).toBeInTheDocument();
|
||||
expect(screen.getByText('Partially ready')).toBeInTheDocument();
|
||||
expect(screen.getByText('Provider models')).toBeInTheDocument();
|
||||
expect(screen.getByText('Profile mappings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Profile coverage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bypassed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Core setup')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Native reading').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Profile routing')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Coverage').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Bypassed').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
|
||||
|
||||
const timeoutInput = screen.getByDisplayValue('60');
|
||||
await userEvent.clear(timeoutInput);
|
||||
await userEvent.type(timeoutInput, '120');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
await userEvent.tab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
@@ -168,10 +189,12 @@ describe('ImageAnalysisSection', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const putCall = fetchMock.mock.calls.find(
|
||||
([url, init]) =>
|
||||
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
|
||||
);
|
||||
const putCall = fetchMock.mock.calls
|
||||
.filter(
|
||||
([url, init]) =>
|
||||
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
|
||||
)
|
||||
.at(-1);
|
||||
expect(putCall).toBeDefined();
|
||||
|
||||
const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}'));
|
||||
@@ -183,7 +206,97 @@ describe('ImageAnalysisSection', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Image Analysis settings saved.')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Image settings saved.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows saving a disabled configuration even when every provider model is cleared', async () => {
|
||||
render(<ImageAnalysisSection />, { withSettingsProvider: true });
|
||||
|
||||
await screen.findByDisplayValue('gemini-3-flash-preview');
|
||||
|
||||
await userEvent.click(screen.getByRole('switch'));
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/image-analysis',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
})
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('gemini-3-flash-preview')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fetchMock.mockClear();
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Clear' })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/image-analysis',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
})
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('button', { name: 'Clear' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
fetchMock.mockClear();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/image-analysis',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const putCall = fetchMock.mock.calls
|
||||
.filter(
|
||||
([url, init]) =>
|
||||
url === '/api/image-analysis' && (init as RequestInit | undefined)?.method === 'PUT'
|
||||
)
|
||||
.at(-1);
|
||||
expect(putCall).toBeDefined();
|
||||
|
||||
const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}'));
|
||||
expect(requestBody).toMatchObject({
|
||||
enabled: false,
|
||||
fallbackBackend: null,
|
||||
providerModels: {
|
||||
gemini: null,
|
||||
ghcp: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-saves edits without rendering a dedicated save button', async () => {
|
||||
render(<ImageAnalysisSection />, { withSettingsProvider: true });
|
||||
|
||||
expect(await screen.findByText('Image')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
|
||||
|
||||
const timeoutInput = screen.getByDisplayValue('60');
|
||||
await userEvent.clear(timeoutInput);
|
||||
await userEvent.type(timeoutInput, '90');
|
||||
await userEvent.tab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/image-analysis',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a clear retryable error when the backend route is not available yet', async () => {
|
||||
@@ -209,7 +322,7 @@ describe('ImageAnalysisSection', () => {
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Image Analysis settings returned an unexpected response\. Restart the dashboard server/i
|
||||
/Image settings returned an unexpected response\. Restart the dashboard server/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
|
||||
Reference in new issue
Block a user