mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 22:14:40 +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
@@ -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