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:
Tam Nhu Tran committed 2026-04-01 15:32:37 -04:00
1 parent 9d1d281e34
commit 982ffc5895
30 files changed
+1893 -708

No files matched your search

+9 -6
View File
@@ -33,7 +33,7 @@ const CLIPROXY_API_KEY = 'test-api-key-12345';
// Default provider models for testing (matches DEFAULT_IMAGE_ANALYSIS_CONFIG)
const DEFAULT_PROVIDER_MODELS =
'agy:gemini-2.5-flash,gemini:gemini-2.5-flash,codex:gpt-5.1-codex-mini,kiro:kiro-claude-haiku-4-5,ghcp:claude-haiku-4.5,claude:claude-haiku-4-5-20251001';
'agy:gemini-3-1-flash-preview,gemini:gemini-3-flash-preview,codex:gpt-5.1-codex-mini,kiro:kiro-claude-haiku-4-5,ghcp:claude-haiku-4.5,claude:claude-haiku-4-5-20251001';
const DEFAULT_PROVIDER = 'agy'; // Default test provider
// ============================================================================
@@ -626,7 +626,7 @@ describe('Image Analyzer Hook', () => {
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_PROFILE_TYPE: 'cliproxy',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview',
}
);
@@ -647,7 +647,7 @@ describe('Image Analyzer Hook', () => {
}>;
}>;
};
expect(body.model).toBe('gemini-2.5-flash');
expect(body.model).toBe('gemini-3-1-flash-preview');
expect(body.max_tokens).toBe(4096);
expect(body.messages).toHaveLength(1);
expect(body.messages[0].role).toBe('user');
@@ -738,7 +738,8 @@ describe('Image Analyzer Hook', () => {
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_PROFILE_TYPE: 'cliproxy',
CCS_CURRENT_PROVIDER: 'codex',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS:
'codex:gpt-5.1-codex-mini,agy:gemini-3-1-flash-preview',
}
);
@@ -756,7 +757,7 @@ describe('Image Analyzer Hook', () => {
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_PROFILE_TYPE: 'cliproxy',
CCS_CURRENT_PROVIDER: 'unknown-provider',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview',
}
);
@@ -820,7 +821,9 @@ describe('Image Analyzer Hook', () => {
);
const output = JSON.parse(result.stdout);
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('gemini-2.5-flash');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain(
'gemini-3-1-flash-preview'
);
});
it('should output valid JSON structure on file read error', () => {
@@ -58,7 +58,8 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_PROFILE_TYPE: 'cliproxy',
CCS_CURRENT_PROVIDER: 'codex',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS:
'codex:gpt-5.1-codex-mini,agy:gemini-3-1-flash-preview',
...env,
},
stdio: ['pipe', 'pipe', 'pipe'],
@@ -195,7 +196,7 @@ describe('image analyzer hook regression coverage', () => {
it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => {
const result = await invokeHook({
CCS_CURRENT_PROVIDER: 'unknown-provider',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview',
});
expect(result.code).toBe(0);
+20 -4
View File
@@ -100,9 +100,17 @@ describe('Model Catalog', () => {
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
});
it('has 3 models total', () => {
it('includes Gemini Flash via Antigravity', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.models.length, 3);
const flash = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-1-flash-preview');
assert(flash, 'Should include Gemini Flash');
assert.strictEqual(flash.name, 'Gemini Flash');
assert.strictEqual(flash.tier, undefined, 'AGY models should not have paid tier');
});
it('has 4 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.models.length, 4);
});
});
@@ -155,9 +163,17 @@ describe('Model Catalog', () => {
assert.strictEqual(gem25.tier, undefined);
});
it('has 2 models total', () => {
it('includes Gemini Flash with pro tier', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.gemini.models.length, 2);
const flash = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-flash-preview');
assert(flash, 'Should include Gemini Flash');
assert.strictEqual(flash.name, 'Gemini Flash');
assert.strictEqual(flash.tier, 'pro');
});
it('has 3 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.gemini.models.length, 3);
});
});
@@ -49,13 +49,13 @@ image_analysis:
enabled: true
timeout: 60
provider_models:
agy: gemini-2.5-flash
agy: gemini-3-1-flash-preview
`);
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
expect(content).toContain('enabled: true');
expect(content).toContain('timeout: 60');
expect(content).toContain('agy: gemini-2.5-flash');
expect(content).toContain('agy: gemini-3-1-flash-preview');
});
it('should parse disabled status from config.yaml', () => {
@@ -79,14 +79,14 @@ image_analysis:
enabled: true
timeout: 60
provider_models:
agy: gemini-2.5-flash
agy: gemini-3-1-flash-preview
gemini: gemini-2.5-pro
codex: gpt-5.1-codex-mini
kiro: kiro-claude-haiku-4-5
`);
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
expect(content).toContain('agy: gemini-2.5-flash');
expect(content).toContain('agy: gemini-3-1-flash-preview');
expect(content).toContain('gemini: gemini-2.5-pro');
expect(content).toContain('codex: gpt-5.1-codex-mini');
expect(content).toContain('kiro: kiro-claude-haiku-4-5');
@@ -218,8 +218,8 @@ image_analysis:
enabled: true,
timeout: 60,
provider_models: {
agy: 'gemini-2.5-flash',
gemini: 'gemini-2.5-flash',
agy: 'gemini-3-1-flash-preview',
gemini: 'gemini-3-flash-preview',
codex: 'gpt-5.1-codex-mini',
kiro: 'kiro-claude-haiku-4-5',
ghcp: 'claude-haiku-4.5',
@@ -244,7 +244,7 @@ image_analysis:
enabled: true
timeout: 60
provider_models:
agy: gemini-2.5-flash
agy: gemini-3-1-flash-preview
`);
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
@@ -62,7 +62,7 @@ describe('image-analysis-backend-resolver', () => {
expect(status.supported).toBe(true);
expect(status.backendId).toBe('gemini');
expect(status.resolutionSource).toBe('fallback-backend');
expect(status.model).toBe('gemini-2.5-flash');
expect(status.model).toBe('gemini-3-flash-preview');
});
it('keeps direct Anthropic settings profiles on native read unless explicitly mapped', () => {
@@ -108,6 +108,29 @@ describe('image-analysis-backend-resolver', () => {
expect(status.resolutionSource).toBe('profile-backend');
});
it('lets explicit profile_backends overrides win over cliproxy provider inference', () => {
const config: ImageAnalysisConfig = {
...DEFAULT_IMAGE_ANALYSIS_CONFIG,
profile_backends: {
glmv: 'ghcp',
},
};
const status = resolveImageAnalysisStatus(
{
profileName: 'glmv',
profileType: 'cliproxy',
cliproxyProvider: 'gemini',
},
config
);
expect(status.supported).toBe(true);
expect(status.status).toBe('mapped');
expect(status.backendId).toBe('ghcp');
expect(status.resolutionSource).toBe('profile-backend');
});
it('reports hook-missing when the profile should persist a hook but it is absent', () => {
const status = resolveImageAnalysisStatus(
{
@@ -128,4 +151,32 @@ describe('image-analysis-backend-resolver', () => {
expect(status.status).toBe('hook-missing');
expect(status.reason).toContain('Profile hook is missing');
});
it('prefers native image reading when the profile settings opt into it', () => {
const status = resolveImageAnalysisStatus(
{
profileName: 'glmv',
profileType: 'settings',
settings: {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_MODEL: 'glm-4.5v',
ANTHROPIC_AUTH_TOKEN: 'glm-test-key',
},
ccs_image: {
native_read: true,
},
},
},
DEFAULT_IMAGE_ANALYSIS_CONFIG
);
expect(status.backendId).toBeNull();
expect(status.resolutionSource).toBe('native-compatible');
expect(status.nativeReadPreference).toBe(true);
expect(status.profileModel).toBe('glm-4.5v');
expect(status.nativeImageCapable).toBe(true);
expect(status.shouldPersistHook).toBe(false);
expect(status.effectiveRuntimeMode).toBe('native-read');
});
});
@@ -27,6 +27,10 @@ function createStatus(overrides: Partial<ImageAnalysisStatus> = {}): ImageAnalys
proxyReason: 'CLIProxy runtime readiness has not been verified yet.',
effectiveRuntimeMode: 'native-read',
effectiveRuntimeReason: null,
profileModel: 'claude-haiku-4.5',
nativeReadPreference: false,
nativeImageCapable: true,
nativeImageReason: 'claude-haiku-4.5 can read images natively.',
...overrides,
};
}
@@ -101,7 +101,7 @@ describe('image-analysis routes', () => {
enabled: true,
timeout: 60,
provider_models: {
gemini: 'gemini-2.5-flash',
gemini: 'gemini-3-flash-preview',
ghcp: 'claude-haiku-4.5',
},
fallback_backend: 'gemini',
@@ -158,7 +158,7 @@ describe('image-analysis routes', () => {
expect.arrayContaining([
expect.objectContaining({
backendId: 'gemini',
model: 'gemini-2.5-flash',
model: 'gemini-3-flash-preview',
}),
expect.objectContaining({
backendId: 'ghcp',
@@ -227,7 +227,7 @@ describe('image-analysis routes', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerModels: {
gemini: 'gemini-2.5-flash',
gemini: 'gemini-3-flash-preview',
},
fallbackBackend: 'gemini',
profileBackends: {
@@ -111,7 +111,7 @@ describe('settings-routes image-analysis status', () => {
expect(body.imageAnalysisStatus.status).toBe('active');
expect(body.imageAnalysisStatus.backendId).toBe('gemini');
expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend');
expect(body.imageAnalysisStatus.model).toBe('gemini-2.5-flash');
expect(body.imageAnalysisStatus.model).toBe('gemini-3-flash-preview');
expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json');
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read');
@@ -255,4 +255,38 @@ describe('settings-routes image-analysis status', () => {
expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge');
expect(body.imageAnalysisStatus.authReadiness).toBe('missing');
});
it('respects per-profile native image preference stored in settings json', async () => {
writeJson(path.join(tempHome, '.ccs', 'glmv.settings.json'), {
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_MODEL: 'glm-4.5v',
ANTHROPIC_AUTH_TOKEN: 'glmv-test-key',
},
ccs_image: {
native_read: true,
},
});
const response = await fetch(`${baseUrl}/api/settings/glmv/raw`);
expect(response.status).toBe(200);
const body = (await response.json()) as {
imageAnalysisStatus: {
backendId: string | null;
resolutionSource: string;
profileModel: string | null;
nativeReadPreference: boolean;
nativeImageCapable: boolean | null;
effectiveRuntimeMode: string;
};
};
expect(body.imageAnalysisStatus.backendId).toBeNull();
expect(body.imageAnalysisStatus.resolutionSource).toBe('native-compatible');
expect(body.imageAnalysisStatus.profileModel).toBe('glm-4.5v');
expect(body.imageAnalysisStatus.nativeReadPreference).toBe(true);
expect(body.imageAnalysisStatus.nativeImageCapable).toBe(true);
expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read');
});
});