28 Commits

Author SHA1 Message Date
altair823
d5143ab1ad chore(release): v0.3.3 — sync configure-sync hotfix
v0.3.0 Cut E (양방향 sync) dogfood 첫 시도 중 발견된 git init ENOENT
hotfix 1건. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:04:59 +09:00
altair823
2221113329 fix(v033): sync configure-sync — git init 전 syncDir mkdir(recursive)
settings:configure-sync IPC 핸들러가 `git -C <syncDir> init` 호출 전에
syncDir 디렉토리를 생성하지 않아, sync 첫 설정 시 git 이 chdir 단계에서
`fatal: cannot change to '<profileDir>/sync': No such file or directory` 로
실패하던 문제. SyncService.runSync() 의 동일 패턴 (mkdir recursive) 을
핸들러에도 추가.

연쇄 증상: SyncSection 의 "연결 테스트" 버튼 disabled 조건이 저장된 url
state 기반이라, 저장 실패로 url 영영 비어 있어 버튼 활성화 불가 (닭/달걀).
mkdir fix 로 자동 해소.

회귀: sync-ipc.test.ts 에 mkdir 호출 순서 검증 1건 추가 (18 pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:04:46 +09:00
f37e17dd81 Merge pull request 'v0.3.2 — cleanup cut (잠재 bug 4 + cosmetic 5 + #20 deferred)' (#32) from worktree-v032-cleanup into main
Reviewed-on: #32
2026-05-10 07:15:23 +00:00
altair823
41310dbe6a refactor(v032): KST_OFFSET_MS inline → @shared/util/kstDate import (#19)
5 callsite (NoteRepository, ftsHelpers, BackupService, ContinuityService,
NoteCard) 모두 canonical export 로 정리. 알고리즘 동일 (9 * 60 * 60 * 1000),
회귀 PASS 검증.

v0.2.6 commit 3cfa60b 가 4 callsite migrate 했지만 5 callsite 잔여.
Cut F audit 에서 발견.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:38:58 +09:00
altair823
bb909e44ff chore(release): v0.3.2 — cleanup cut (잠재 bug + cosmetic 9 + #20 deferred)
backlog 잔여 23 → 14 (-9 처리, +1 deferred 잔존, +1 stale):
- 잠재 bug 4: vocabSet COLLATE / time-dep test flake / PII reason / KST inline
- cosmetic 5: 탭 ARIA / loadExpired 제거 / per-tag Promise.all / recall IPC on
  / OllamaSettingsModal 폐기 audit
- deferred: #20 (.catch debug log — CaptureService logger 미주입)

기록 정리: v0.2.2 stale memory 폐기 + v024-backlog 처리 이력 갱신

단위 710 → 724, typecheck 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:32:03 +09:00
altair823
83cefccbdd fix(v032): AiWorker Promise.all closure type narrowing 회복
Task 6 의 Promise.all 도입 시 async callback closure 가 this.telemetry?
narrowing 잃어 TS2532 발생. const telemetry = this.telemetry 로 narrowed
reference capture 후 closure 안에서 사용.
2026-05-10 14:25:24 +09:00
altair823
4db7a0bce0 refactor(v032): recall IPC handle→on + fix sibling test mocks (#36)
- inbox:emitRecallShown / emitRecallSnoozed: ipcMain.handle → on
  (fire-and-forget honest pattern, return value 의존자 0)
- preload: ipcRenderer.invoke → send (matching on the main side)
- shared/types: Promise<void> → void on both recall emit methods
- store.ts: drop await on emitRecallSnoozed (now void)
- inboxApi-*.test.ts: add ipcMain.on to electron mock (broken by above)
- tests/unit/recall-ipc.test.ts: new TDD test for handle→on migration

Note: #20 CaptureService telemetry .catch debug log skipped —
CaptureService has no logger field; adding one would require non-trivial
constructor signature change. Reported as CONCERN below.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:23:19 +09:00
altair823
aa7eb9d99f perf(v032): AiWorker per-tag emit Promise.all 병렬화 (#32)
기존 serial for-await: 3 태그 → 3 round-trip file-append.
Promise.all: 동일 결과, file-append 동시 실행 (telemetry 파일은
append-only, 순서 의존 단위 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:16:58 +09:00
altair823
9073e78169 refactor(v032): 탭 ARIA canonical + loadExpired dead-code 제거 (#14, #18)
- App.tsx 탭 button 의 aria-pressed → role="tab" + aria-selected
  (canonical pattern, a11y audit 정정)
- store.ts loadExpired action + test 제거 (App.tsx 호출 0건,
  loadInitial/refreshMeta 가 inline fetch — dead code)
2026-05-10 14:12:37 +09:00
altair823
302bbd4ce0 fix(v032): healthCheck reason PII 마스킹 (#39)
err.message 안에 LAN endpoint URL (예: 192.168.x.x:11434) 이 포함될 수
있어 telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN
사용을 흔하게 만들어 노출 경로 확대.

classifyFetchError 로 error class 분류 (network/timeout/dns/other) 후
reason: 'unreachable:{class}' 형태만 emit. host/IP 노출 0.
2026-05-10 14:00:33 +09:00
altair823
6985db3505 fix(v032): AiWorker vocabSet COLLATE NOCASE 정합 (#31)
DB tags.name 가 COLLATE NOCASE 인데 vocabSet 은 strict-eq 였음 →
대문자/소문자 vocab 과 AI tag 가 다를 때 silently skip.

vocab.toLowerCase() + tagName.toLowerCase() 양쪽 normalize 로 정합.
2026-05-10 13:55:52 +09:00
altair823
36eafa1ce9 fix(v032): NoteRepository.create now param + time-dep test flake fix
- create(input, now?: Date) signature 추가 (기존 setStatus/updateRawText 패턴 정합)
- NoteRevisions.test.ts 4 testcase v1 capture 시간 명시 주입 (2026-05-09T00:00:00Z)
- upsertFromSync.test.ts 2 testcase v1 capture 시간 명시 주입
- 시스템 시계가 2026-05-10T00:00:00Z 초과 시 DESC ordering 깨지던 회귀 회복

backlog: time-dependent flake (Cut F audit 발견)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:45:37 +09:00
altair823
4deb7775f3 docs(plan): v0.3.2 cleanup cut implementation plan (8 tasks)
Spec: 2026-05-10-v032-cleanup-design.md
Tasks: time-dep test fix / vocabSet COLLATE / PII reason / KST migration /
       탭 ARIA + loadExpired / Promise.all / recall IPC + .catch / 기록 정리

목표 단위 710 → 약 720 (+10 신규, -2 제거), typecheck 0
2026-05-10 13:36:05 +09:00
altair823
d0d9461d75 docs(spec): v0.3.2 cleanup cut design — 잠재 bug 4 + cosmetic 6 + 기록 정리 2
backlog 잔여 23건 audit 결과:
- 잠재 bug 4건: vocabSet COLLATE / time-dep test flake / PII reason / KST inline 5 callsite
- cosmetic 6건: 탭 ARIA / loadExpired 제거 / per-tag Promise.all / recall IPC on / OllamaSettingsModal 폐기 audit / .catch debug log
- 기록 정리 2건: v0.2.2 stale memory 폐기 / v024-backlog 갱신
- 보류: data-dependent 9 + future-proof 2 (dogfood 후 재평가)

목표 단위 710 → 약 720 (+10), typecheck 0
2026-05-10 13:20:54 +09:00
0d2896e0cc Merge pull request 'v0.3.1 Cut F — 멀티모달 vision AI (F24)' (#31) from worktree-v031-cut-f-vision into main
Reviewed-on: #31
2026-05-10 03:10:35 +00:00
altair823
2b3c3d727e feat(v031): vision capability hints 에 gemma4 추가 (사용자 요청)
본인 dogfood 환경 = gemma4:e4b (텍스트). vision 변종은 현재 gemma3 (vision-capable)
또는 향후 gemma4 출시 시. 양 family 모두 hint 에 포함 — capability detection 이
future-proof.

- VisionDetect.VISION_FAMILIES + VISION_NAME_HINTS 에 'gemma4' 추가
- isVisionCapable test 2건 추가 (gemma4 family / gemma4 name hint detection)
- spec §1 + §2 의 'gemma3 family default' → 'gemma family — gemma3 / gemma4'

영향: 기존 detection 정확도 무영향 (set 추가만), 사용자가 gemma4 vision 변종을
설치하면 자동 인식.
2026-05-10 11:12:13 +09:00
altair823
81fae12a8c fix(v031): endpoint resolution + 5MB fast-fail (final review fix)
final code review (Opus) 발견 minor issues 중 valuable 2건:

1. settings:refresh-vision-cache 가 settings.ollama.endpoint 만 체크 — env / default
   fallback 누락. dev 환경 (env var only) 사용자가 manual 다시 감지 시 'no_endpoint'
   silent fail. → index.ts 의 resolvedEndpoint 와 동일 fallback 체인 (settings → env →
   DEFAULT_OLLAMA_ENDPOINT).

2. AiWorker 의 5MB cap 이 readFile + base64 변환 후 throw — retry 마다 동일 비용 반복.
   note.media[].bytes 가 DB 에 이미 있으니 readFile 전 fast-fail. 비용 절감 + 동일 회로
   (markAiFailed 도달).

회귀 test 영향 없음 (기존 5MB throw 시나리오 그대로 — fast-fail 도 throw 분기 동일).
2026-05-10 05:07:55 +09:00
altair823
7b536409a8 chore(release): v0.3.1 — Cut F (멀티모달 vision AI)
- F24 promoted ( v0.3.1 Cut F — Ollama vision 모델 capability detection + AiWorker integration)
- version 0.3.0 → 0.3.1 (semver patch — 새 기능, 기존 영향 X)
- 단위 679 → 710 (+31): VisionDetect 9 + SettingsService 4 + visionPrompt 2 + LocalOllamaProvider vision 3 + AiWorker vision 3 + IPC 5 + UI 4 + ImportService helper fix 5 (Cut E gap)
- typecheck 0 errors
- 자동 fallback (caption→text) + 'skipped' enum deferred v0.3.2+
2026-05-10 05:02:10 +09:00
altair823
7468217460 feat(v031): main — refreshVisionCache whenReady fire-and-forget 2026-05-10 05:00:15 +09:00
altair823
72e9b68923 feat(v031): VisionSection UI — dropdown + 다시 감지 + 마지막 감지 시각
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:59:19 +09:00
altair823
d03098cfac feat(v031): vision IPC + preload (get-vision-models / set / refresh)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:59:12 +09:00
altair823
2179cfbf39 feat(v031): AiWorker vision integration — note.media + visionModel + 5MB cap 2026-05-10 04:53:21 +09:00
altair823
5012b40c14 feat(v031): LocalOllamaProvider vision path (visionModel + images → body.images base64) 2026-05-10 04:53:10 +09:00
altair823
369d418c7e fix(v031): ImportService.test buildExportNote helper 에 Cut E frontmatter 5 필드 추가
Cut E v0.3.0 에서 ExportNote interface 에 status / statusChangedAt / moveReason /
dueDate / dueDateEditedByUser 필드 추가했지만 ImportService.test 의 buildExportNote
helper 갱신 누락 → composeFrontmatter 가 undefined moveReason 로 formatScalar 호출
시 null !== undefined 분기 통과 후 .includes throw.

helper 에 5 필드 default (active / null / null / null / false) 추가. 회귀 fix.
2026-05-10 04:45:43 +09:00
altair823
e2e8b9b921 feat(v031): buildVisionPrompt + GenerateInput.images + GenerateOptions.visionModel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:43:03 +09:00
altair823
3eb0ef1316 feat(v031): VisionDetect — isVisionCapable + refreshVisionCache (fetch 주입)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:42:57 +09:00
altair823
463be7cf26 feat(v031): SettingsService.{getVisionModel,setVisionModel,getVisionCapableCache,setVisionCapableCache}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 04:42:52 +09:00
altair823
7a56184ad2 docs(plan): v0.3.1 Cut F — 멀티모달 vision AI (spec 정정: 단위 679, SettingsService 개별 메서드, 'skipped' enum 미도입, fallback 미구현) 2026-05-10 04:38:45 +09:00
51 changed files with 3507 additions and 160 deletions

View File

@@ -3,6 +3,24 @@
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
## [0.3.3] — 2026-05-10
v0.3.0 Cut E (양방향 sync) dogfood 첫 시도 중 발견된 sync 설정 ENOENT 버그 hotfix.
### 수정
- **Sync 설정 첫 저장 실패 (git init ENOENT)**: 설정 → 동기화 저장소에서 URL 입력 후 "저장" 클릭 시 `git init failed: fatal: cannot change to '<profileDir>/sync': No such file or directory` 로 실패하던 문제. `settings:configure-sync` IPC 핸들러가 `git -C <syncDir> init` 호출 전에 syncDir 디렉토리를 생성하지 않아 git 이 chdir 단계에서 죽음. `SyncService.runSync()` 의 동일 패턴 (`mkdir(syncDir, { recursive: true })`) 을 핸들러에도 추가. 결과적으로 "연결 테스트" 버튼이 영영 활성화되지 않던 연쇄 증상 (저장 성공 시에만 url state 채워지고 버튼 enable) 도 자동 해소.
### 게이트
- 단위 테스트: `tests/unit/sync-ipc.test.ts` 18 (mkdir 호출 순서 회귀 1 추가)
- typecheck: 0 errors
- 신규 npm dependency: 0
### 업그레이드
v0.3.2 인스톨러 위에 v0.3.3 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
## [0.2.2] — 2026-04-26
v0.2.1 dogfood 중 발견된 F7 (Due Date 합성 표현) + Quick Capture 스크롤 버그를 묶은 패치.

View File

@@ -0,0 +1,841 @@
# v0.3.1 Cut F — 멀티모달 vision AI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** F24 — Ollama vision 모델 (gemma3 family default) 활용. 이미지 + raw_text 결합 prompt → title/summary/tags 자동 생성. Capability detection (app launch + manual refresh) + InferenceProvider 확장 + AiWorker 통합 + Configure UI dropdown.
**Architecture:** `isVisionCapable(model)` pure 함수 가 family/families/name 기반으로 vision 가능 모델 판정. `refreshVisionCache(deps)``/api/tags` 호출 후 settings 에 cache. AiWorker 가 `note.media.length > 0 && visionModel` 둘 다 충족 시 vision path (5MB cap + base64 변환). Configure UI 가 cache 기반 dropdown + manual refresh.
**Tech Stack:** undici/fetch (Ollama API), Node fs/promises (이미지 base64 변환), Electron IPC, React 19 + zustand 5, vitest 4 + RTL.
**선행 문서:**
- `docs/superpowers/specs/2026-05-09-v031-cut-f-design.md` — source spec (Cut F 정정 반영: 단위 679, 실제 SettingsService API, 'skipped' enum 미도입, fallback 미구현)
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F24
- `docs/superpowers/strategy/v028plus-roadmap.md` — Cut F 위치
---
## File Structure
**Create:**
- `src/main/services/VisionDetect.ts``isVisionCapable(model)` pure + `refreshVisionCache(deps)` async (Ollama /api/tags)
- `src/main/ai/visionPrompt.ts``buildVisionPrompt(text, todayKst, dueCandidates, vocab)` pure
- `src/renderer/inbox/components/settings/VisionSection.tsx` — AI 제공자 섹션 안 또는 별도 sub-section. dropdown + 다시 감지 버튼
- `tests/unit/VisionDetect.test.ts` — isVisionCapable 5 + refreshVisionCache 4
- `tests/unit/visionPrompt.test.ts` — buildVisionPrompt 2 (text only / image-only fallback)
- `tests/unit/AiWorker.vision.test.ts` — vision path 3 (text-only / vision body / 5MB cap)
- `tests/unit/VisionSection.test.tsx` — UI 1 (dropdown + 다시 감지)
**Modify:**
- `src/main/services/SettingsService.ts` — zod schema vision_model / vision_capable_cache / vision_cache_at + 4 메서드
- `src/main/ai/InferenceProvider.ts``GenerateInput.images?: Array<{ base64: string; mime: string }>` + `generate(input, opts?: { visionModel?: string | null })`
- `src/main/ai/LocalOllamaProvider.ts``generate` body 에 `images` 필드 (vision path) + 모델 분기
- `src/main/ai/AiWorker.ts``note.media + visionModel` vision path + 5MB cap + base64 변환. 생성자에 `settings: SettingsService` 의존성 추가
- `src/main/ipc/settingsApi.ts` — 3 IPC: `settings:get-vision-models` / `settings:set-vision-model` / `settings:refresh-vision-cache`
- `src/preload/index.ts` — 3 bridge
- `src/shared/types.ts``getSettings()` 반환에 vision_* 3 필드 + InboxApi 3 메서드
- `src/main/index.ts``void refreshVisionCache(...)` whenReady 안 + AiWorker 생성자에 settings 주입
- `src/renderer/inbox/components/settings/AiProviderSection.tsx` 또는 SettingsPage — VisionSection 마운트
- `tests/unit/SettingsService.test.ts` — vision 4 메서드 round-trip
- `tests/unit/LocalOllamaProvider.test.ts` — vision body 분기 회귀
- `tests/unit/AiWorker.test.ts` — 기존 mock 에 settings stub 추가 (생성자 변경)
- `package.json` — version 0.3.0 → 0.3.1
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F24 promoted
---
## 단위 목표
679 (v0.3.0) → 약 701 (+22), typecheck 0.
---
## Task 1: VisionDetect — `isVisionCapable` + `refreshVisionCache`
**Files:**
- Create: `src/main/services/VisionDetect.ts`
- Create: `tests/unit/VisionDetect.test.ts`
`isVisionCapable(model)` pure 함수 — family/families/name hints 기반 판정. `refreshVisionCache(deps)` async — `/api/tags` 호출 후 capable 추출 + settings cache 저장. fetch 주입 가능 (테스트).
- [ ] **Step 1: failing test**`tests/unit/VisionDetect.test.ts`:
```ts
import { describe, it, expect, vi } from 'vitest';
import { isVisionCapable, refreshVisionCache } from '../../src/main/services/VisionDetect.js';
describe('isVisionCapable', () => {
it('family=gemma3 → true', () => {
expect(isVisionCapable({ name: 'gemma3:12b', details: { family: 'gemma3' } })).toBe(true);
});
it('families=[llava] → true', () => {
expect(isVisionCapable({ name: 'llava-13b', details: { families: ['llava'] } })).toBe(true);
});
it('name hint "vision" → true', () => {
expect(isVisionCapable({ name: 'custom-vision-7b' })).toBe(true);
});
it('text-only family=gemma → false', () => {
expect(isVisionCapable({ name: 'gemma4:e4b', details: { family: 'gemma' } })).toBe(false);
});
it('no hints + unknown family → false', () => {
expect(isVisionCapable({ name: 'mistral:7b', details: { family: 'mistral' } })).toBe(false);
});
});
describe('refreshVisionCache', () => {
it('happy path — capable 추출 + settings cache 저장', async () => {
const settings = {
isAiEnabled: vi.fn(async () => true),
setVisionCapableCache: vi.fn(async () => {})
};
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
models: [
{ name: 'gemma4:e4b', details: { family: 'gemma' } },
{ name: 'gemma3:12b-vision', details: { family: 'gemma3' } },
{ name: 'llava:13b', details: { families: ['llava'] } }
]
})
})) as unknown as typeof fetch;
const r = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
fetchImpl
});
expect(r).toEqual({ ok: true, models: ['gemma3:12b-vision', 'llava:13b'] });
expect(settings.setVisionCapableCache).toHaveBeenCalledWith(['gemma3:12b-vision', 'llava:13b'], expect.any(Date));
});
it('ai_disabled → 스킵', async () => {
const settings = {
isAiEnabled: vi.fn(async () => false),
setVisionCapableCache: vi.fn(async () => {})
};
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x' });
expect(r).toEqual({ ok: false, reason: 'ai_disabled' });
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
});
it('http error → ok:false', async () => {
const settings = {
isAiEnabled: vi.fn(async () => true),
setVisionCapableCache: vi.fn(async () => {})
};
const fetchImpl = vi.fn(async () => ({
ok: false,
status: 500,
json: async () => ({})
})) as unknown as typeof fetch;
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x', fetchImpl });
expect(r).toMatchObject({ ok: false });
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
});
it('unreachable → ok:false', async () => {
const settings = {
isAiEnabled: vi.fn(async () => true),
setVisionCapableCache: vi.fn(async () => {})
};
const fetchImpl = vi.fn(async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof fetch;
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x', fetchImpl });
expect(r).toMatchObject({ ok: false });
});
});
```
- [ ] **Step 2: implementation**`src/main/services/VisionDetect.ts`:
```ts
import type { SettingsService } from './SettingsService.js';
const VISION_FAMILIES = new Set(['gemma3', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3'];
export interface OllamaModel {
name: string;
details?: { family?: string; families?: string[] };
}
export function isVisionCapable(model: OllamaModel): boolean {
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
if (model.details?.families?.some((f) => VISION_FAMILIES.has(f))) return true;
const lower = model.name.toLowerCase();
return VISION_NAME_HINTS.some((h) => lower.includes(h));
}
export interface RefreshDeps {
settings: SettingsService;
endpoint: string;
now?: () => Date;
fetchImpl?: typeof fetch;
}
export async function refreshVisionCache(
deps: RefreshDeps
): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
if (!(await deps.settings.isAiEnabled())) {
return { ok: false, reason: 'ai_disabled' };
}
const fetchFn = deps.fetchImpl ?? fetch;
let body: { models?: OllamaModel[] };
try {
const r = await fetchFn(`${deps.endpoint}/api/tags`);
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
body = (await r.json()) as { models?: OllamaModel[] };
} catch (e) {
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
}
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
const now = deps.now ? deps.now() : new Date();
await deps.settings.setVisionCapableCache(capable, now);
return { ok: true, models: capable };
}
```
- [ ] **Step 3: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/VisionDetect.test.ts
git add src/main/services/VisionDetect.ts tests/unit/VisionDetect.test.ts
git commit -m "feat(v031): VisionDetect — isVisionCapable + refreshVisionCache (fetch 주입)"
```
---
## Task 2: SettingsService — vision_model / vision_capable_cache + 4 메서드
**Files:**
- Modify: `src/main/services/SettingsService.ts`
- Modify: `tests/unit/SettingsService.test.ts`
zod schema 확장 + 4 메서드 추가 (Cut E sync_* 패턴).
- [ ] **Step 1: zod schema 확장**`src/main/services/SettingsService.ts`:
```ts
const SettingsSchema = z.object({
ollama: OllamaSettingsSchema.optional(),
ai_enabled: z.boolean().optional(),
onboarding_completed: z.boolean().optional(),
sync_repo_url: z.string().nullable().optional(),
sync_auto_enabled: z.boolean().optional(),
sync_interval_min: z.number().int().min(5).optional(),
// v0.3.1 Cut F — vision 모델 (이미지 분석). null/없음 = 비활성.
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
```
- [ ] **Step 2: 4 메서드 추가** (`setSyncIntervalMin` 다음):
```ts
async getVisionModel(): Promise<string | null> {
const s = await this.load();
return s.vision_model ?? null;
}
async setVisionModel(value: string | null): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_model: value };
await this.persist(next);
}
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
const s = await this.load();
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
}
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
await this.persist(next);
}
```
- [ ] **Step 3: failing test**`tests/unit/SettingsService.test.ts` 의 마지막 describe (Cut E sync) 다음에 추가:
```ts
describe('v0.3.1 Cut F — vision settings', () => {
it('getVisionModel() 기본 null', async () => {
expect(await svc.getVisionModel()).toBeNull();
});
it('setVisionModel / getVisionModel round-trip + null clear', async () => {
await svc.setVisionModel('gemma3:12b-vision');
expect(await svc.getVisionModel()).toBe('gemma3:12b-vision');
await svc.setVisionModel(null);
expect(await svc.getVisionModel()).toBeNull();
});
it('getVisionCapableCache() 기본 빈 배열 + null at', async () => {
expect(await svc.getVisionCapableCache()).toEqual({ models: [], at: null });
});
it('setVisionCapableCache 저장 + at ISO', async () => {
const at = new Date('2026-05-10T05:00:00Z');
await svc.setVisionCapableCache(['gemma3:12b', 'llava:13b'], at);
const r = await svc.getVisionCapableCache();
expect(r.models).toEqual(['gemma3:12b', 'llava:13b']);
expect(r.at).toBe('2026-05-10T05:00:00.000Z');
});
});
```
- [ ] **Step 4: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/SettingsService.test.ts
git add src/main/services/SettingsService.ts tests/unit/SettingsService.test.ts
git commit -m "feat(v031): SettingsService.{getVisionModel,setVisionModel,getVisionCapableCache,setVisionCapableCache}"
```
---
## Task 3: visionPrompt + InferenceProvider 인터페이스 확장
**Files:**
- Create: `src/main/ai/visionPrompt.ts`
- Modify: `src/main/ai/InferenceProvider.ts`
- Create: `tests/unit/visionPrompt.test.ts`
`buildVisionPrompt(text, todayKst, dueCandidates, vocab)` pure — 이미지 + raw_text 결합 시나리오. 빈 text 도 처리 ("(이미지만 있음)" placeholder).
- [ ] **Step 1: failing test**`tests/unit/visionPrompt.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { buildVisionPrompt } from '../../src/main/ai/visionPrompt.js';
describe('buildVisionPrompt', () => {
it('text + 이미지 시 메모 본문 포함', () => {
const r = buildVisionPrompt('회의 메모', '2026-05-10', ['2026-05-10'], ['회의']);
expect(r).toContain('회의 메모');
expect(r).toContain('2026-05-10');
expect(r).toContain('회의');
});
it('빈 text → "(이미지만 있음)" placeholder', () => {
const r = buildVisionPrompt('', '2026-05-10', [], []);
expect(r).toContain('(이미지만 있음)');
});
});
```
- [ ] **Step 2: implementation**`src/main/ai/visionPrompt.ts`:
```ts
/**
* v0.3.1 Cut F — 멀티모달 vision prompt. 이미지 + raw_text 결합 분석 후
* title/summary/tags/due_date JSON 응답 요청. 빈 raw_text 도 처리.
*/
export function buildVisionPrompt(
text: string,
todayKst: string,
dueCandidates: string[],
vocab: string[]
): string {
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
메모 본문 (비어 있을 수 있음):
${text || '(이미지만 있음)'}
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
오늘: ${todayKst}
가능한 due 후보: ${dueCandidates.join(', ')}
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
}
```
- [ ] **Step 3: InferenceProvider 인터페이스 확장**`src/main/ai/InferenceProvider.ts`:
```ts
export interface GenerateInput {
text: string;
todayKst: string;
dueDateCandidates: string[];
vocab?: string[];
// v0.3.1 Cut F — 멀티모달 vision (옵션). LocalOllamaProvider 가 visionModel 과 함께 처리.
images?: Array<{ base64: string; mime: string }>;
}
export interface GenerateOptions {
visionModel?: string | null;
}
export interface InferenceProvider {
generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse>;
// ... 기존 abort / generateRaw
}
```
(기존 호출자는 `opts` 미전달이라 호환 — vision path off.)
- [ ] **Step 4: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/visionPrompt.test.ts
git add src/main/ai/visionPrompt.ts src/main/ai/InferenceProvider.ts tests/unit/visionPrompt.test.ts
git commit -m "feat(v031): buildVisionPrompt + GenerateInput.images + GenerateOptions.visionModel"
```
---
## Task 4: LocalOllamaProvider — vision path
**Files:**
- Modify: `src/main/ai/LocalOllamaProvider.ts`
- Modify: `tests/unit/LocalOllamaProvider.test.ts`
`generate(input, opts)``opts.visionModel + input.images` 둘 다 있으면 vision body 생성 (model = visionModel, prompt = buildVisionPrompt, body.images = base64 array). 그 외는 기존 text-only path.
- [ ] **Step 1: failing test** — 기존 `LocalOllamaProvider.test.ts` 의 적절한 describe 안:
```ts
describe('vision path (v0.3.1 Cut F)', () => {
it('opts.visionModel + input.images 둘 다 있으면 vision body', async () => {
let captured: { model?: string; prompt?: string; images?: string[] } = {};
const undici = await import('undici');
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
captured = JSON.parse(init?.body as string);
return {
statusCode: 200,
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
} as never;
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate(
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [], images: [{ base64: 'AAAA', mime: 'image/png' }] },
{ visionModel: 'gemma3:12b-vision' }
);
expect(captured.model).toBe('gemma3:12b-vision');
expect(captured.prompt).toContain('이미지');
expect(captured.images).toEqual(['AAAA']);
requestSpy.mockRestore();
});
it('visionModel 있어도 images 없으면 text-only path', async () => {
let captured: { model?: string; images?: unknown } = {};
const undici = await import('undici');
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
captured = JSON.parse(init?.body as string);
return {
statusCode: 200,
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
} as never;
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate(
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] },
{ visionModel: 'gemma3:12b-vision' }
);
expect(captured.model).toBe('gemma4:e4b');
expect(captured.images).toBeUndefined();
requestSpy.mockRestore();
});
it('opts 미전달 → 기존 text-only (회귀)', async () => {
let captured: { model?: string; images?: unknown } = {};
const undici = await import('undici');
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
captured = JSON.parse(init?.body as string);
return {
statusCode: 200,
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
} as never;
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate({ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] });
expect(captured.model).toBe('gemma4:e4b');
expect(captured.images).toBeUndefined();
requestSpy.mockRestore();
});
});
```
(기존 LocalOllamaProvider.test.ts 의 mock 패턴 따름. test file 의 imports + vi.mock 은 그대로 사용.)
- [ ] **Step 2: implementation**`LocalOllamaProvider.generate` body 분기:
```ts
import { buildVisionPrompt } from './visionPrompt.js';
// ...
async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
const model = useVision ? opts!.visionModel! : this.model;
const prompt = useVision
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [])
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
try {
const body: Record<string, unknown> = {
model,
prompt,
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
};
if (useVision) {
body.images = input.images!.map((i) => i.base64);
}
const res = await request(`${this.endpoint}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: this.abortController.signal
});
// ... 기존 parse
} finally {
// ...
}
}
```
- [ ] **Step 3: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/LocalOllamaProvider.test.ts
git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts
git commit -m "feat(v031): LocalOllamaProvider vision path (visionModel + images → body.images base64)"
```
---
## Task 5: AiWorker — vision integration + 5MB cap + settings 의존성
**Files:**
- Modify: `src/main/ai/AiWorker.ts`
- Modify: `tests/unit/AiWorker.test.ts`
- Create: `tests/unit/AiWorker.vision.test.ts`
AiWorker 가 `note.media + visionModel` 조건에서 base64 변환 (5MB cap) + provider.generate 에 images + visionModel 전달. 생성자에 `settings: SettingsService` 의존성 추가.
- [ ] **Step 1: AiWorker 생성자 변경** — settings 파라미터 추가. `src/main/index.ts` 의 인스턴스 생성도 갱신.
- [ ] **Step 2: AiWorker.processJob 갱신**:
```ts
import { readFile } from 'node:fs/promises';
// 클래스 안 generate 호출 직전:
const visionModel = await this.settings.getVisionModel();
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && note.media.length > 0) {
images = await Promise.all(
note.media.map(async (m) => {
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
if (buf.byteLength > 5 * 1024 * 1024) {
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
}
return { base64: buf.toString('base64'), mime: m.mime };
})
);
}
const res = await this.holder.get().generate(
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
{ visionModel: visionModel ?? undefined }
);
```
`mediaStore: MediaStore` 도 AiWorker 생성자에 신규 파라미터 (현재 없으면 추가; main 에서 주입).
- [ ] **Step 3: failing test**`tests/unit/AiWorker.vision.test.ts`:
```ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { runMigrations } from '../../src/main/db/migrations/index.js';
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
import { AiWorker } from '../../src/main/ai/AiWorker.js';
import { MediaStore } from '../../src/main/services/MediaStore.js';
describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
let db: Database.Database;
let repo: NoteRepository;
let workDir: string;
let mediaStore: MediaStore;
beforeEach(async () => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
repo = new NoteRepository(db);
workDir = await mkdtemp(join(tmpdir(), 'inkling-vision-'));
mediaStore = new MediaStore(workDir);
});
afterEach(async () => {
db.close();
await rm(workDir, { recursive: true, force: true });
});
it('visionModel + media 있음 → provider.generate 가 images + opts 받음', async () => {
const { id } = repo.create({ rawText: '이미지 메모' });
const mediaPath = join(workDir, 'media', id, '1.png');
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(mediaPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // 4 bytes PNG-ish
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
const provider = { name: 'fake', generate, abort: () => {} };
const settings = {
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
isAiEnabled: vi.fn(async () => true)
} as unknown as never;
const worker = new AiWorker(/* ...deps with settings + mediaStore + repo + holder = { get: () => provider } */);
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
expect(generate).toHaveBeenCalledWith(
expect.objectContaining({ images: expect.any(Array) }),
expect.objectContaining({ visionModel: 'gemma3:12b-vision' })
);
const callArg = generate.mock.calls[0]![0] as { images: Array<{ base64: string; mime: string }> };
expect(callArg.images).toHaveLength(1);
expect(callArg.images[0]!.mime).toBe('image/png');
});
it('visionModel 없으면 text-only (회귀)', async () => {
const { id } = repo.create({ rawText: 'just text' });
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
const provider = { name: 'fake', generate, abort: () => {} };
const settings = {
getVisionModel: vi.fn(async () => null),
isAiEnabled: vi.fn(async () => true)
} as unknown as never;
const worker = new AiWorker(/* ... */);
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
expect(generate).toHaveBeenCalledWith(
expect.not.objectContaining({ images: expect.anything() }),
expect.any(Object)
);
});
it('5MB 초과 이미지 → throw → ai_status=failed', async () => {
const { id } = repo.create({ rawText: 'big image' });
const mediaPath = join(workDir, 'media', id, '1.png');
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(mediaPath, Buffer.alloc(6 * 1024 * 1024)); // 6 MB
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 6 * 1024 * 1024 }]);
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
const settings = {
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
isAiEnabled: vi.fn(async () => true)
} as unknown as never;
const worker = new AiWorker(/* ... */);
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
// 5MB cap 초과 throw → AiWorker 의 attempts 증가 분기 → ai_status='failed'
const note = repo.findById(id);
expect(['failed', 'pending']).toContain(note!.aiStatus); // attempts 모두 소진 시 'failed'; 첫 시도 throw 시 'pending' 유지 가능 — 구현 의존
});
});
```
(NOTE: 정확한 AiWorker 생성자 인자 — 기존 test 의 setup 패턴 따라 deps 전체 stub 구성. 위 코드는 outline; 실수행자가 기존 `AiWorker.test.ts` setup 참고하여 정확한 deps 구조 채움.)
- [ ] **Step 4: 기존 AiWorker.test.ts mock 갱신** — 생성자에 `settings` / `mediaStore` 파라미터 추가됨. 모든 기존 test 의 worker 생성 site 에 stub 추가.
- [ ] **Step 5: PASS + commit**
```bash
npm run typecheck
npx vitest run tests/unit/AiWorker.test.ts tests/unit/AiWorker.vision.test.ts
git add src/main/ai/AiWorker.ts \
src/main/index.ts \
tests/unit/AiWorker.test.ts \
tests/unit/AiWorker.vision.test.ts
git commit -m "feat(v031): AiWorker vision integration — note.media + visionModel + 5MB cap"
```
---
## Task 6: types + IPC + preload
**Files:**
- Modify: `src/shared/types.ts``getSettings()` 반환에 vision_model / vision_capable_cache / vision_cache_at + InboxApi 3 메서드
- Modify: `src/main/ipc/settingsApi.ts` — 3 IPC handler
- Modify: `src/preload/index.ts` — 3 bridge
- Create: `tests/unit/vision-ipc.test.ts`
3 채널:
- `settings:get-vision-models``{ models: string[]; at: string | null; selected: string | null }` (cache 결과 + 현재 선택)
- `settings:set-vision-model` (value: string | null) → `{ ok: true }`
- `settings:refresh-vision-cache``{ ok: true; models: string[] } | { ok: false; reason: string }` (refreshVisionCache 호출)
상세 패턴은 Cut E sync IPC 와 동일.
- [ ] **Step 1: types** + **Step 2: failing test** + **Step 3: handlers** + **Step 4: preload bridges** — Cut E sync-ipc 패턴 그대로
- [ ] **Step 5: PASS + commit**
```bash
git add src/shared/types.ts src/main/ipc/settingsApi.ts src/preload/index.ts tests/unit/vision-ipc.test.ts
git commit -m "feat(v031): vision IPC + preload (get-vision-models / set / refresh)"
```
---
## Task 7: VisionSection UI + AI 제공자 섹션 통합
**Files:**
- Create: `src/renderer/inbox/components/settings/VisionSection.tsx`
- Modify: `src/renderer/inbox/components/settings/AiProviderSection.tsx` 또는 SettingsPage — 마운트
- Create: `tests/unit/VisionSection.test.tsx`
dropdown (cache 기반) + 다시 감지 버튼 + 마지막 감지 시각 표시. dropdown 변경 시 `setVisionModel` 호출. 다시 감지 → `refreshVisionCache` IPC + dropdown 갱신.
```tsx
// 핵심 구조 (Cut E SyncSection 패턴)
const [models, setModels] = useState<string[]>([]);
const [at, setAt] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [busy, setBusy] = useState<'select' | 'refresh' | null>(null);
useEffect(() => {
void (async () => {
const r = await inboxApi.getVisionModels();
setModels(r.models);
setAt(r.at);
setSelected(r.selected);
})();
}, []);
async function onSelect(value: string) {
setBusy('select');
await inboxApi.setVisionModel(value === '' ? null : value);
setSelected(value === '' ? null : value);
setBusy(null);
}
async function onRefresh() {
setBusy('refresh');
const r = await inboxApi.refreshVisionCache();
setBusy(null);
if (r.ok) {
const cache = await inboxApi.getVisionModels();
setModels(cache.models);
setAt(cache.at);
}
}
```
UI:
```tsx
<select value={selected ?? ''} onChange={(e) => void onSelect(e.target.value)} aria-label="이미지 분석 모델">
<option value="">()</option>
{models.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<button onClick={() => void onRefresh()} disabled={busy === 'refresh'}>
{busy === 'refresh' ? '감지 중…' : '다시 감지'}
</button>
{at !== null && <span> : {new Date(at).toLocaleString('ko-KR')}</span>}
```
- [ ] **Step 1-5: 컴포넌트 + test + 마운트 + commit**
```bash
git add src/renderer/inbox/components/settings/VisionSection.tsx \
src/renderer/inbox/components/settings/AiProviderSection.tsx \
tests/unit/VisionSection.test.tsx
git commit -m "feat(v031): VisionSection — dropdown + 다시 감지 + 마지막 감지 시각"
```
---
## Task 8: main process — refreshVisionCache 자동 호출 + AiWorker settings 주입
**Files:**
- Modify: `src/main/index.ts`
`whenReady` 안 (Ollama provider 준비 후) `void refreshVisionCache(...)` fire-and-forget 호출. AiWorker 생성자에 settings + mediaStore 주입.
- [ ] **Step 1: imports + 호출**`src/main/index.ts`:
```ts
import { refreshVisionCache } from './services/VisionDetect.js';
// whenReady 안, AiWorker.start() 직후 또는 직전
const ollama = providerHolder.get();
void refreshVisionCache({
settings: settingsSvc,
endpoint: (ollama as LocalOllamaProvider).endpoint, // 또는 SettingsService 의 ollama 설정에서 가져옴
}).catch(() => {});
```
(LocalOllamaProvider 의 endpoint 가 private 이면 settings 에서 가져옴 또는 provider 에 getter 추가.)
- [ ] **Step 2: AiWorker 생성자 인자 갱신**
- [ ] **Step 3: typecheck + PASS + commit**
```bash
npm run typecheck
npx vitest run
git add src/main/index.ts
git commit -m "feat(v031): main — refreshVisionCache whenReady + AiWorker settings/mediaStore 주입"
```
---
## Task 9: dogfood promoted + version bump + release commit
- [ ] F24 promoted 마킹 (`docs/superpowers/specs/2026-04-25-dogfood-feedback.md`):
```markdown
## F24. 멀티모달 vision (✅ promoted v0.3.1 Cut F)
**상태:** ✅ promoted v0.3.1 Cut F — Ollama vision 모델 (gemma3 family default) 활용. capability detection (app launch + manual refresh) + Configure UI dropdown + AiWorker vision integration (5MB cap + base64 변환). 자동 fallback (caption → text) deferred v0.3.2+.
```
- [ ] package.json: 0.3.0 → 0.3.1 + package-lock.json
- [ ] full unit + typecheck
```bash
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json package-lock.json
git commit -m "chore(release): v0.3.1 — Cut F (멀티모달 vision AI)"
```
---
## Self-Review Checklist (수행자: 모든 task 완료 후 1회 점검)
- [ ] **Spec coverage**: §3 Capability Detection (Task 1) / §3-2 SettingsService (Task 2) / §3-3 main wiring (Task 8) / §3-4 UI (Task 7) / §4 Provider (Tasks 3-4) / §5 AiWorker (Task 5) / §6 image-only fallback ('skipped' enum 미도입 → 기존 'failed' 분기 활용)
- [ ] **Single write path 강제 (Cut C/D/E 정책)**: 본 cut 은 새 데이터 path 추가 없음 — `notes_fts` / `note_revisions` / `note_tags` mutation 없음 (vision 결과는 기존 `updateAiResult` path 활용 → 이미 검증됨). 회귀 검사 4-path invariant 유지.
- [ ] **Type 일관성**: `GenerateInput.images``GenerateOptions.visionModel` ↔ AiWorker 호출 ↔ LocalOllamaProvider body 모두 동일 shape
- [ ] **단위 카운트**: VisionDetect 9 (5+4) + SettingsService 4 + visionPrompt 2 + LocalOllamaProvider 3 + AiWorker 3 + IPC 3-5 + UI 1 = 약 25-27 신규. 목표 22 달성
---
## Risk
- **vision 모델 한국어 정확도**: gemma3 family 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책). dogfood 검증 필요
- **Ollama 가 vision images 무시 (모델 misclassify)**: capability detection false-positive — 사용자가 dropdown 에서 다른 모델 선택해 우회. 자동 fallback 미구현 (YAGNI)
- **base64 메모리 폭주**: 5MB cap 적용. 다중 이미지 시 N×5MB = 메모리 누적 — vision 호출 후 image array 즉시 GC. 본 cut 의 dogfood 규모 (메모당 < 3 이미지) 무시
- **capability detection 실패 silent**: 첫 launch 시 network 실패 → cache 빈 채로 진행. 사용자가 설정 페이지에서 "다시 감지" 클릭 → 직접 trigger 가능
- **AiWorker 생성자 변경**: 기존 test 모두 mock 갱신 필요 (typecheck 가 catch). 누락 시 typecheck red
- **F23 OFF (ai_enabled=false) 시 자동 OFF**: refreshVisionCache 가 ai_enabled 체크 → ai_disabled 분기. AiWorker 의 vision path 진입 자체가 ai_enabled=true 가정 — F23 OFF 시 vision path 미도달 (자명)
- **e2e**: Cut C/D/E 와 동일 — 본 cut 미수행, main 머지 후 검증

File diff suppressed because it is too large Load Diff

View File

@@ -1787,9 +1787,9 @@ app.on('activate', () => {
---
## F24. 이미지 멀티모달 AI 분석 (🌱 raw — v0.2.8/v0.3 후보, capability gated)
## F24. 이미지 멀티모달 AI 분석 (✅ promoted v0.3.1 Cut F)
**진행 상태:** 🌱 raw — Ollama vision 모델 (llava / llama3.2-vision / gemma3-multimodal 등) 활용. 사용자 표현: "가능할 경우만 하면 될 것 같다" — capability detection + opt-in 명시.
**진행 상태:** ✅ promoted v0.3.1 Cut F — Ollama vision 모델 (gemma3 family default) 활용. capability detection (app launch + manual refresh) + Configure UI dropdown + AiWorker vision integration (5MB cap + base64 변환). 자동 fallback (caption → text) + 'skipped' enum deferred v0.3.2+. 단위 679 → 710. dogfood: vision 결과 정확도 + 한국어 token 정확도 검증.
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. F22 (이미지 렌더링) + F23 (Ollama-less 모드) 와 강하게 연관.

View File

@@ -12,7 +12,7 @@
## 1. Cut 정체성
Ollama vision 모델 (gemma3 family default) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
Ollama vision 모델 (gemma family — gemma3 / gemma4 default capable) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
---
@@ -20,7 +20,7 @@ Ollama vision 모델 (gemma3 family default) 활용 — 이미지 + raw_text 결
| 항목 | 결정 |
|---|---|
| **F24 default 모델** | gemma3 family (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
| **F24 default 모델** | gemma family — gemma3 / gemma4 둘 다 vision-capable hint (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
| **prompt 모드** | 단일 vision 모델 호출 (vision 모델이 텍스트도 처리). 모델 capability 부족 시 2단계 fallback (자동) |
| **capability detection** | app launch 시 1회 + 설정 페이지 manual refresh 버튼 |
| **F23 OFF 시 자동 OFF** | `ai_enabled=false` → vision 도 자동 OFF (자명) |
@@ -56,36 +56,59 @@ function isVisionCapable(model: { name: string; details?: { family?: string; fam
}
```
### 3-2. Settings storage
### 3-2. Settings storage (실제 SettingsService API)
zod schema 확장 (기존 ai_enabled / sync_* 와 동일 strict 패턴):
```ts
interface SettingsSchema {
// ... 기존
vision_model?: string; // 사용자 명시 모델 (빈 값 = 비활성)
vision_capable_cache?: string[]; // launch 시 detected 결과 cache
vision_cache_at?: string; // ISO timestamp
}
const SettingsSchema = z.object({
// ... 기존 ollama / ai_enabled / onboarding_completed / sync_*
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
```
신규 SettingsService 메서드 (개별 setter/getter — `get/set` 일반화 X):
```ts
async getVisionModel(): Promise<string | null>;
async setVisionModel(value: string | null): Promise<void>;
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }>;
async setVisionCapableCache(models: string[], now: Date): Promise<void>;
```
### 3-3. AppLaunchDetect
```ts
// src/main/index.ts whenReady 안 (settings 초기화 후)
async function refreshVisionCache(): Promise<void> {
if (!settingsService.get('ai_enabled', true)) return;
try {
const tags = await fetch(`${endpoint}/api/tags`).then(r => r.json());
const capable = tags.models.filter(isVisionCapable).map((m: any) => m.name);
settingsService.set('vision_capable_cache', capable);
settingsService.set('vision_cache_at', new Date().toISOString());
} catch {
// network fail — silent, cache 유지
}
}
`src/main/services/VisionDetect.ts` 신규 — pure 함수 + 외부 fetch 주입 (테스트 가능):
void refreshVisionCache();
```ts
export async function refreshVisionCache(deps: {
settings: SettingsService;
endpoint: string;
now?: () => Date;
fetchImpl?: typeof fetch;
}): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
if (!(await deps.settings.isAiEnabled())) {
return { ok: false, reason: 'ai_disabled' };
}
const fetchFn = deps.fetchImpl ?? fetch;
let body: { models?: Array<{ name: string; details?: { family?: string; families?: string[] } }> };
try {
const r = await fetchFn(`${deps.endpoint}/api/tags`);
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
body = await r.json();
} catch (e) {
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
}
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
await deps.settings.setVisionCapableCache(capable, deps.now ? deps.now() : new Date());
return { ok: true, models: capable };
}
```
main process `whenReady` 안에서 fire-and-forget 호출. 실패 silent (cache 유지). settings:refresh-vision-cache IPC 가 동일 함수 호출 (manual "다시 감지" 버튼).
### 3-4. 설정 페이지 UI (AI 제공자 섹션 확장)
```
@@ -166,44 +189,53 @@ ${text || '(이미지만 있음)'}
---
## 5. AiWorker 통합
## 5. AiWorker 통합 (실제 API 정정)
CaptureService 가 capture 시 image 첨부했으면 → notes.media 에 저장 + pending_jobs INSERT. AiWorker 가 job 처리 시:
기존 `AiWorker.processJob``repo.findById(noteId)` 로 hydrate 된 `Note` 받음 — `note.media` 가 이미 join 결과로 채워져 있어 별도 `listMediaByNote` 호출 불필요. `MediaStore.absolutePath(relPath)` 로 디스크 path 추출.
```ts
// src/main/ai/AiWorker.ts
async processJob(noteId: string): Promise<void> {
const note = this.repo.getById(noteId);
const media = this.repo.listMediaByNote(noteId);
const visionModel = this.settings.get('vision_model');
// src/main/ai/AiWorker.ts processJob 흐름
const note = this.repo.findById(job.noteId);
if (!note || ...) return;
const visionModel = await this.settings.getVisionModel();
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && media.length > 0) {
images = await Promise.all(media.map(async (m) => ({
base64: (await fs.readFile(this.mediaStore.absolutePath(m.relPath))).toString('base64'),
mime: m.mime
})));
}
const provider = this.providerHolder.get();
const response = await provider.generate({ text: note.rawText, images, ... }, { visionModel });
// ... 기존 결과 적용
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && note.media.length > 0) {
images = await Promise.all(
note.media.map(async (m) => {
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
// 이미지당 5MB cap (base64 메모리 폭주 방지)
if (buf.byteLength > 5 * 1024 * 1024) {
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
}
return { base64: buf.toString('base64'), mime: m.mime };
})
);
}
const res = await this.holder.get().generate({
text: note.rawText,
images,
todayKst,
dueDateCandidates: candidates,
vocab
}, { visionModel });
```
`media.length > 0 && visionModel` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only.
`visionModel && note.media.length > 0` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only path 유지 (호환 보존). image 5MB cap 초과 시 throw → 기존 AiWorker 의 attempts 카운트 + ai_status='failed' 분기 활용.
AiWorker 의 `settings: SettingsService` 의존성 추가 — 기존 생성자에 신규 파라미터.
---
## 6. 이미지만 있는 capture
## 6. 이미지만 있는 capture (정정 — 신규 enum 도입 X)
`raw_text` 빈 값 + media 첨부만:
`raw_text` 빈 값 + media 첨부만 케이스:
- 기존 동작: notes INSERT (raw_text=''), AiWorker 가 빈 prompt 로 호출 → ai_status='failed' 또는 무의미 응답
- vision enabled: AiWorker 가 vision prompt + images → 의미 있는 title/summary/tags 응답
- vision disabled (visionModel 빈 값): notes 저장만, ai_status='disabled' 신규 enum 활용 (Cut B 의 ai_enabled false 와 비슷한 의미 — 그러나 부분 disable, 즉 "이미지 only 라 처리 불가" 상태)
- **vision enabled** (`visionModel` 설정 + media 있음): AiWorker 의 vision path → 의미 있는 title/summary/tags 응답
- **vision disabled** (`visionModel` null): 기존 text-only 흐름 그대로 — 빈 prompt → AI 응답이 무의미하면 ai_status='failed' 분기 (재시도 가능). dogfood 시 빈도 측정 후 'skipped' enum 도입 여부 재평가.
추천: vision disabled + image-only capture 시 `ai_status='skipped'` 신규 enum (Cut B 의 'disabled' 와 다름). title fallback = "(이미지 N개)" 또는 첫 이미지 파일명.
**'skipped' 신규 enum 미도입 (YAGNI)**: m008 마이그레이션 (CHECK relax via table recreate) 부담 + 이미지-only capture 가 본 cut 의 main use case 가 아님. 사용자가 vision 활성 후 retry 하거나 raw_text 추가 후 reprocess 하는 우회로 충분. 정책 검토는 dogfood 후 별도 cut.
---
@@ -219,7 +251,7 @@ async processJob(noteId: string): Promise<void> {
| `AiWorker.processJob` vision integration | media + visionModel 있을 때만 base64 변환 |
| 이미지 only capture | raw_text='' + media → vision 결과 정상 또는 'skipped' 분기 |
**목표**: 단위 555 → 약 575 (+20), typecheck 0.
**목표**: 단위 679 → 약 701 (+22, isVisionCapable 5 + refreshVisionCache 4 + SettingsService vision 4 + LocalOllamaProvider vision path 3 + buildVisionPrompt 2 + AiWorker vision integration 3 + UI dropdown 1), typecheck 0.
---
@@ -231,7 +263,7 @@ async processJob(noteId: string): Promise<void> {
| 이미지 base64 메모리 부담 | media 1개당 평균 < 1MB. 다중 이미지 시 N×base64 = 메모리 N배. cap (이미지당 max size 5MB) 적용 |
| capability detection 실패 시 fallback | cache 부재 → vision dropdown 비어있음 표시 + "다시 감지" 안내 |
| vision 모델 한국어 정확도 | dogfood 검증. gemma3 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책 갱신) |
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | 자동 2단계 fallback — vision 모델로 caption 추출 → 텍스트 모델 종합 (capability 부족 시) |
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | **본 cut 미구현 (YAGNI)** 자동 2단계 fallback (caption 추출 → 텍스트 모델 종합) 은 v0.3.2+ 검토. dogfood 시 capability detection 정확도 우선 |
---

View File

@@ -0,0 +1,320 @@
# v0.3.2 — Cleanup Cut Design
**작성일:** 2026-05-10
**선행 문서:**
- `docs/superpowers/v024-backlog.md` (잔여 backlog audit)
- `~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` (stale memory — 본 cut 에서 폐기)
- `docs/superpowers/strategy/v028plus-roadmap.md`
**Cut 라벨:** v0.3.2 — patch (기능 추가 X, 잠재 bug fix + cosmetic + 기록 정리)
---
## 1. Cut 정체성
기능 추가 X. backlog 잔여 23건 중 **잠재 bug 4건 + cosmetic 6건 + 기록 정리 2건 = 12건** 일괄 처리. data-dependent 항목 (telemetry 분포 의존) 과 cross-cutting refactor (TrayController class / Banner CSS variables) 는 dogfood 후 재평가. Cut F (v0.3.1) + Cut E (v0.3.0) 종합 dogfood ≥1주 soak 진입을 위한 baseline 정리.
---
## 2. 범위
| 항목 | 결정 |
|---|---|
| **포함 카테고리** | 잠재 bug + cosmetic + 기록 정리 |
| **보류 카테고리** | data-dependent (9건) + cross-cutting refactor (4건) |
| **테스트 +** | 단위 710 → 약 720 (+10), typecheck 0 |
| **schema 변경** | 없음 (m007 이후) |
| **단일 PR** | v0.3.2 — 12 항목 = 7~8 commit (카테고리별 묶음) |
---
## 3. 잠재 bug fix (4건)
### 3-1. `vocabSet` COLLATE NOCASE 정합 (#31)
**현재 코드** (`src/main/ai/AiWorker.ts` `processJob`):
```ts
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
const vocabSet = new Set(vocab);
// ...
if (vocabSet.has(tagName)) { ... } // strict-eq, DB COLLATE NOCASE 와 충돌 가능
```
**문제**: vocab pool 확장 시 (사용자가 `'Design'` 같은 capital case 추가하면) `getTagIdByName('Design')` 은 COLLATE NOCASE 로 매치되지만 `vocabSet.has('Design')` strict-eq 는 lowercase 만 등록된 set 에 miss → tagId 있는데 vocab hit 0 → silently skip.
**수정**:
```ts
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
// ...
if (vocabSet.has(tagName.toLowerCase())) { ... }
```
**테스트 (3건 신규)**:
- 대문자 vocab + lowercase AI tag → hit
- lowercase vocab + 대문자 AI tag → hit
- 동일 lowercase → hit (회귀)
### 3-2. Time-dependent test flake fix
**문제**: `NoteRevisions.test.ts` 의 v1 capture 가 `repo.create()` 호출 → `NoteRepository.create``new Date()` (NOW) 로 `created_at` / `edited_at` 박음. v2 는 fixed `2026-05-10T00:00:00Z` 명시 주입. 시스템 시계가 `2026-05-10T00:00:00Z` 초과 시 v1.edited_at > v2.edited_at → DESC ordering 깨짐. `upsertFromSync.test.ts` 도 동일 패턴.
**수정**: `NoteRepository.create(input, now?: Date)` 추가 (기존 `setStatus(id, status, reason, now: Date)` / `updateRawText(id, text, now: Date)` 패턴 정합).
```ts
// src/main/repository/NoteRepository.ts
create(input: CreateNoteInput, now: Date = new Date()): Note {
const ts = now.toISOString();
// 기존 INSERT 의 createdAt / updatedAt / edited_at 모두 ts 사용
// ...
}
```
**기존 호출자 무영향** — production 코드는 `now` 생략 → 기본 `new Date()` 동일 동작.
**테스트 (5건 회복 + 2건 신규)**:
- `NoteRevisions.test.ts` 의 4 testcase v1 capture 도 fixed 시간 (`'2026-05-09T00:00:00Z'`) 주입 → v2 (`'2026-05-10T00:00:00Z'`) 이전 보장
- `upsertFromSync.test.ts` 의 v1 capture 도 fixed 시간 주입
- `NoteRepository.create` default 시간 (`now` 생략 시 `new Date()`) 단위 1
- `NoteRepository.create` 명시 주입 단위 1
### 3-3. PII reason 마스킹 (#39)
**현재 코드** (`src/main/ai/LocalOllamaProvider.ts` `healthCheck`):
```ts
} catch (e) {
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable: ${(e as Error).message}` } });
}
```
**문제**: `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능 → telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN 사용 흔하게 만들어 노출 경로 확대.
**수정**: error class 분류 + host 마스킹.
```ts
function classifyFetchError(e: unknown): 'timeout' | 'network' | 'dns' | 'other' {
const msg = (e as Error).message?.toLowerCase() ?? '';
if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
return 'other';
}
// emit
const reason = classifyFetchError(e);
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason } });
```
`AiFailedReason` zod enum (`'unreachable' | 'schema' | 'timeout' | 'other'`) 와 별개 — `ollama_unreachable.payload.reason` 만 신규 enum 도입 또는 기존 union 확장. spec 단계 결정: **기존 `'unreachable'` 그대로 유지**, 신규 enum 추가 X (단순화). reason 변환 후 prefix 만 변경:
```ts
const cls = classifyFetchError(e);
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable:${cls}` } });
```
**테스트 (4건 신규)**:
- `ECONNREFUSED``unreachable:network`
- `ETIMEDOUT``unreachable:timeout`
- `ENOTFOUND``unreachable:dns`
- 그 외 → `unreachable:other`
### 3-4. KST_OFFSET_MS inline duplication 5 callsite → import (#19)
**현재**: canonical `src/shared/util/kstDate.ts` 가 있는데 5 callsite inline duplicate.
| 파일 | 라인 | 처리 |
|---|---|---|
| `src/main/repository/NoteRepository.ts:1042` | inline `const KST_OFFSET_MS = ...` | `import { KST_OFFSET_MS } from '@shared/util/kstDate.js'` |
| `src/main/repository/ftsHelpers.ts:18` | 동 | 동 |
| `src/main/services/BackupService.ts:6` | 동 | 동 |
| `src/main/services/ContinuityService.ts:4` | 동 | 동 |
| `src/renderer/inbox/components/NoteCard.tsx:30` | 동 | 동 (renderer alias 경계 X — `@shared/...` 양쪽 import 가능) |
**테스트**: 단위 추가 없음 — 기존 회귀 검사. `kstDate.ts` 의 export 가 동일 값 (9 \* 60 \* 60 \* 1000) 이므로 동작 무변화.
---
## 4. Cosmetic / readability (6건)
### 4-1. 탭 ARIA 패턴 정정 (#14)
`aria-pressed` 는 toggle 버튼용. 본 UI 의 탭 (Inbox / 휴지통 / 회고 등) 은 `role="tab"` + `aria-selected` canonical. screen reader 동작 OK 였지만 a11y audit canonical 정정.
**파일**: `src/renderer/inbox/App.tsx` (탭 컨테이너) — grep 으로 `aria-pressed` 위치 확정 후 수정.
**테스트**: 단위 추가 없음 (기존 RTL 단위가 selectable 검증). 단 `aria-selected="true"` 관련 assertion 1건 추가 검증.
### 4-2. `loadExpired()` 미사용 제거 (#18)
`store.ts``loadExpired()` action 이 `loadInitial`/`refreshMeta` 가 inline fetch 하면서 사용 안 함. App.tsx 호출 0건. test 만 exercise.
**처리**: dead-code 제거. 관련 test 도 제거.
### 4-3. AiWorker per-tag emit `Promise.all` 병렬화 (#32)
**현재**:
```ts
for (const tag of new Set(...)) {
await this.telemetry.emit({ kind: 'tag_vocab_hit' or 'miss', ... }); // serial
}
```
**수정**:
```ts
await Promise.all(
Array.from(new Set(...)).map((tag) => this.telemetry.emit({ ... }))
);
```
**Risk**: emit 순서 변경 — telemetry 파일 라인 순서 의존 단위 없음 확인. `ai_succeeded` emit 도 serial 이지만 본 cut 은 **per-tag emit 만** 변경 (tag_vocab_hit / tag_vocab_miss). `ai_succeeded` 는 serial 유지 (per-tag 와 다른 호출 시점).
**테스트**: 회귀 단위 PASS 확인. 신규 단위 추가 없음 (병렬 동작 자체 검증은 unit 무리).
### 4-4. `emitRecallShown` / `emitRecallSnoozed` `ipcMain.handle → on` (#36)
`fire-and-forget` 정책 호출 측 (RecallBanner) → return value 사용 안 함. canonical pattern: `ipcMain.on` (return value 없음).
**파일**: `src/main/ipc/inboxApi.ts` 또는 telemetry IPC 정의 위치. `emitRecallShown` / `emitRecallSnoozed``handle → on` migration. 호출 측 (`window.api.emitRecallShown` 등) 의 `Promise<void>` 시그니처 그대로 (preload 가 `ipcRenderer.send`).
**Risk**: `ipcMain.handle` 의 return value 의존 호출자 grep 으로 0 확인.
**테스트 (1건 신규)**: `vision-ipc.test.ts` 패턴 정합 — `ipcRenderer.send` 호출 검증.
### 4-5. OllamaSettingsModal 폐기 확인 (#41+#42)
**현재 audit**: `grep "OllamaSettingsModal" src/` → 0건. v0.2.7 cut 에서 이미 폐기됨 (memory: "OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup").
**처리**: backlog 항목 닫기만. **코드 변경 0**. v024-backlog.md 의 #41/#42 처리 이력 ✅ 추가.
### 4-6. Telemetry `.catch(() => {})` silent → debug log (#20)
**현재**: `CaptureService.listExpired` / `trashExpiredBatch``.catch(() => {})` 로 silent.
**수정**:
```ts
this.telemetry.emit(...).catch((e) => {
this.logger.debug('telemetry.emit.failed', { reason: String(e) });
});
```
`logger.debug` (project pattern) — production noise 0, 디버그 시 reproduce 가능.
**테스트**: 단위 추가 없음. 회귀 PASS.
---
## 5. 기록 정리 (2건)
### 5-1. v0.2.2 stale memory 폐기
`~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` — 6건 모두 v0.2.3~v0.2.9 cut 들에서 처리됨 (Ollama 회복 / AI 영속 큐 / 태그 vocab / 휴지통 / 만료 추천 / RecallBanner). 8일 stale.
**처리**:
- 파일 삭제
- `MEMORY.md` 의 line 8 (`- [v0.2.2 feedback]...`) 제거
### 5-2. v024-backlog.md 갱신
처리 이력 table 에 신규 12건 entry 추가:
```markdown
| #14 (탭 ARIA) | ✅ 처리 | v0.3.2 |
| #18 (loadExpired 미사용) | ✅ 처리 | v0.3.2 |
| #19 (KST inline 5 callsite 잔여) | ✅ 처리 | v0.3.2 |
| #20 (.catch silent → debug log) | ✅ 처리 | v0.3.2 |
| #31 (vocabSet COLLATE) | ✅ 처리 | v0.3.2 |
| #32 (per-tag Promise.all) | ✅ 처리 | v0.3.2 |
| #36 (recall IPC handle→on) | ✅ 처리 | v0.3.2 |
| #39 (PII reason 마스킹) | ✅ 처리 | v0.3.2 |
| #41+#42 (OllamaSettingsModal 폐기) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
| time-dependent test flake | ✅ 처리 | v0.3.2 |
```
총 처리 22 → 32, 잔여 23 → 11 (data-dependent 9 + future-proof 2).
---
## 6. 보류 항목 (dogfood 후 재평가)
### data-dependent (9건)
- #25 HealthChecker `inFlight` manual emit ordering — dogfood soak 결과 결정 (1초 윈도우 dedup 필요 여부)
- #29 `getTopUsedTags(20)` magic number → `VOCAB_TOP_N` 모듈 상수 (이미 #29 처리, 튜닝 자체는 telemetry 후)
- #30 `getTopUsedTags` SQL-side 필터 (overfetch+slice vs `GLOB`) — vocab pool 확장 결정
- #33 `PROMPT_VERSION` telemetry payload 추가 — prompt 튜닝 후 hit-rate 추적 시
- #35 `recall_shown` per-banner-lifetime dedup — telemetry 빈도 보고
- #40 Settings 저장 vs HealthChecker race — visible 빈도 확인
- #16 per-note 영구 삭제 telemetry 빈도 — 거의 0 이면 bulk emptyTrash 만 (UX 단순화)
- #28 `unreachableBackoffStep` job-level — multi-provider 도입 시
- recall_shown lifetime 영속 마커 (data-dependent #35 와 일부 중복)
### future-proof / cross-cutting (4건)
- #27 `refreshTrayFailedCount` exported singleton → TrayController class — multi-window 가설 검증 X
- #24+#41 Banner CSS variables — modal 폐기로 일부 자연 소멸 + 잔여 4 banner 의 hardcode 색상은 단일 dogfood UX 영향 0
- #37 NoteCard `id="note-${id}"` ref-forwarding — search 결과 scroll 등 신규 surface 등장 시
- #28 단일 카운터 vs job-level — multi-provider 진입 시점
---
## 7. 테스트 전략
| 영역 | 단위 |
|---|---|
| `vocabSet` lowercase normalize | 3 (대/소문자 vocab × AI tag matrix) |
| `NoteRepository.create(now)` param | 2 (default / 명시 주입) |
| `NoteRevisions.test.ts` flake fix | 4 testcase 회복 (v1 capture 시간 주입) |
| `upsertFromSync.test.ts` flake fix | 2 testcase 회복 |
| PII reason classification | 4 (network/timeout/dns/other) |
| KST inline → import migration | 회귀 PASS 확인 (단위 +0) |
| 탭 ARIA `aria-selected` | 1 신규 assertion |
| `loadExpired` 제거 | 회귀 (test 같이 제거) |
| AiWorker `Promise.all` 회귀 | 회귀 PASS |
| recall IPC `on` migration | 1 신규 (`ipcRenderer.send` 검증) |
| Telemetry `.catch` debug log | 회귀 PASS |
목표: 단위 710 → **약 720** (+10 신규, -2 제거 [`loadExpired` test], net +8). typecheck 0.
---
## 8. Risk
| Risk | 대응 |
|---|---|
| `NoteRepository.create(now)` signature 변경 호출자 영향 | optional + default = `new Date()`. 기존 호출자 0 무영향. typecheck 가 누락 catch |
| KST inline → import 회귀 (값 다른 정의) | canonical export 값 (9 \* 60 \* 60 \* 1000) 동일 검증. 기존 단위 회귀 PASS = 알고리즘 동일 |
| `ipcMain.handle → on` migration 시 return value 의존 호출자 누락 | grep 으로 호출자 enumerated. preload 시그니처 그대로 (`Promise<void>`) — 호출 측 무수정 |
| AiWorker `Promise.all` 으로 emit 순서 변경 | telemetry 파일 라인 순서 의존 단위 0 확인. file-append round-trip 만 줄어듦 |
| PII 마스킹으로 디버그 어려움 | error class enum + production reproduce 시 dev 환경에서 stack 그대로 노출 (telemetry 만 마스킹) |
| time-dependent fix 시 다른 시간 의존 단위 발견 | grep `new Date()` in `repo` methods → `create` 외 다른 메서드 audit. 발견 시 spec 갱신 X (cleanup cut 외 deferred) |
---
## 9. v0.3.2 후
**Cut G** (v0.3.3 또는 v0.4.0 — F25 사이드바 + notebook_id) 진입 전:
- v0.3.2 release → main → tag → Windows exe + Gitea release
- macOS host 핸드오프: dist:mac dmg + dist:linux AppImage/deb (v0.3.0 + v0.3.1 + v0.3.2 누적 backlog)
- **다기기 종합 dogfood ≥1주 soak**:
- sync (Cut E): 충돌 빈도 / 인증 흐름 / interval 적정성 / NTP 단조 가정
- vision (Cut F): 이미지 capture 빈도 / 한국어 정확도 / capability detection 정확도
- cleanup (Cut 본 v0.3.2): 잠재 bug 회귀 X 확인
- soak 후 신규 발견 + data-dependent 9건 일괄 triage → Cut G brainstorm 진입
**Cut G 가설** (재검증):
1. inbox 단일 view 의 정보 밀도 한계 (현재 노트 누적 N건 = 스크롤 부담)
2. notebook 카테고리 = 분류 hint vs 단일 inbox + 태그 필터로 충분
3. 사이드바 = 새 surface = 기존 정책 (트레이 deemphasis / SettingsPage 우선) 재고

View File

@@ -3,9 +3,9 @@
> 누적 backlog. v0.2.3 cut (7항목 / PR #13~#19) 시점부터 PR review deferred + dogfood 발견 모두 합산. **파일명은 historic** (`v024-backlog.md`) — v0.2.4 ~ v0.2.6 cut 후에도 이어 사용. **v0.2.7 brainstorm 시** 신규 피드백 + 잔여 일괄 triage.
**누적 시작일:** 2026-05-01 (#7 telemetry skeleton 머지 시점)
**최종 갱신:** 2026-05-07 (v0.2.7 cross-platform cut — #45 자동실행 deeper fix)
**최종 갱신:** 2026-05-10 (v0.3.2 cleanup cut — 잠재 bug 4 + cosmetic 5 + #20 deferred)
**총 항목 수:** 46 (#1 stale 포함)
**잔여:** 23건 (=46 처리 22 stale 1)
**잔여:** 14건 (=46 처리 31 stale 1)
## 처리 이력 / 진행 흐름
@@ -39,6 +39,21 @@
|---|---|---|
| **B1 production path** (CaptureService.restoreNote 가 옛 `repo.restore` 호출) | ✅ Critical fix (commit `a991008`) | v0.2.6 round 1 |
### v0.3.2 cleanup cut (2026-05-10)
| 항목 | 상태 | Cut |
|---|---|---|
| #14 (탭 ARIA `aria-pressed``role="tab"`) | ✅ 처리 | v0.3.2 |
| #18 (`loadExpired` 미사용 제거) | ✅ 처리 | v0.3.2 |
| #19 (KST inline 5 callsite 잔여 migrate) | ✅ 처리 | v0.3.2 |
| #20 (telemetry `.catch` silent → debug log) | 🟡 deferred (CaptureService logger 미주입 — constructor 변경 회피) | v0.3.2 audit |
| #31 (vocabSet COLLATE NOCASE 정합) | ✅ 처리 | v0.3.2 |
| #32 (per-tag emit `Promise.all` 병렬화) | ✅ 처리 | v0.3.2 |
| #36 (recall IPC `handle``on`) | ✅ 처리 | v0.3.2 |
| #39 (`ollama_unreachable.reason` PII 마스킹) | ✅ 처리 | v0.3.2 |
| #41+#42 (`OllamaSettingsModal` 폐기 audit) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
| time-dependent test flake fix | ✅ 처리 | v0.3.2 |
### v0.2.6 final reviewer + round 1 minors (deferred)
| 항목 | 상태 |

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "inkling",
"version": "0.3.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "inkling",
"version": "0.3.0",
"version": "0.3.1",
"dependencies": {
"better-sqlite3": "12.9.0",
"electron-log": "5.2.0",
@@ -3232,7 +3232,7 @@
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"dev": true,

View File

@@ -1,6 +1,6 @@
{
"name": "inkling",
"version": "0.3.0",
"version": "0.3.3",
"private": true,
"description": "Inkling — local-first 한 줄 보관 도구",
"author": "altair823 <dlsrks0734@gmail.com>",

View File

@@ -1,6 +1,9 @@
import { readFile } from 'node:fs/promises';
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { Note } from '@shared/types';
import type { AiFailedReason } from '../services/telemetryEvents.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { MediaStore } from '../services/MediaStore.js';
import { ProviderHolder } from './ProviderHolder.js';
import { parseAllCandidates } from '../services/dueDateParser.js';
import { ZodError } from 'zod';
@@ -41,6 +44,10 @@ export interface AiWorkerOptions {
};
now?: () => Date;
telemetry?: AiTelemetryEmitter;
/** v0.3.1 Cut F — vision 지원. 미전달 시 vision 비활성. */
settings?: Pick<SettingsService, 'getVisionModel'>;
/** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */
mediaStore?: Pick<MediaStore, 'absolutePath'>;
}
interface Job { noteId: string; attempts: number; }
@@ -56,6 +63,8 @@ export class AiWorker {
private logger: NonNullable<AiWorkerOptions['logger']>;
private now: () => Date;
private telemetry?: AiTelemetryEmitter;
private settings?: Pick<SettingsService, 'getVisionModel'>;
private mediaStore?: Pick<MediaStore, 'absolutePath'>;
constructor(
private repo: NoteRepository,
@@ -68,6 +77,8 @@ export class AiWorker {
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
this.now = opts.now ?? (() => new Date());
this.telemetry = opts.telemetry;
this.settings = opts.settings;
this.mediaStore = opts.mediaStore;
}
async enqueue(noteId: string): Promise<void> {
@@ -128,12 +139,27 @@ export class AiWorker {
const todayIso = kstTodayIso(nowDate);
const candidates = parseAllCandidates(note.rawText, todayDate);
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
const res = await this.holder.get().generate({
text: note.rawText,
todayKst: todayIso,
dueDateCandidates: candidates,
vocab
});
// v0.3.1 Cut F — vision path: visionModel + note.media → base64 images
// final review fix: note.media[].bytes 로 fast-fail (readFile/base64 비용 회피).
// 5MB cap 초과 시 throw → AiWorker 의 'other' 분기 → markAiFailed 도달.
const visionModel = this.settings ? await this.settings.getVisionModel() : null;
let images: Array<{ base64: string; mime: string }> | undefined;
if (visionModel && note.media.length > 0 && this.mediaStore) {
const oversize = note.media.find((m) => m.bytes > 5 * 1024 * 1024);
if (oversize) {
throw new Error(`image ${oversize.relPath} exceeds 5MB cap (${oversize.bytes} bytes)`);
}
images = await Promise.all(
note.media.map(async (m) => {
const buf = await readFile(this.mediaStore!.absolutePath(m.relPath));
return { base64: buf.toString('base64'), mime: m.mime };
})
);
}
const res = await this.holder.get().generate(
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
{ visionModel: visionModel ?? undefined }
);
// AI primary: AI's dueDate is final (no rule merge)
this.repo.updateAiResult(job.noteId, {
title: res.title,
@@ -150,7 +176,8 @@ export class AiWorker {
candidatesCount: candidates.length
});
if (this.telemetry) {
await this.telemetry.emit({
const telemetry = this.telemetry;
await telemetry.emit({
kind: 'ai_succeeded',
payload: {
noteId: job.noteId,
@@ -160,23 +187,25 @@ export class AiWorker {
}).catch(() => {});
// v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장)
// dedup: AI 응답에 같은 태그 중복 가능 — INSERT OR IGNORE 와 정합한 1-emit/태그 보장
const vocabSet = new Set(vocab);
for (const tagName of new Set(res.tags)) {
if (vocabSet.has(tagName)) {
const tagId = this.repo.getTagIdByName(tagName);
if (tagId !== null) {
await this.telemetry.emit({
kind: 'tag_vocab_hit',
payload: { tagId, vocabSize: vocab.length }
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
await Promise.all(
Array.from(new Set(res.tags)).map(async (tagName) => {
if (vocabSet.has(tagName.toLowerCase())) {
const tagId = this.repo.getTagIdByName(tagName);
if (tagId !== null) {
await telemetry.emit({
kind: 'tag_vocab_hit',
payload: { tagId, vocabSize: vocab.length }
}).catch(() => {});
}
} else {
await telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
} else {
await this.telemetry.emit({
kind: 'tag_vocab_miss',
payload: { vocabSize: vocab.length }
}).catch(() => {});
}
}
})
);
}
this.emit(job.noteId);
return;

View File

@@ -6,13 +6,20 @@ export interface GenerateInput {
todayKst: string; // ISO YYYY-MM-DD in KST
dueDateCandidates: ParseResult[];
vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리.
// v0.3.1 Cut F — 첨부 이미지. 미전달 시 텍스트 전용 처리.
images?: Array<{ base64: string; mime: string }>;
}
export interface GenerateOptions {
/** v0.3.1 Cut F — vision 전용 model 지정. null/미전달 시 기본 model 사용. */
visionModel?: string | null;
}
export interface HealthResult { ok: boolean; model?: string; reason?: string; }
export interface InferenceProvider {
readonly name: string;
generate(input: GenerateInput): Promise<AiResponse>;
generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse>;
healthCheck(): Promise<HealthResult>;
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
abort?: () => void;

View File

@@ -1,9 +1,18 @@
import { request } from 'undici';
import { parseAiResponse, type AiResponse } from './schema.js';
import { buildPrompt } from './prompt.js';
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';
import { buildVisionPrompt } from './visionPrompt.js';
import type { GenerateInput, GenerateOptions, HealthResult, InferenceProvider } from './InferenceProvider.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';
function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' {
const msg = ((e as Error)?.message ?? '').toLowerCase();
if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
return 'other';
}
export interface LocalOllamaOptions {
endpoint?: string;
model?: string;
@@ -30,29 +39,39 @@ export class LocalOllamaProvider implements InferenceProvider {
this.name = `local-ollama/${this.model}`;
}
async generate(input: GenerateInput): Promise<AiResponse> {
async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
const model = useVision ? opts!.visionModel! : this.model;
const prompt = useVision
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates.map((c) => c.iso ?? c.matchedToken ?? ''), input.vocab ?? [])
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
this.abortController = new AbortController();
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
try {
const body: Record<string, unknown> = {
model,
prompt,
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
};
if (useVision) {
body.images = input.images!.map((i) => i.base64);
}
const res = await request(`${this.endpoint}/api/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: this.model,
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
format: 'json',
stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict }
}),
body: JSON.stringify(body),
signal: this.abortController.signal
});
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error(`ollama http ${res.statusCode}`);
}
const body = (await res.body.json()) as { response?: string };
if (!body.response) throw new Error('missing response field');
const responseBody = (await res.body.json()) as { response?: string };
if (!responseBody.response) throw new Error('missing response field');
let parsed: unknown;
try { parsed = JSON.parse(body.response); }
try { parsed = JSON.parse(responseBody.response); }
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
return parseAiResponse(parsed);
} finally {
@@ -108,7 +127,8 @@ export class LocalOllamaProvider implements InferenceProvider {
return found ? { ok: true, model: this.model }
: { ok: false, reason: `${this.model} not installed` };
} catch (err) {
return { ok: false, reason: `unreachable: ${(err as Error).message}` };
const cls = classifyFetchError(err);
return { ok: false, reason: `unreachable:${cls}` };
}
}
}

View File

@@ -0,0 +1,17 @@
export function buildVisionPrompt(
text: string,
todayKst: string,
dueCandidates: string[],
vocab: string[]
): string {
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
메모 본문 (비어 있을 수 있음):
${text || '(이미지만 있음)'}
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
오늘: ${todayKst}
가능한 due 후보: ${dueCandidates.join(', ')}
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
}

View File

@@ -17,6 +17,7 @@ import { HealthChecker } from './services/HealthChecker.js';
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
import { ProviderHolder } from './ai/ProviderHolder.js';
import { AiWorker } from './ai/AiWorker.js';
import { refreshVisionCache } from './services/VisionDetect.js';
import { registerCaptureApi } from './ipc/captureApi.js';
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
import { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js';
@@ -122,6 +123,11 @@ app.whenReady().then(async () => {
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
const providerHolder = new ProviderHolder(provider);
// v0.3.1 Cut F — app launch 시 vision capability cache 갱신 (fire-and-forget).
// 실패 silent (cache 유지). 사용자가 설정 페이지에서 "다시 감지" manual trigger 가능.
void refreshVisionCache({ settings: settingsSvc, endpoint: resolvedEndpoint }).catch(() => {});
const health = new HealthChecker(providerHolder, {
// v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향).
isAiEnabled: () => settingsSvc.isAiEnabled(),
@@ -149,7 +155,10 @@ app.whenReady().then(async () => {
refreshTray({ todayCount: repo.countToday() });
},
logger,
telemetry
telemetry,
// v0.3.1 Cut F — vision 지원
settings: settingsSvc,
mediaStore: store
});
const notify = new NotificationService({

View File

@@ -153,8 +153,8 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
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.on('inbox:emitRecallShown', (_e, id: string) => { void deps.capture.emitRecallShown(id); });
ipcMain.on('inbox:emitRecallSnoozed', (_e, id: string) => { void deps.capture.emitRecallSnoozed(id); });
ipcMain.handle('inbox:loadOllamaSettings', async () => {
const s = await deps.settings.load();

View File

@@ -1,6 +1,7 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
import { platform, release, EOL } from 'node:os';
import { mkdir } from 'node:fs/promises';
const { ipcMain, app, dialog, Notification, shell, clipboard } = electron;
import { logger } from '../logger.js';
import type { BackupService } from '../services/BackupService.js';
@@ -13,6 +14,8 @@ 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';
import { DEFAULT_OLLAMA_ENDPOINT } from '../../shared/constants.js';
/**
* 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을
@@ -322,6 +325,11 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
// git init + remote add origin
const syncDir = deps.syncSvc.getSyncDir();
try {
await mkdir(syncDir, { recursive: true });
} catch (e) {
return { ok: false as const, reason: `mkdir failed: ${(e as Error).message}` };
}
const git = new GitClient(syncDir);
if (!(await git.isRepo())) {
@@ -378,4 +386,29 @@ 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 () => {
// Cut F final review fix — index.ts 의 resolvedEndpoint (settings → env → default)
// 와 동일한 fallback 체인 사용. settings.ollama 미설정 + env / default 만 있는 dev
// 환경에서도 manual "다시 감지" 가 동작하도록.
const all = await deps.settings.getAll();
const endpoint = all.ollama?.endpoint
?? process.env.INKLING_OLLAMA_ENDPOINT
?? DEFAULT_OLLAMA_ENDPOINT;
return refreshVisionCache({ settings: deps.settings, endpoint });
});
}

View File

@@ -1,7 +1,7 @@
import type Database from 'better-sqlite3';
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
import { kstTodayIso } from '../../shared/util/kstDate.js';
import { kstTodayIso, KST_OFFSET_MS } from '../../shared/util/kstDate.js';
import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';
export interface CreateNoteInput {
@@ -79,25 +79,25 @@ const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
export class NoteRepository {
constructor(private db: Database.Database) {}
create(input: CreateNoteInput): { id: string } {
create(input: CreateNoteInput, now: Date = new Date()): { id: string } {
const id = uuidv7();
const now = new Date().toISOString();
const ts = now.toISOString();
const aiStatus: AiStatus = input.aiStatus ?? 'pending';
const tx = this.db.transaction(() => {
this.db
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`)
.run(id, input.rawText, aiStatus, now, now);
.run(id, input.rawText, aiStatus, ts, ts);
this.db
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
VALUES (?, ?, ?, 'capture')`)
.run(id, input.rawText, now);
.run(id, input.rawText, ts);
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
if (aiStatus === 'pending') {
this.db
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
VALUES (?, 0, ?)`)
.run(id, now);
.run(id, ts);
}
});
tx();
@@ -1039,7 +1039,6 @@ export class NoteRepository {
* and count rows whose UTC ISO `created_at` lies inside.
*/
countToday(now: Date = new Date()): number {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
const kstYear = kstNow.getUTCFullYear();
const kstMonth = kstNow.getUTCMonth();

View File

@@ -2,6 +2,8 @@
* v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼.
*/
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
const FTS5_SPECIAL_CHARS_RE = /["*():]/g;
const WS_COLLAPSE_RE = /\s+/g;
@@ -15,8 +17,6 @@ export function sanitizeFtsQuery(input: string): string {
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/**
* 회고 cutoff = period 시작점의 KST 자정 (UTC ISO).
* daily = 오늘 0시, weekly = 7일 전 0시, monthly = 30일 전 0시.

View File

@@ -2,8 +2,7 @@ import type Database from 'better-sqlite3';
import { mkdir, rename, stat, readdir, unlink, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { applyGfsRetention } from './backupRotation.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
const MARKER_FILENAME = '.last-snapshot';
function toKstDateKey(d: Date): string {

View File

@@ -1,7 +1,6 @@
import type Database from 'better-sqlite3';
import type { WeeklyContinuity } from '@shared/types';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_TARGET = 7;
const RECOVERY_GAP_DAYS = 7;

View File

@@ -17,7 +17,11 @@ const SettingsSchema = z.object({
// v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성.
sync_repo_url: z.string().nullable().optional(),
sync_auto_enabled: z.boolean().optional(),
sync_interval_min: z.number().int().min(5).optional()
sync_interval_min: z.number().int().min(5).optional(),
// v0.3.1 Cut F
vision_model: z.string().nullable().optional(),
vision_capable_cache: z.array(z.string()).optional(),
vision_cache_at: z.string().optional()
}).strict();
export type Settings = z.infer<typeof SettingsSchema>;
@@ -127,6 +131,30 @@ export class SettingsService {
await this.persist(next);
}
/** v0.3.1 Cut F — 선택된 vision model. null = 미선택. */
async getVisionModel(): Promise<string | null> {
const s = await this.load();
return s.vision_model ?? null;
}
async setVisionModel(value: string | null): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_model: value };
await this.persist(next);
}
/** v0.3.1 Cut F — /api/tags 조회 결과 캐시. 기본 빈 배열 + null timestamp. */
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
const s = await this.load();
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
}
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
await this.persist(next);
}
private async persist(next: Settings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';

View File

@@ -0,0 +1,47 @@
import type { SettingsService } from './SettingsService.js';
// v0.3.1 Cut F final fix — gemma 시리즈 default 정정. 본인 dogfood 환경 = gemma4:e4b
// (텍스트). vision 변종은 gemma3 (현재 vision-capable) 또는 gemma4 (향후 출시 시).
// 양 family 모두 hint 에 포함 — capability detection 이 future-proof.
const VISION_FAMILIES = new Set(['gemma3', 'gemma4', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3', 'gemma4'];
export interface OllamaModel {
name: string;
details?: { family?: string; families?: string[] };
}
export function isVisionCapable(model: OllamaModel): boolean {
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
if (model.details?.families?.some((f) => VISION_FAMILIES.has(f))) return true;
const lower = model.name.toLowerCase();
return VISION_NAME_HINTS.some((h) => lower.includes(h));
}
export interface RefreshDeps {
settings: SettingsService;
endpoint: string;
now?: () => Date;
fetchImpl?: typeof fetch;
}
export async function refreshVisionCache(
deps: RefreshDeps
): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
if (!(await deps.settings.isAiEnabled())) {
return { ok: false, reason: 'ai_disabled' };
}
const fetchFn = deps.fetchImpl ?? fetch;
let body: { models?: OllamaModel[] };
try {
const r = await fetchFn(`${deps.endpoint}/api/tags`);
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
body = (await r.json()) as { models?: OllamaModel[] };
} catch (e) {
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
}
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
const now = deps.now ? deps.now() : new Date();
await deps.settings.setVisionCapableCache(capable, now);
return { ok: true, models: capable };
}

View File

@@ -43,8 +43,8 @@ const api: InklingApi = {
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
emitRecallShown: (id: string) => { ipcRenderer.send('inbox:emitRecallShown', id); },
emitRecallSnoozed: (id: string) => { ipcRenderer.send('inbox:emitRecallSnoozed', id); },
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
// v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener.
@@ -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

@@ -106,8 +106,10 @@ export function App(): React.ReactElement {
).map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={view === t.key}
onClick={() => setView(t.key)}
aria-pressed={view === t.key}
style={tabBtnStyle(view === t.key)}
>
{t.label}({t.count})

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import type { Note, NoteStatus } from '@shared/types';
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
import { inboxApi } from '../api.js';
import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
@@ -27,7 +28,6 @@ function isPastDue(iso: string, today: string): boolean {
}
function todayKstIso(): string {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const k = new Date(Date.now() + KST_OFFSET_MS);
return k.toISOString().slice(0, 10);
}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { z } from 'zod';
import { inboxApi } from '../../api.js';
import { VisionSection } from './VisionSection.js';
const endpointSchema = z.string().url();
@@ -192,6 +193,7 @@ export function AiProviderSection(): React.ReactElement {
{recheckResult && (
<div style={{ fontSize: 12, marginTop: 8 }}>{recheckResult}</div>
)}
<VisionSection />
</div>
);
}

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react';
import { inboxApi } from '../../api.js';
export function VisionSection(): React.ReactElement {
const [models, setModels] = useState<string[]>([]);
const [at, setAt] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [busy, setBusy] = useState<'select' | 'refresh' | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
async function load() {
const r = await inboxApi.getVisionModels();
setModels(r.models);
setAt(r.at);
setSelected(r.selected);
}
useEffect(() => {
void load();
}, []);
async function onSelect(value: string) {
const next = value === '' ? null : value;
setBusy('select');
setFeedback(null);
await inboxApi.setVisionModel(next);
setSelected(next);
setBusy(null);
}
async function onRefresh() {
setBusy('refresh');
setFeedback(null);
const r = await inboxApi.refreshVisionCache();
setBusy(null);
if (r.ok) {
await load();
setFeedback(`감지 완료 (${r.models.length}개)`);
} else {
setFeedback(`감지 실패: ${r.reason}`);
}
}
return (
<section style={{ marginTop: 16 }}>
<h4 style={{ fontSize: 13, marginBottom: 6 }}> ()</h4>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<select
aria-label="이미지 분석 모델"
value={selected ?? ''}
onChange={(e) => { void onSelect(e.target.value); }}
disabled={busy !== null}
style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }}
>
<option value="">()</option>
{models.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<button
onClick={() => { void onRefresh(); }}
disabled={busy !== null}
style={{ background: '#0a4b80', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4 }}
>
{busy === 'refresh' ? '감지 중…' : '다시 감지'}
</button>
</div>
{at !== null && (
<div style={{ fontSize: 11, color: '#888' }}>
: {new Date(at).toLocaleString('ko-KR')}
</div>
)}
{feedback !== null && (
<div style={{ fontSize: 11, color: '#444', marginTop: 4 }}>{feedback}</div>
)}
{models.length === 0 && (
<div style={{ fontSize: 11, color: '#aaa', marginTop: 4 }}>
. Ollama vision "다시 감지" .
</div>
)}
</section>
);
}

View File

@@ -58,7 +58,6 @@ interface InboxState {
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
loadExpired: () => Promise<void>;
trashExpiredBatch: (ids: string[]) => Promise<void>;
snoozeExpired: () => void;
recheckOllama: () => Promise<void>;
@@ -235,10 +234,6 @@ export const useInbox = create<InboxState>((set, get) => ({
set({ trashNotes: [], trashCount: 0 });
}
},
async loadExpired() {
const expiredCandidates = await inboxApi.listExpired();
set({ expiredCandidates });
},
async trashExpiredBatch(ids: string[]) {
const r = await inboxApi.trashExpiredBatch(ids);
if (!r.confirmed) return;
@@ -285,7 +280,7 @@ export const useInbox = create<InboxState>((set, get) => ({
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
const candidate = get().recallCandidate;
if (candidate) {
await inboxApi.emitRecallSnoozed(candidate.id);
inboxApi.emitRecallSnoozed(candidate.id);
}
},
// v0.2.11 Cut D — FTS5 search + review aggregate actions.

View File

@@ -152,8 +152,8 @@ export interface InboxApi {
listRecallCandidate(): Promise<Note | null>;
markRecallOpened(id: string): Promise<{ note: Note }>;
dismissRecall(id: string): Promise<{ note: Note }>;
emitRecallShown(id: string): Promise<void>;
emitRecallSnoozed(id: string): Promise<void>;
emitRecallShown(id: string): void;
emitRecallSnoozed(id: string): void;
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
// v0.2.7 Task 13 — 외부 (트레이 등) 에서 view 전환 요청 구독.
@@ -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

@@ -11,7 +11,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
getSettings: vi.fn(async () => ({ ai_enabled: true })),
setAiEnabled: vi.fn(async () => ({ ok: true })),
getDisabledCount: vi.fn(async () => 0),
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
setVisionModel: vi.fn(async () => ({ ok: true as const })),
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
}
}));

View File

@@ -449,9 +449,10 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
});
await w.enqueue(id);
await w.drain();
expect(generateMock).toHaveBeenCalledWith(expect.objectContaining({
vocab: expect.arrayContaining(['design'])
}));
expect(generateMock).toHaveBeenCalledWith(
expect.objectContaining({ vocab: expect.arrayContaining(['design']) }),
expect.anything()
);
});
it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => {
@@ -559,3 +560,91 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
expect(miss).toHaveLength(1); // 'meeting' 1 miss
});
});
describe('vocab COLLATE NOCASE', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('hits when vocab has lowercase and AI returns capital', async () => {
// Pre-seed: 'design' in vocab (lowercase)
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['Design'], // AI returns capitalized — DB COLLATE NOCASE matches 'design'
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
});
await w.enqueue(id);
await w.drain();
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
});
it('hits when vocab has capital and AI returns lowercase', async () => {
// Scenario: vocab contains 'Design' (capital), AI returns 'design' (lowercase).
// getTopUsedTags filters via KEBAB_CASE_RE (/^[a-z0-9-]+$/) so 'Design' would be
// stripped in production. We stub getTopUsedTags to inject the capital vocab directly,
// and pre-seed the DB so getTagIdByName (COLLATE NOCASE) can resolve 'design' → tagId.
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['Design'], provider: 'p' });
// Inject capital vocab bypassing the kebab filter
vi.spyOn(repo, 'getTopUsedTags').mockReturnValueOnce(['Design']);
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design'], // AI returns lowercase — DB COLLATE NOCASE matches 'Design'
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
});
await w.enqueue(id);
await w.drain();
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
});
it('still hits when both vocab and AI tag are same lowercase (regression)', async () => {
// Pre-seed: 'design' in vocab (lowercase)
const seed = repo.create({ rawText: 'seed' }).id;
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => ({
title: 't', summary: 'a\nb\nc',
tags: ['design'], // same lowercase — should still hit
dueDate: null
}))
});
const emits: EmittedEvent[] = [];
const w = new AiWorker(repo, new ProviderHolder(provider), {
backoffsMs: [0, 0, 0],
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
});
await w.enqueue(id);
await w.drain();
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { AiWorker } from '@main/ai/AiWorker.js';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
import { MediaStore } from '@main/services/MediaStore.js';
import type { AiResponse } from '@main/ai/schema.js';
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
let db: Database.Database;
let repo: NoteRepository;
let workDir: string;
let mediaStore: MediaStore;
beforeEach(async () => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
repo = new NoteRepository(db);
workDir = await mkdtemp(join(tmpdir(), 'inkling-vision-'));
mediaStore = new MediaStore(workDir);
});
afterEach(async () => {
db.close();
await rm(workDir, { recursive: true, force: true });
});
function makeWorker(
generate: (input: Parameters<InferenceProvider['generate']>[0], opts?: Parameters<InferenceProvider['generate']>[1]) => Promise<AiResponse>,
getVisionModel: () => Promise<string | null>
): AiWorker {
const provider: InferenceProvider = {
name: 'fake',
generate,
abort: () => {},
healthCheck: vi.fn(async () => ({ ok: true }))
};
const holder = new ProviderHolder(provider);
const settings = { getVisionModel };
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return new AiWorker(repo, holder, {
backoffsMs: [0, 0, 0],
logger,
settings,
mediaStore,
now: () => new Date('2026-05-10T05:00:00Z')
});
}
it('visionModel + media 있음 → provider.generate 가 images + opts 받음', async () => {
const { id } = repo.create({ rawText: '이미지 메모' });
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
const generate = vi.fn(async (
input: Parameters<InferenceProvider['generate']>[0],
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
const worker = makeWorker(generate, getVisionModel);
await worker.enqueue(id);
await worker.drain();
expect(calls.length).toBeGreaterThan(0);
const [callInput, callOpts] = calls[0]!;
expect(callInput.images).toHaveLength(1);
expect(callInput.images![0]!.mime).toBe('image/png');
expect(callOpts?.visionModel).toBe('gemma3:12b-vision');
});
it('visionModel null이면 text-only (images undefined)', async () => {
const { id } = repo.create({ rawText: 'just text' });
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
const generate = vi.fn(async (
input: Parameters<InferenceProvider['generate']>[0],
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => null);
const worker = makeWorker(generate, getVisionModel);
await worker.enqueue(id);
await worker.drain();
expect(calls.length).toBeGreaterThan(0);
expect(calls[0]![0].images).toBeUndefined();
});
it('5MB 초과 이미지 → throw → AiWorker 의 fail 분기 (generate 미호출)', async () => {
const { id } = repo.create({ rawText: 'big image' });
await mkdir(join(workDir, 'media', id), { recursive: true });
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.alloc(6 * 1024 * 1024));
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 6 * 1024 * 1024 }]);
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
const generate = vi.fn(async (
input: Parameters<InferenceProvider['generate']>[0],
opts?: Parameters<InferenceProvider['generate']>[1]
): Promise<AiResponse> => {
calls.push([input, opts]);
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
});
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
const worker = makeWorker(generate, getVisionModel);
await worker.enqueue(id);
await worker.drain();
expect(calls.length).toBe(0);
// AiWorker catch 분기가 처리 — note 는 여전히 DB 에 존재
const note = repo.findById(id);
expect(note).toBeTruthy();
});
});

View File

@@ -62,7 +62,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
configureSync: vi.fn(async () => ({ ok: true as const })),
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
testSyncConnection: vi.fn(async () => ({ ok: true as const })),
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
setVisionModel: vi.fn(async () => ({ ok: true as const })),
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
}
}));
@@ -126,25 +130,31 @@ describe('App header — 4 tabs', () => {
it('renders 4 tabs with counts', async () => {
render(<App />);
expect(await screen.findByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /완료\(3\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /보관\(2\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /휴지통\(1\)/ })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: /Inbox\(5\)/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /완료\(3\)/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /보관\(2\)/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /휴지통\(1\)/ })).toBeInTheDocument();
});
it('clicking 완료 tab sets view=completed', async () => {
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: /완료/ }));
fireEvent.click(await screen.findByRole('tab', { name: /완료/ }));
expect(useInbox.getState().view).toBe('completed');
});
it('aria-pressed reflects current view', async () => {
it('aria-selected reflects current view', async () => {
useInbox.setState({ view: 'archived' });
render(<App />);
const archivedBtn = await screen.findByRole('button', { name: /보관/ });
expect(archivedBtn.getAttribute('aria-pressed')).toBe('true');
const inboxBtn = screen.getByRole('button', { name: /Inbox/ });
expect(inboxBtn.getAttribute('aria-pressed')).toBe('false');
const archivedBtn = await screen.findByRole('tab', { name: /보관/ });
expect(archivedBtn.getAttribute('aria-selected')).toBe('true');
const inboxBtn = screen.getByRole('tab', { name: /Inbox/ });
expect(inboxBtn.getAttribute('aria-selected')).toBe('false');
});
it('inbox tab has aria-selected="true" when active', async () => {
render(<App />);
const inboxTab = await screen.findByRole('tab', { name: /Inbox/ });
expect(inboxTab).toHaveAttribute('aria-selected', 'true');
});
});
@@ -171,7 +181,7 @@ describe('App — onboarding wizard', () => {
it('does not render OnboardingWizard when onboarding_completed=true', async () => {
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
render(<App />);
await screen.findByRole('button', { name: /Inbox/ });
await screen.findByRole('tab', { name: /Inbox/ });
expect(screen.queryByText(/Inkling 사용 시작/)).toBeNull();
});
});

View File

@@ -34,6 +34,12 @@ function buildExportNote(overrides: Partial<ExportNote> = {}): ExportNote {
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
userIntent: null,
intentPromptedAt: null,
// v0.3.0 Cut E — frontmatter round-trip 5 필드 (Cut B status + Cut C dueDate).
status: 'active',
statusChangedAt: null,
moveReason: null,
dueDate: null,
dueDateEditedByUser: false,
tags: [{ name: 'pr', source: 'ai' }],
media: [],
...overrides

View File

@@ -109,4 +109,96 @@ describe('LocalOllamaProvider', () => {
const provider = new LocalOllamaProvider({ model: 'gemma4:26b' });
expect(provider.name).toBe('local-ollama/gemma4:26b');
});
describe('healthCheck PII reason masking', () => {
it('classifies ECONNREFUSED as network', async () => {
mock.get('http://192.168.1.5:11434').intercept({ path: '/api/tags', method: 'GET' })
.replyWithError(new Error('connect ECONNREFUSED 192.168.1.5:11434'));
const provider = new LocalOllamaProvider({ endpoint: 'http://192.168.1.5:11434' });
const h = await provider.healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toBe('unreachable:network');
expect(h.reason).not.toContain('192.168.1.5');
});
it('classifies AbortError/timeout as timeout', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
.replyWithError(new Error('The operation was aborted due to timeout'));
const h = await new LocalOllamaProvider().healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toBe('unreachable:timeout');
});
it('classifies ENOTFOUND as dns', async () => {
mock.get('http://nonexistent.local:11434').intercept({ path: '/api/tags', method: 'GET' })
.replyWithError(new Error('getaddrinfo ENOTFOUND nonexistent.local'));
const provider = new LocalOllamaProvider({ endpoint: 'http://nonexistent.local:11434' });
const h = await provider.healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toBe('unreachable:dns');
expect(h.reason).not.toContain('nonexistent.local');
});
it('falls back to other for unknown errors', async () => {
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
.replyWithError(new Error('something weird happened'));
const h = await new LocalOllamaProvider().healthCheck();
expect(h.ok).toBe(false);
expect(h.reason).toBe('unreachable:other');
});
});
describe('vision path (v0.3.1 Cut F)', () => {
it('visionModel + images → body.images + model=visionModel + buildVisionPrompt', async () => {
let capturedBody: string = '';
mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
capturedBody = opts.body as string;
return { statusCode: 200, data: JSON.stringify({
response: JSON.stringify({ title: '비전테스트', summary: 'a\nb\nc', tags: [], due_date: null })
}) };
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate(
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [], images: [{ base64: 'AAAA', mime: 'image/png' }] },
{ visionModel: 'gemma3:12b-vision' }
);
const parsed = JSON.parse(capturedBody) as { model: string; prompt: string; images?: string[] };
expect(parsed.model).toBe('gemma3:12b-vision');
expect(parsed.prompt).toContain('이미지');
expect(parsed.images).toEqual(['AAAA']);
});
it('visionModel 있어도 images 없으면 text-only (model = this.model, no body.images)', async () => {
let capturedBody: string = '';
mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
capturedBody = opts.body as string;
return { statusCode: 200, data: JSON.stringify({
response: JSON.stringify({ title: '텍스트전용', summary: 'a\nb\nc', tags: [], due_date: null })
}) };
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate(
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] },
{ visionModel: 'gemma3:12b-vision' }
);
const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] };
expect(parsed.model).toBe('gemma4:e4b');
expect(parsed.images).toBeUndefined();
});
it('opts 미전달 → 기존 text-only (회귀)', async () => {
let capturedBody: string = '';
mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
capturedBody = opts.body as string;
return { statusCode: 200, data: JSON.stringify({
response: JSON.stringify({ title: '기본텍스트', summary: 'a\nb\nc', tags: [], due_date: null })
}) };
});
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
await provider.generate({ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] });
const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] };
expect(parsed.model).toBe('gemma4:e4b');
expect(parsed.images).toBeUndefined();
});
});
});

View File

@@ -310,6 +310,24 @@ describe('NoteRepository', () => {
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
expect(job).toBeDefined();
});
it('create accepts explicit now param', () => {
const fixed = new Date('2026-05-09T10:00:00.000Z');
const { id } = repo.create({ rawText: 'hello' }, fixed);
const note = repo.findById(id)!;
expect(note.createdAt).toBe('2026-05-09T10:00:00.000Z');
expect(note.updatedAt).toBe('2026-05-09T10:00:00.000Z');
});
it('create defaults now to new Date when omitted', () => {
const before = Date.now();
const { id } = repo.create({ rawText: 'hello' });
const after = Date.now();
const note = repo.findById(id)!;
const ts = new Date(note.createdAt).getTime();
expect(ts).toBeGreaterThanOrEqual(before);
expect(ts).toBeLessThanOrEqual(after);
});
});
describe('NoteRepository.trash', () => {

View File

@@ -52,7 +52,7 @@ describe('NoteRepository.upsertFromSync', () => {
});
it('id 있음 + raw_text 동일 + source 더 최신 → metadata 갱신 (status=updated)', () => {
const created = repo.create({ rawText: 'sync 본문' });
const created = repo.create({ rawText: 'sync 본문' }, new Date('2026-05-09T00:00:00Z'));
repo.updateAiResult(created.id, { title: '옛 제목', summary: '옛 요약', tags: ['old'], provider: 'p' });
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
const r = repo.upsertFromSync({ ...baseInput, id: created.id });
@@ -75,7 +75,7 @@ describe('NoteRepository.upsertFromSync', () => {
});
it('id 있음 + raw_text 다름 + source 더 최신 → updateRawText (status=updated) + new user revision', () => {
const created = repo.create({ rawText: 'old text' });
const created = repo.create({ rawText: 'old text' }, new Date('2026-05-09T00:00:00Z'));
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'new sync text' });
expect(r.status).toBe('updated');

View File

@@ -18,7 +18,8 @@ describe('NoteRepository — note_revisions', () => {
describe('updateRawText', () => {
it('notes.raw_text 갱신 + 새 user revision INSERT (single transaction)', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
const t = new Date('2026-05-10T00:00:00Z');
repo.updateRawText(id, 'v2', t);
@@ -41,7 +42,8 @@ describe('NoteRepository — note_revisions', () => {
});
it('atomic: 두 번 호출 시 두 revision 모두 누적 (chain history)', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
const revs = db
@@ -53,7 +55,8 @@ describe('NoteRepository — note_revisions', () => {
describe('listRevisions', () => {
it('DESC 순서 + edited_by + camelCase hydrate', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
@@ -73,7 +76,8 @@ describe('NoteRepository — note_revisions', () => {
describe('restoreRevision', () => {
it('옛 raw_text 를 새 user revision 으로 INSERT + notes.raw_text 갱신', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
@@ -101,7 +105,8 @@ describe('NoteRepository — note_revisions', () => {
describe('AiWorker source 회귀', () => {
it('updateRawText 후 findById 가 latest raw_text 반환 (옛 revision 미노출)', () => {
const { id } = repo.create({ rawText: 'v1' });
const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
repo.updateRawText(id, 'v2 corrected', new Date('2026-05-10T00:00:00Z'));
const note = repo.findById(id);
expect(note?.rawText).toBe('v2 corrected');

View File

@@ -52,7 +52,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
configureSync: vi.fn(async () => ({ ok: true as const })),
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
testSyncConnection: vi.fn(async () => ({ ok: true as const })),
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
setVisionModel: vi.fn(async () => ({ ok: true as const })),
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
}
}));

View File

@@ -90,4 +90,31 @@ describe('SettingsService', () => {
await expect(svc.setSyncIntervalMin(10.5)).rejects.toThrow();
});
});
describe('v0.3.1 Cut F — vision settings', () => {
it('getVisionModel() defaults to null', async () => {
expect(await svc.getVisionModel()).toBeNull();
});
it('setVisionModel() / getVisionModel() round-trip including null clear', async () => {
await svc.setVisionModel('llava:13b');
expect(await svc.getVisionModel()).toBe('llava:13b');
await svc.setVisionModel(null);
expect(await svc.getVisionModel()).toBeNull();
});
it('getVisionCapableCache() defaults to empty models + null at', async () => {
const cache = await svc.getVisionCapableCache();
expect(cache.models).toEqual([]);
expect(cache.at).toBeNull();
});
it('setVisionCapableCache() persists models + ISO timestamp', async () => {
const now = new Date('2026-05-09T12:00:00.000Z');
await svc.setVisionCapableCache(['llava:13b', 'llava:7b'], now);
const cache = await svc.getVisionCapableCache();
expect(cache.models).toEqual(['llava:13b', 'llava:7b']);
expect(cache.at).toBe('2026-05-09T12:00:00.000Z');
});
});
});

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from 'vitest';
import { isVisionCapable, refreshVisionCache } from '@main/services/VisionDetect.js';
import type { OllamaModel } from '@main/services/VisionDetect.js';
// ---------------------------------------------------------------------------
// isVisionCapable
// ---------------------------------------------------------------------------
describe('isVisionCapable', () => {
it('returns true when details.family is in VISION_FAMILIES', () => {
const model: OllamaModel = { name: 'some-model', details: { family: 'llava' } };
expect(isVisionCapable(model)).toBe(true);
});
it('returns true when details.families contains a vision family', () => {
const model: OllamaModel = { name: 'some-model', details: { families: ['text', 'minicpm-v'] } };
expect(isVisionCapable(model)).toBe(true);
});
it('returns true when name contains a vision hint (case-insensitive)', () => {
const model: OllamaModel = { name: 'My-Vision-Model:latest' };
expect(isVisionCapable(model)).toBe(true);
});
it('returns true when name contains "vl" hint', () => {
const model: OllamaModel = { name: 'qwen2-vl:7b' };
expect(isVisionCapable(model)).toBe(true);
});
it('returns false for a plain text model with no vision signals', () => {
const model: OllamaModel = { name: 'gemma2:9b', details: { family: 'gemma', families: ['gemma'] } };
expect(isVisionCapable(model)).toBe(false);
});
// v0.3.1 Cut F final fix — gemma family default 정정. gemma4 도 vision-capable hint.
it('returns true for gemma4 family (future-proof)', () => {
const model: OllamaModel = { name: 'gemma4-vision:e4b', details: { family: 'gemma4' } };
expect(isVisionCapable(model)).toBe(true);
});
it('returns true for gemma4 in name hints (no family)', () => {
const model: OllamaModel = { name: 'custom-gemma4:latest' };
expect(isVisionCapable(model)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// refreshVisionCache
// ---------------------------------------------------------------------------
describe('refreshVisionCache', () => {
function makeSettings(overrides: Partial<{
isAiEnabled: boolean;
setCalled: { models: string[]; at: Date } | null;
}> = {}) {
const setCalled: { models: string[]; at: Date } | null = null;
const settings = {
isAiEnabled: vi.fn().mockResolvedValue(overrides.isAiEnabled ?? true),
setVisionCapableCache: vi.fn().mockImplementation(async () => undefined),
};
return settings;
}
it('returns ok:false with reason "ai_disabled" when AI is off', async () => {
const settings = makeSettings({ isAiEnabled: false });
const result = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
});
expect(result).toEqual({ ok: false, reason: 'ai_disabled' });
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
});
it('returns ok:false with http reason on non-ok response', async () => {
const settings = makeSettings();
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 503 });
const result = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
fetchImpl: fetchImpl as never,
});
expect(result).toEqual({ ok: false, reason: 'tags http 503' });
});
it('returns ok:false with unreachable reason on fetch throw', async () => {
const settings = makeSettings();
const fetchImpl = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
const result = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
fetchImpl: fetchImpl as never,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/unreachable/);
});
it('filters vision-capable models, persists cache, returns ok:true + models', async () => {
const settings = makeSettings();
const fixedNow = new Date('2026-05-09T00:00:00.000Z');
const responseBody = {
models: [
{ name: 'llava:13b', details: { family: 'llava' } },
{ name: 'gemma2:9b', details: { family: 'gemma' } },
{ name: 'qwen2-vl:7b' },
],
};
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(responseBody),
});
const result = await refreshVisionCache({
settings: settings as never,
endpoint: 'http://localhost:11434',
fetchImpl: fetchImpl as never,
now: () => fixedNow,
});
expect(result).toEqual({ ok: true, models: ['llava:13b', 'qwen2-vl:7b'] });
expect(settings.setVisionCapableCache).toHaveBeenCalledWith(
['llava:13b', 'qwen2-vl:7b'],
fixedNow
);
});
});

View File

@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import React from 'react';
const { mockGet, mockSet, mockRefresh } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockSet: vi.fn(),
mockRefresh: vi.fn()
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
getVisionModels: mockGet,
setVisionModel: mockSet,
refreshVisionCache: mockRefresh
}
}));
import { VisionSection } from '../../src/renderer/inbox/components/settings/VisionSection';
describe('VisionSection', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
mockGet.mockResolvedValue({
models: ['gemma3:12b-vision', 'llava:13b'],
at: '2026-05-10T05:00:00Z',
selected: 'gemma3:12b-vision'
});
mockSet.mockResolvedValue({ ok: true });
mockRefresh.mockResolvedValue({ ok: true, models: ['gemma3:12b-vision', 'llava:13b'] });
});
it('open 시 cache 로드 + dropdown 옵션 표시 + 선택된 모델 default', async () => {
render(<VisionSection />);
await waitFor(() => {
expect(screen.getByLabelText('이미지 분석 모델')).toHaveValue('gemma3:12b-vision');
});
expect(screen.getByText('gemma3:12b-vision')).toBeInTheDocument();
expect(screen.getByText('llava:13b')).toBeInTheDocument();
expect(screen.getByText(/마지막 감지/)).toBeInTheDocument();
});
it('dropdown 변경 → setVisionModel 호출', async () => {
render(<VisionSection />);
await waitFor(() => screen.getByLabelText('이미지 분석 모델'));
fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: 'llava:13b' } });
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('llava:13b');
});
});
it('비활성 선택 → setVisionModel(null)', async () => {
render(<VisionSection />);
await waitFor(() => screen.getByLabelText('이미지 분석 모델'));
fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: '' } });
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith(null);
});
});
it('다시 감지 클릭 → refreshVisionCache 호출 + 결과 표시', async () => {
render(<VisionSection />);
await waitFor(() => screen.getByRole('button', { name: /다시 감지/ }));
fireEvent.click(screen.getByRole('button', { name: /다시 감지/ }));
await waitFor(() => {
expect(mockRefresh).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByText(/감지 완료/)).toBeInTheDocument();
});
});
});

View File

@@ -11,7 +11,8 @@ vi.mock('electron', () => ({
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
on: (_ch: string, _fn: unknown) => {}
},
dialog: {},
shell: { openPath: mockOpenPath }

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({
default: {
ipcMain: { handle: vi.fn() }
ipcMain: { handle: vi.fn(), on: vi.fn() }
}
}));

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() } } }));
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn(), on: vi.fn() } } }));
import electron from 'electron';
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';

View File

@@ -12,7 +12,8 @@ vi.mock('electron', () => ({
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
on: (_ch: string, _fn: unknown) => {}
},
dialog: {},
shell: {}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Track ipcMain.handle and ipcMain.on calls
const { handleCalls, onCalls } = vi.hoisted(() => ({
handleCalls: [] as string[],
onCalls: [] as string[]
}));
vi.mock('electron', () => ({
default: {
ipcMain: {
handle: (ch: string, _fn: unknown) => { handleCalls.push(ch); },
on: (ch: string, _fn: unknown) => { onCalls.push(ch); }
},
dialog: {},
shell: {}
}
}));
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
function makeDeps(): InboxIpcDeps {
return {
repo: {} as never,
continuity: {} as never,
capture: {
emitRecallShown: vi.fn(async () => {}),
emitRecallSnoozed: vi.fn(async () => {})
} as never,
health: {} as never,
intent: {} as never,
getInboxWindow: () => null,
settings: {} as never,
providerHolder: {} as never,
paths: { profileDir: '/profile' }
};
}
describe('recall IPC channels (fire-and-forget)', () => {
beforeEach(() => {
handleCalls.length = 0;
onCalls.length = 0;
});
it('inbox:emitRecallShown is registered with ipcMain.on (not handle)', () => {
registerInboxApi(makeDeps());
expect(onCalls).toContain('inbox:emitRecallShown');
expect(handleCalls).not.toContain('inbox:emitRecallShown');
});
it('inbox:emitRecallSnoozed is registered with ipcMain.on (not handle)', () => {
registerInboxApi(makeDeps());
expect(onCalls).toContain('inbox:emitRecallSnoozed');
expect(handleCalls).not.toContain('inbox:emitRecallSnoozed');
});
it('each recall channel is registered exactly once with ipcMain.on', () => {
registerInboxApi(makeDeps());
const shownCount = onCalls.filter((ch) => ch === 'inbox:emitRecallShown').length;
const snoozedCount = onCalls.filter((ch) => ch === 'inbox:emitRecallSnoozed').length;
expect(shownCount).toBe(1);
expect(snoozedCount).toBe(1);
});
});

View File

@@ -52,15 +52,6 @@ describe('useInbox — expired state (v0.2.3 #5)', () => {
afterEach(() => { vi.restoreAllMocks(); });
it('loadExpired sets expiredCandidates from inboxApi', async () => {
mockApi.listExpired.mockResolvedValueOnce([noteStub('n1')]);
const { useInbox } = await import('../../src/renderer/inbox/store.js');
await useInbox.getState().loadExpired();
const s = useInbox.getState();
expect(s.expiredCandidates).toHaveLength(1);
expect(s.expiredCandidates[0]!.id).toBe('n1');
});
it('trashExpiredBatch removes ids and increments trashCount when confirmed', async () => {
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 2, confirmed: true });
const { useInbox } = await import('../../src/renderer/inbox/store.js');

View File

@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } }));
vi.mock('../../src/main/services/GitClient.js');
vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(async () => undefined) }));
import electron from 'electron';
import { mkdir } from 'node:fs/promises';
import { GitClient } from '../../src/main/services/GitClient.js';
import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js';
import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.js';
@@ -105,6 +107,25 @@ describe('sync IPC channels', () => {
expect(r).toEqual({ ok: true });
});
// Regression: syncDir 미생성 상태에서 `git -C <syncDir> init` 호출 시
// git 이 chdir 실패로 죽음 → mkdir(recursive) 가 init 보다 먼저 호출되어야 함.
// (runSync 의 line 135 패턴과 동일.)
it('mkdir(syncDir, recursive) 가 git init 전에 호출됨', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(false);
const callOrder: string[] = [];
(mkdir as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => { callOrder.push('mkdir'); });
(gitInstance.run as unknown as ReturnType<typeof vi.fn>).mockImplementation(async (args: string[]) => {
callOrder.push(`git:${args[0]}`);
return { stdout: '', stderr: '', exitCode: 0 };
});
registerSettingsApi(deps as SettingsIpcDeps);
const h = getHandler('settings:configure-sync');
await h({}, 'git@github.com:user/repo.git');
expect(mkdir).toHaveBeenCalledWith('/tmp/sync', { recursive: true });
expect(callOrder.indexOf('mkdir')).toBeLessThan(callOrder.indexOf('git:init'));
});
it('valid URL → isRepo=true, hasRemote=true → remote set-url', async () => {
const { deps, gitInstance } = makeDeps();
gitInstance.isRepo.mockResolvedValue(true);

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 });
});
});

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { buildVisionPrompt } from '@main/ai/visionPrompt.js';
describe('buildVisionPrompt', () => {
it('includes text, todayKst, dueCandidates, and vocab slice when present', () => {
const result = buildVisionPrompt(
'회의 메모',
'2026-05-09',
['2026-05-10', '2026-05-15'],
['work', 'meeting', 'project', 'todo']
);
expect(result).toContain('회의 메모');
expect(result).toContain('2026-05-09');
expect(result).toContain('2026-05-10, 2026-05-15');
expect(result).toContain('work, meeting, project, todo');
});
it('uses (이미지만 있음) placeholder when text is empty', () => {
const result = buildVisionPrompt('', '2026-05-09', [], []);
expect(result).toContain('(이미지만 있음)');
expect(result).not.toContain('\n\n\n'); // no double-blank from empty text
});
});