import electron from 'electron'; import type { BrowserWindow } from 'electron'; const { ipcMain, dialog } = electron; import type { NoteRepository } from '../repository/NoteRepository.js'; import type { ContinuityService } from '../services/ContinuityService.js'; import type { CaptureService } from '../services/CaptureService.js'; import type { HealthChecker } from '../services/HealthChecker.js'; import type { IntentService } from '../services/IntentService.js'; import type { Note } from '@shared/types'; import type { HealthResult } from '../ai/InferenceProvider.js'; import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js'; import type { SettingsService } from '../services/SettingsService.js'; import type { ProviderHolder } from '../ai/ProviderHolder.js'; export interface InboxIpcDeps { repo: NoteRepository; continuity: ContinuityService; capture: CaptureService; health: HealthChecker; intent: IntentService; getInboxWindow: () => BrowserWindow | null; settings: SettingsService; providerHolder: ProviderHolder; } export function registerInboxApi(deps: InboxIpcDeps): void { ipcMain.handle('inbox:list', (_e, opts: { limit: number; cursor?: string }) => deps.repo.list(opts) ); ipcMain.handle( 'inbox:updateAi', (_e, arg: { noteId: string; fields: { title?: string; summary?: string; tags?: string[] } }) => { deps.repo.updateUserAiFields(arg.noteId, arg.fields); } ); ipcMain.handle('inbox:setDueDate', (_e, arg: { noteId: string; date: string | null }) => { deps.repo.setDueDate(arg.noteId, arg.date); }); ipcMain.handle('inbox:delete', async (_e, noteId: string) => { await deps.capture.deleteNote(noteId); }); ipcMain.handle( 'inbox:setIntent', (_e, arg: { noteId: string; text: string }) => { deps.intent.setIntent(arg.noteId, arg.text); } ); ipcMain.handle('inbox:dismissIntent', (_e, noteId: string) => { deps.intent.dismissIntent(noteId); }); ipcMain.handle('inbox:continuity', () => deps.continuity.get()); ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount()); ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus()); ipcMain.handle('inbox:todayCount', () => deps.repo.countToday()); ipcMain.handle('inbox:restore', async (_e, noteId: string) => { await deps.capture.restoreNote(noteId); }); ipcMain.handle('inbox:permanentDelete', async (_e, noteId: string) => { const win = deps.getInboxWindow(); const opts: Electron.MessageBoxOptions = { type: 'question', buttons: ['영구 삭제', '취소'], defaultId: 1, cancelId: 1, title: 'Inkling', message: '이 노트를 영구 삭제합니다', detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.' }; const r = win ? await dialog.showMessageBox(win, opts) : await dialog.showMessageBox(opts); if (r.response !== 0) return { confirmed: false }; await deps.capture.permanentDeleteNote(noteId); return { confirmed: true }; }); ipcMain.handle('inbox:emptyTrash', async () => { const fullCount = deps.repo.countTrashed(); if (fullCount === 0) return { confirmed: true, count: 0 }; const win = deps.getInboxWindow(); const opts: Electron.MessageBoxOptions = { type: 'question', buttons: ['휴지통 비우기', '취소'], defaultId: 1, cancelId: 1, title: 'Inkling', message: `휴지통의 노트 ${fullCount}개를 영구 삭제합니다`, detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.' }; const r = win ? await dialog.showMessageBox(win, opts) : await dialog.showMessageBox(opts); if (r.response !== 0) return { confirmed: false, count: 0 }; const result = await deps.capture.emptyTrash(); return { confirmed: true, count: result.count }; }); ipcMain.handle('inbox:listTrash', (_e, opts: { limit: number }) => deps.repo.listTrashed(opts) ); ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed()); ipcMain.handle('inbox:listExpired', async () => deps.capture.listExpired()); ipcMain.handle( 'inbox:trashExpiredBatch', async (_e, payload: { ids: string[] }) => { if (payload.ids.length === 0) return { trashedCount: 0, confirmed: false }; const win = deps.getInboxWindow(); const opts: Electron.MessageBoxOptions = { type: 'question', buttons: ['옮기기', '취소'], defaultId: 1, cancelId: 1, title: 'Inkling', message: `선택한 노트 ${payload.ids.length}개를 휴지통으로 옮깁니다`, detail: '복구는 휴지통 탭에서 가능합니다.' }; const r = win ? await dialog.showMessageBox(win, opts) : await dialog.showMessageBox(opts); if (r.response !== 0) return { trashedCount: 0, confirmed: false }; const result = await deps.capture.trashExpiredBatch(payload.ids); return { trashedCount: result.trashedCount, confirmed: true }; } ); ipcMain.handle('inbox:ollamaRecheck', async () => { await deps.health.runOnce({ manual: true }); return deps.health.lastStatus(); }); ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed()); ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed()); ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate()); ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id)); ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id)); ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id)); ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id)); ipcMain.handle('inbox:loadOllamaSettings', async () => { const s = await deps.settings.load(); return s.ollama ?? null; }); ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { // 검증: 새 인스턴스로 healthCheck const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); const r = await trial.healthCheck(); if (!r.ok) return { ok: false, reason: r.reason ?? 'unknown' }; try { await deps.settings.setOllama(value); } catch (e) { return { ok: false, reason: `persist failed: ${(e as Error).message}` }; } deps.providerHolder.get().abort?.(); deps.providerHolder.replace(trial); // 즉시 health 재확인 → onUpdate callback 통해 OllamaBanner 자동 갱신 await deps.health.runOnce(); return { ok: true }; }); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { const w = getWin(); if (!w || w.isDestroyed()) return; w.webContents.send('note:updated', note); } export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void { const w = getWin(); if (!w || w.isDestroyed()) return; w.webContents.send('ollama:status', status); }