From d03098cfac0e2cd8a018ce793399c6c20b1bdf86 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 04:59:12 +0900 Subject: [PATCH] feat(v031): vision IPC + preload (get-vision-models / set / refresh) Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/settingsApi.ts | 24 +++++++ src/preload/index.ts | 4 ++ src/shared/types.ts | 8 +++ tests/unit/vision-ipc.test.ts | 125 ++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 tests/unit/vision-ipc.test.ts diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 4d62971..8294a11 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -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 }); + }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 539f4cc..0d64c2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 6bcbbf2..3c6270a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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; 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 { diff --git a/tests/unit/vision-ipc.test.ts b/tests/unit/vision-ipc.test.ts new file mode 100644 index 0000000..5022e70 --- /dev/null +++ b/tests/unit/vision-ipc.test.ts @@ -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 }).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 = { + 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 }).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 }).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 }); + }); +});