feat(v031): vision IPC + preload (get-vision-models / set / refresh)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-10 04:59:12 +09:00
parent 2179cfbf39
commit d03098cfac
4 changed files with 161 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import type { SettingsService } from '../services/SettingsService.js';
import type { SyncTimer } from '../services/SyncTimer.js';
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js';
import { refreshVisionCache } from '../services/VisionDetect.js';
/**
* 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을
@@ -378,4 +379,27 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
}
return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt };
});
// v0.3.1 Cut F — vision IPC
ipcMain.handle('settings:get-vision-models', async () => {
const cache = await deps.settings.getVisionCapableCache();
const selected = await deps.settings.getVisionModel();
return { models: cache.models, at: cache.at, selected };
});
ipcMain.handle('settings:set-vision-model', async (_e, value: string | null) => {
const sanitized = typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
await deps.settings.setVisionModel(sanitized);
return { ok: true as const };
});
ipcMain.handle('settings:refresh-vision-cache', async () => {
const all = await deps.settings.getAll();
const endpoint = all.ollama?.endpoint;
if (!endpoint) {
return { ok: false as const, reason: 'no_endpoint' };
}
return refreshVisionCache({ settings: deps.settings, endpoint });
});
}

View File

@@ -97,6 +97,10 @@ const api: InklingApi = {
getSyncStatus: () => ipcRenderer.invoke('sync:get-status'),
setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value),
setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value),
// v0.3.1 Cut F — vision capability + 모델 선택
getVisionModels: () => ipcRenderer.invoke('settings:get-vision-models'),
setVisionModel: (value: string | null) => ipcRenderer.invoke('settings:set-vision-model', value),
refreshVisionCache: () => ipcRenderer.invoke('settings:refresh-vision-cache'),
}
};

View File

@@ -197,6 +197,10 @@ export interface InboxApi {
sync_repo_url?: string | null;
sync_auto_enabled?: boolean;
sync_interval_min?: number;
// v0.3.1 Cut F
vision_model?: string | null;
vision_capable_cache?: string[];
vision_cache_at?: string;
}>;
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
@@ -218,6 +222,10 @@ export interface InboxApi {
getSyncStatus(): Promise<SyncStatusSnapshot>;
setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>;
setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>;
// v0.3.1 Cut F — vision capability detection + 모델 선택.
getVisionModels(): Promise<{ models: string[]; at: string | null; selected: string | null }>;
setVisionModel(value: string | null): Promise<{ ok: true }>;
refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>;
}
export interface InklingApi {

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } }));
vi.mock('../../src/main/services/VisionDetect.js', () => ({
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: ['gemma3:12b-vision'] }))
}));
vi.mock('../../src/main/services/GitClient.js');
import electron from 'electron';
import { refreshVisionCache } from '../../src/main/services/VisionDetect.js';
import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js';
import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.js';
function getHandler(channel: string): (...args: unknown[]) => unknown {
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
const call = handle.mock.calls.find((c) => c[0] === channel);
if (!call) throw new Error(`channel ${channel} not registered`);
return call[1] as (...args: unknown[]) => unknown;
}
function makeDeps() {
const settings = {
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
setVisionModel: vi.fn(async () => {}),
getVisionCapableCache: vi.fn(async () => ({
models: ['gemma3:12b-vision', 'llava:13b'],
at: '2026-05-10T05:00:00Z'
})),
setVisionCapableCache: vi.fn(async () => {}),
// existing methods used by other handlers
getAll: vi.fn(async () => ({
ollama: { endpoint: 'http://localhost:11434', model: 'gemma2:2b' }
})),
setAiEnabled: vi.fn(async () => {}),
setOnboardingCompleted: vi.fn(async () => {}),
isAiEnabled: vi.fn(async () => true),
getSyncRepoUrl: vi.fn(async () => null),
setSyncRepoUrl: vi.fn(async () => {}),
isAutoSyncEnabled: vi.fn(async () => false),
getSyncIntervalMin: vi.fn(async () => 30),
setSyncIntervalMin: vi.fn(async () => {}),
setAutoSyncEnabled: vi.fn(async () => {})
};
const syncSvc = {
getSyncDir: vi.fn(() => '/tmp/sync'),
listConflicts: vi.fn(() => []),
resolveConflict: vi.fn(async () => ({ ok: true as const })),
getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null }))
};
const deps: Partial<SettingsIpcDeps> = {
backup: { runDaily: vi.fn(async () => ({ snapshotted: false })) } as never,
exportSvc: {} as never,
importSvc: {} as never,
syncSvc: syncSvc as never,
telemetry: { exportTo: vi.fn(async () => ({ eventCount: 0 })) } as never,
settings: settings as never,
getInboxWindow: () => null
};
return { settings, syncSvc, deps };
}
describe('vision IPC channels', () => {
beforeEach(() => {
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
vi.clearAllMocks();
});
it('3 vision channels registered', () => {
const { deps } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
const channels = handle.mock.calls.map((c) => c[0]);
expect(channels).toContain('settings:get-vision-models');
expect(channels).toContain('settings:set-vision-model');
expect(channels).toContain('settings:refresh-vision-cache');
});
it('settings:get-vision-models returns { models, at, selected } from settings', async () => {
const { deps, settings } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:get-vision-models');
const r = await h({});
expect(settings.getVisionCapableCache).toHaveBeenCalled();
expect(settings.getVisionModel).toHaveBeenCalled();
expect(r).toEqual({
models: ['gemma3:12b-vision', 'llava:13b'],
at: '2026-05-10T05:00:00Z',
selected: 'gemma3:12b-vision'
});
});
it('settings:set-vision-model calls settings.setVisionModel(value) + returns { ok: true }', async () => {
const { deps, settings } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:set-vision-model');
const r = await h({}, 'llava:13b');
expect(settings.setVisionModel).toHaveBeenCalledWith('llava:13b');
expect(r).toEqual({ ok: true });
});
it('settings:refresh-vision-cache calls refreshVisionCache and returns result', async () => {
const { deps } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:refresh-vision-cache');
const r = await h({});
expect(refreshVisionCache).toHaveBeenCalledWith({
settings: deps.settings,
endpoint: 'http://localhost:11434'
});
expect(r).toEqual({ ok: true, models: ['gemma3:12b-vision'] });
});
it('settings:set-vision-model with null clears the value', async () => {
const { deps, settings } = makeDeps();
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:set-vision-model');
const r = await h({}, null);
expect(settings.setVisionModel).toHaveBeenCalledWith(null);
expect(r).toEqual({ ok: true });
});
});