- m1 (Minor): saveOllamaSettings IPC가 setOllama throw 시 try/catch
→ { ok: false, reason: 'persist failed: ...' } 대칭 응답
- m3 (Minor): Modal ESC=close + Enter=save 키 핸들러 + 첫 input autoFocus
- m4 (Minor): handleSave 첫 줄 if (saving) return; — sync double-click 가드
- n1 (Nit): 'gemma4:e4b' / 'http://localhost:11434' magic
→ src/shared/constants.ts 의 DEFAULT_OLLAMA_MODEL / DEFAULT_OLLAMA_ENDPOINT
defer to v0.2.4 backlog:
- m2: ollama_unreachable.reason 에 endpoint URL 노출 (PII 우회) — telemetry masking 정책
skip:
- i1 (race UX): acknowledge only, 정확성 영향 0
- m5 (abort try/catch): 현재 LocalOllamaProvider.abort 는 throw X
- m6 (first-boot blocking): 무시 가능
- n2 (offReplace): 현재 listener callsite 0건
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
185 lines
7.0 KiB
TypeScript
185 lines
7.0 KiB
TypeScript
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);
|
|
}
|