From 97ca119b55a4d2cca8f1bf5e8f27e66532f3620b Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:17:23 +0900 Subject: [PATCH 01/13] =?UTF-8?q?docs(ollama-settings):=20v0.2.3.1=20spec?= =?UTF-8?q?=20=E2=80=94=20in-app=20endpoint/model=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mini-brainstorm 3개 결정: - Q1=B: Endpoint + Model 둘 다 포함 - Q2=A: Freetext input (dropdown 은 v0.2.4 영역) - Q3=B: JSON file (`/settings.json`, migration v4 회피) 자명 결정 (질문 없이 패턴): - precedence: settings > env > default - in-flight: AbortController abort + provider re-create - UI: 트레이 + OllamaBanner 진입점, React modal - validation: save 전 healthCheck Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-04-v0231-ollama-settings-design.md | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md diff --git a/docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md b/docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md new file mode 100644 index 0000000..1f375f0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md @@ -0,0 +1,334 @@ +# v0.2.3.1 Ollama 설정 In-App UI — Design Spec + +> 작성: 2026-05-04 · v0.2.3 dogfood unblock 용 patch cut. 환경변수 의존 제거, 사용자 친화 endpoint/model 변경 path. + +## 1. Goal + +Inkling 사용자가 트레이 메뉴 / OllamaBanner 에서 Ollama endpoint + model 을 직접 변경 가능하도록. 현재는 `INKLING_OLLAMA_ENDPOINT` env var 만 지원 — Windows 의 dynamic port 점유 (Hyper-V/WSL2 NAT) 같은 환경 이슈에 즉시 대응 못함. patch cut 으로 dogfood unblock 후 1주 soak 진입. + +## 2. Decisions (mini-brainstorm 합의) + +| # | 질문 | 선택 | 이유 | +|---|---|---|---| +| Q1 | Model 설정 포함 여부 | **B** Endpoint + Model 둘 다 | endpoint 만으로는 커버 불충분 (LAN 서버 fallback 시 model 도 다를 수 있음) | +| Q2 | Model input 형태 | **A** Freetext | "급한" patch cut, healthCheck 가 검증, dropdown 은 v0.2.4 영역 | +| Q3 | Settings 영속화 | **B** JSON file (`/settings.json`) | migration v4 회피, 항목 2개 라 transaction 불필요, user 직접 편집 가능 | + +자명 결정 (질문 없이 패턴 따름): +- env var precedence: **settings > env > default** +- in-flight job 처리: **AbortController abort + provider re-create** +- UI placement: **트레이 메뉴 "Ollama 설정..." + OllamaBanner 의 "설정" 링크** +- validation: **save 전 healthCheck** + +## 3. Architecture & data flow + +``` +사용자 액션: + 1. 트레이 → "Ollama 설정..." 클릭 (또는 OllamaBanner 의 "설정" 링크) + 2. Settings modal 열림 — endpoint + model 입력란 + "저장" 버튼 + 3. 사용자 endpoint/model 입력 → "저장" 클릭 + +저장 흐름: + ├─ Renderer: inboxApi.saveOllamaSettings({ endpoint, model }) + ├─ Main IPC: 임시 LocalOllamaProvider 생성 → healthCheck() + │ ├─ ok=true → JSON 영속화 + provider/health 교체 + │ └─ ok=false → 저장 거부 + reason 반환 (modal 안에 inline 에러) + ├─ 기존 in-flight AI job 처리: + │ └─ AbortController abort → 현재 generate 중단 → unreachable 분류 → + │ AiWorker 의 무한 retry 가 새 endpoint 로 재시도 (자동 회복) + └─ HealthChecker.recheck() → OllamaBanner 즉시 갱신 + +부팅 흐름 (precedence: settings > env > default): + index.ts: + const settings = await settingsSvc.load() // JSON 또는 빈 객체 + const endpoint = settings.ollama?.endpoint + ?? process.env.INKLING_OLLAMA_ENDPOINT + ?? 'http://localhost:11434' + const model = settings.ollama?.model ?? 'gemma4:e4b' +``` + +### 3.1 핵심 invariants + +1. **저장 = 검증 통과 전제** — healthCheck ok=false 면 JSON 안 씀. 사용자 잘못된 값 영속화 방지 +2. **Provider mutability via re-create** — `setEndpoint()` 메서드 추가 X. `ProviderHolder` 가 새 인스턴스 보유, listeners 알림. `AbortController` 가 in-flight 중단 +3. **Settings precedence**: settings.json > env var > hardcoded default. UI 가 source of truth +4. **단일 settings file** — `/settings.json`. atomic write (`writeFile` temp → `rename`). 손상 시 빈 객체 fallback (no app crash) +5. **HealthChecker rebind** — `ProviderHolder.onReplace` 통해 새 provider 받아 polling endpoint 즉시 갱신 +6. **Backward compat** — settings.json 없는 첫 부팅: env var → default 순. 기존 사용자 영향 0 +7. **Cross-platform 자동** — `app.getPath('userData')` + `node:path.join` + `node:fs/promises` 가 OS 별 경로/separator/UTF-8 자동. 별도 분기 0 + +## 4. Components + +### 4.1 `SettingsService` (신규) + +**파일**: `src/main/services/SettingsService.ts` + +```typescript +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { z } from 'zod'; + +const OllamaSettingsSchema = z.object({ + endpoint: z.string().url(), + model: z.string().min(1) +}).strict(); + +const SettingsSchema = z.object({ + ollama: OllamaSettingsSchema.optional() +}).strict(); + +export type Settings = z.infer; +export type OllamaSettings = z.infer; + +export class SettingsService { + private filePath: string; + private cache: Settings | null = null; + + constructor(profileDir: string) { + this.filePath = join(profileDir, 'settings.json'); + } + + async load(): Promise { + if (this.cache !== null) return this.cache; + try { + const raw = await readFile(this.filePath, 'utf8'); + const parsed = JSON.parse(raw); + this.cache = SettingsSchema.parse(parsed); + } catch { + this.cache = {}; // 파일 없음 또는 손상 → 빈 객체 fallback + } + return this.cache; + } + + async setOllama(value: OllamaSettings): Promise { + const validated = OllamaSettingsSchema.parse(value); + const current = await this.load(); + const next: Settings = { ...current, ollama: validated }; + await mkdir(dirname(this.filePath), { recursive: true }); + const tmpPath = this.filePath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8'); + await rename(tmpPath, this.filePath); + this.cache = next; + } +} +``` + +### 4.2 `ProviderHolder` (신규) + +**파일**: `src/main/ai/ProviderHolder.ts` + +```typescript +import type { LocalOllamaProvider } from './LocalOllamaProvider.js'; + +export class ProviderHolder { + private current: LocalOllamaProvider; + private listeners: Array<(p: LocalOllamaProvider) => void> = []; + + constructor(initial: LocalOllamaProvider) { + this.current = initial; + } + + get(): LocalOllamaProvider { + return this.current; + } + + replace(next: LocalOllamaProvider): void { + this.current = next; + for (const fn of this.listeners) fn(next); + } + + onReplace(fn: (p: LocalOllamaProvider) => void): void { + this.listeners.push(fn); + } +} +``` + +### 4.3 `LocalOllamaProvider` AbortController + 사용처 변경 + +**파일**: `src/main/ai/LocalOllamaProvider.ts` (수정) + +```typescript +export class LocalOllamaProvider implements InferenceProvider { + private abortController: AbortController | null = null; + + async generate(input: GenerateInput): Promise { + this.abortController = new AbortController(); + const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs); + try { + 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 } + }), + signal: this.abortController.signal + }); + // ... 기존 응답 처리 ... + } finally { + clearTimeout(timer); + this.abortController = null; + } + } + + abort(): void { + this.abortController?.abort(); + } +} +``` + +기존 `signal: controller.signal` 부분에서 `controller` 가 method-local 이었음 → `this.abortController` 로 이동 (외부 abort 가능). + +### 4.4 `AiWorker` + `HealthChecker` 가 `ProviderHolder` 사용 + +`AiWorker` constructor: `provider: InferenceProvider` → `private holder: ProviderHolder` +- `processJob` 내 `this.provider.generate(...)` → `this.holder.get().generate(...)` +- `provider: this.provider.name` → `provider: this.holder.get().name` + +`HealthChecker` 도 동일 패턴 + `onReplace` listener 등록 → 새 provider 즉시 polling. + +### 4.5 IPC + Preload + InboxApi types + +```typescript +// src/main/ipc/inboxApi.ts +ipcMain.handle('inbox:loadOllamaSettings', async () => { + const s = await deps.settings.load(); + return s.ollama ?? null; +}); + +ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { + // 검증: 새 인스턴스로 healthCheck + const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); + const r = await trial.healthCheck(); + if (!r.ok) return { ok: false, reason: r.reason }; + await deps.settings.setOllama(value); + // in-flight 중단 후 holder 교체 + deps.providerHolder.get().abort(); + deps.providerHolder.replace(trial); + await deps.health.recheck(); + return { ok: true }; +}); +``` + +```typescript +// src/preload/index.ts +loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'), +saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v), +``` + +```typescript +// src/shared/types.ts InboxApi +loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>; +saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>; +``` + +### 4.6 `OllamaSettingsModal` 컴포넌트 + +**파일**: `src/renderer/inbox/components/OllamaSettingsModal.tsx` + +- Props: `open: boolean`, `onClose: () => void` +- 입력란 2개 (endpoint, model) + "저장" / "취소" +- 마운트 시 `inboxApi.loadOllamaSettings()` → 초기값 prefill +- 저장 시 `saveOllamaSettings(...)` → 성공 닫기 + 토스트, 실패 inline 에러 +- React `` 또는 portal — 별도 BrowserWindow X (단순함) + +### 4.7 OllamaBanner "설정" 링크 + +기존 `OllamaBanner.tsx` 에 endpoint 변경 링크 추가: +```typescript + +``` +modal state 는 App.tsx 가 보유 + OllamaBanner 와 OllamaSettingsModal 둘 다에 넘김. + +### 4.8 트레이 메뉴 + IPC 채널 + +`tray.ts` 의 `createTray` 가 10번째 positional callback 받음 → backlog #4/#26 (TrayCallbacks object refactor) 와 합산 가능. 본 cut 에선 일관성 우선 positional 추가: + +```typescript +{ label: 'Ollama 설정...', click: () => runOpenOllamaSettings() } +``` + +`index.ts` 가 `runOpenOllamaSettings = () => mainWindow.webContents.send('inbox:openOllamaSettings')` 푸시. Renderer App.tsx 가 이 channel 구독해 modal 열기. + +### 4.9 `index.ts` 부팅 흐름 변경 + +```typescript +const settingsSvc = new SettingsService(paths.profileDir); +const settings = await settingsSvc.load(); + +const resolvedEndpoint = settings.ollama?.endpoint + ?? process.env.INKLING_OLLAMA_ENDPOINT + ?? 'http://localhost:11434'; +const resolvedModel = settings.ollama?.model ?? 'gemma4:e4b'; + +logger.info('ai.endpoint', { + endpoint: resolvedEndpoint, + source: settings.ollama?.endpoint + ? 'settings' + : (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default') +}); + +const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel }); +const holder = new ProviderHolder(provider); + +const health = new HealthChecker(holder, { ... }); +const aiWorker = new AiWorker(repo, holder, { ... }); +``` + +## 5. Privacy invariant + +- `settings.json` 은 local only, telemetry emit X +- 잠재적 `ollama_settings_changed` event 추가 시 endpoint URL 노출 → privacy 위반 → **emit 안 함** (본 cut) +- 향후 v0.2.4 dogfood telemetry 에서 변경 빈도 측정 필요 시 `{ count: number }` payload (URL 자체 X) 형태로 추가 + +## 6. Tests (≥10개) + +### SettingsService.test.ts (신규, 6) +1. `load()` 파일 없음 → 빈 객체 +2. `load()` 손상 JSON (parse 실패) → 빈 객체 fallback (no throw) +3. `load()` 캐시 동작 — 두 번째 호출 시 file read 안 함 +4. `setOllama()` zod 검증 실패 (non-URL endpoint) → throw +5. `setOllama()` 정상 저장 → 디스크 file 존재 + 내용 일치 +6. `setOllama()` atomic write — temp file 남지 않음 (rename 후 cleanup) + +### ProviderHolder.test.ts (신규, 2) +7. `replace()` 시 listener 발화 + `get()` 가 새 인스턴스 반환 +8. listener 여러 개 등록 시 모두 발화 + +### LocalOllamaProvider.test.ts (확장, 2) +9. `abort()` 호출 시 in-flight `generate()` rejects (AbortError) +10. constructor `model` 파라미터 적용 (default `gemma4:e4b` 외 임의 model) + +총 신규 단위 **10개**. 기존 403 + 10 = **413**. + +(Renderer modal 컴포넌트 단위 테스트 X — Inkling 패턴 따라 store-only. IPC handler 자체도 service-level test 가 logic 보유.) + +## 7. Out of scope + +- Multi-provider abstraction (OpenAI, Anthropic, etc) — strategy.md local-first 정책 충돌, v0.2.4+ +- Settings UI 안에서 다른 기능 (telemetry retention, vocab top-N 등) — 별 cut +- Cross-machine settings sync — 단일 머신 dogfood 패턴 +- Model dropdown / 자동 list refresh — Q2=A 결정 (freetext) +- `ollama pull` 자동 안내 — over-scope +- Settings export/import / version migration — over-scope +- Settings 변경 history / undo — over-scope +- Settings UI 안에 model healthCheck 결과 시각화 (loading spinner 등) — minimal toast 만 +- `ollama_settings_changed` telemetry — privacy invariant 보호 (v0.2.4 검토 시 count-only) +- Settings 변경 로그 파일 — env-debug 영역, v0.2.4 검토 + +## 8. Gates (roadmap §3.1) + +- typecheck 0 +- 단위 403 → 413 (+10) +- e2e 1/1 (smoke 회귀 X) +- backward compat: settings.json 없는 부팅 → env var → default 폴백 정상 +- cross-platform: SettingsService.test 가 `app.getPath` mock 으로 Win/macOS/Linux 시뮬레이션 (별도 case 또는 path matrix) + +## 9. Roadmap relation + +- v0.2.3 dogfood unblock 패치. 정식 v0.2.3 cut (#1~#7) 와 별개 patch +- 머지 후 v0.2.3.1 binary 재빌드 + Gitea release (existing `v0.2.3` tag → `v0.2.3.1` 신규 tag, release 별도) +- ≥1주 dogfood soak 후 telemetry export + 신규 피드백 + v0.2.4 backlog 38건 일괄 triage → v0.2.4 brainstorm From 71ec79ae19a05c2c530ce23d5dbe59897168d595 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:21:00 +0900 Subject: [PATCH 02/13] =?UTF-8?q?docs(ollama-settings):=20v0.2.3.1=20plan?= =?UTF-8?q?=20=E2=80=94=207=20tasks=20TDD=20+=2010=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1 SettingsService (JSON 영속화 + zod, +6 cases) T2 LocalOllamaProvider abort + model param (+2 cases) T3 ProviderHolder + AiWorker/HealthChecker refactor (+2 cases) T4 index 부팅 + IPC + preload + types T5 OllamaSettingsModal + App.tsx + OllamaBanner 링크 T6 트레이 메뉴 "Ollama 설정..." T7 Closure (version 0.2.3 → 0.2.3.1 + gates) 총 신규 단위 +10. 단위 403 → 413. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-04-v0231-ollama-settings.md | 1156 +++++++++++++++++ 1 file changed, 1156 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-v0231-ollama-settings.md diff --git a/docs/superpowers/plans/2026-05-04-v0231-ollama-settings.md b/docs/superpowers/plans/2026-05-04-v0231-ollama-settings.md new file mode 100644 index 0000000..ae00f2f --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-v0231-ollama-settings.md @@ -0,0 +1,1156 @@ +# v0.2.3.1 Ollama Settings In-App UI 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:** 사용자가 트레이/배너에서 Ollama endpoint + model 을 직접 설정할 수 있도록. JSON file 영속화. precedence: settings > env > default. AbortController + ProviderHolder 통한 in-flight job 안전 교체. + +**Architecture:** 새 `SettingsService` (JSON 영속화 + zod 검증) + `ProviderHolder` (mutable provider holder + listeners) + `LocalOllamaProvider.abort()` (AbortController exposure). AiWorker/HealthChecker 가 provider 직접 보유 → holder 통해 간접 참조로 변경. UI 는 React modal 컴포넌트 (별도 BrowserWindow X). + +**Tech Stack:** zod 4.3.6 (settings 검증), React 19 modal, node:fs/promises atomic write (temp + rename), Electron IPC 2 신규 channel. + +**Spec:** `docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md` + +--- + +## File Structure + +| File | Role | Action | +|------|------|--------| +| `src/main/services/SettingsService.ts` | JSON 영속화 + zod 검증 | 신규 | +| `src/main/ai/ProviderHolder.ts` | Mutable provider holder + listeners | 신규 | +| `src/main/ai/LocalOllamaProvider.ts` | AbortController exposure + abort() method | 수정 | +| `src/main/ai/AiWorker.ts` | provider → holder.get() | 수정 | +| `src/main/services/HealthChecker.ts` | provider → holder.get() + onReplace | 수정 | +| `src/main/index.ts` | SettingsService 부팅 + holder 생성 | 수정 | +| `src/main/ipc/inboxApi.ts` | 2 신규 IPC handler + 1 push channel | 수정 | +| `src/preload/index.ts` | 2 신규 inboxApi 메서드 + 1 listener | 수정 | +| `src/shared/types.ts` | InboxApi 시그니처 추가 | 수정 | +| `src/main/tray.ts` | "Ollama 설정..." 메뉴 + 10번째 callback | 수정 | +| `src/renderer/inbox/components/OllamaSettingsModal.tsx` | Modal UI | 신규 | +| `src/renderer/inbox/components/OllamaBanner.tsx` | "설정" 링크 추가 | 수정 | +| `src/renderer/inbox/App.tsx` | Modal mount + open/close state | 수정 | +| `tests/unit/SettingsService.test.ts` | 6 cases | 신규 | +| `tests/unit/ProviderHolder.test.ts` | 2 cases | 신규 | +| `tests/unit/LocalOllamaProvider.test.ts` | +2 cases | 수정 | + +총 신규 단위 10개. 단위 403 → 413 예상. + +--- + +## Task 1: SettingsService — JSON 영속화 + zod 검증 + +**Files:** +- Create: `src/main/services/SettingsService.ts` +- Test: `tests/unit/SettingsService.test.ts` (신규) + +- [ ] **Step 1: Create test file with first failing case** + +Create `tests/unit/SettingsService.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SettingsService } from '@main/services/SettingsService.js'; + +describe('SettingsService', () => { + let dir: string; + let svc: SettingsService; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'inkling-settings-')); + svc = new SettingsService(dir); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('load() returns empty object when file does not exist', async () => { + const s = await svc.load(); + expect(s).toEqual({}); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +Run: `npm test -- SettingsService` +Expected: FAIL — `Cannot find module '@main/services/SettingsService.js'` + +- [ ] **Step 3: Implement SettingsService** + +Create `src/main/services/SettingsService.ts`: + +```typescript +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { z } from 'zod'; + +const OllamaSettingsSchema = z.object({ + endpoint: z.string().url(), + model: z.string().min(1) +}).strict(); + +const SettingsSchema = z.object({ + ollama: OllamaSettingsSchema.optional() +}).strict(); + +export type Settings = z.infer; +export type OllamaSettings = z.infer; + +export class SettingsService { + private filePath: string; + private cache: Settings | null = null; + + constructor(profileDir: string) { + this.filePath = join(profileDir, 'settings.json'); + } + + async load(): Promise { + if (this.cache !== null) return this.cache; + try { + const raw = await readFile(this.filePath, 'utf8'); + const parsed = JSON.parse(raw); + this.cache = SettingsSchema.parse(parsed); + } catch { + this.cache = {}; + } + return this.cache; + } + + async setOllama(value: OllamaSettings): Promise { + const validated = OllamaSettingsSchema.parse(value); + const current = await this.load(); + const next: Settings = { ...current, ollama: validated }; + await mkdir(dirname(this.filePath), { recursive: true }); + const tmpPath = this.filePath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8'); + await rename(tmpPath, this.filePath); + this.cache = next; + } +} +``` + +- [ ] **Step 4: Run test, verify pass** + +Run: `npm test -- SettingsService` +Expected: PASS — 1 case. + +- [ ] **Step 5: Add 5 more cases** + +Append to `tests/unit/SettingsService.test.ts` inside the `describe`: + +```typescript + it('load() returns empty object on corrupted JSON (no throw)', async () => { + writeFileSync(join(dir, 'settings.json'), '{ this is not json'); + const s = await svc.load(); + expect(s).toEqual({}); + }); + + it('load() caches result — second call does not re-read file', async () => { + await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' }); + const before = await svc.load(); + // 외부에서 파일 변경 + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ ollama: { endpoint: 'http://lan:11434', model: 'gemma4:26b' } })); + const after = await svc.load(); + // 캐시 적용 — 파일 변경 무시 + expect(after).toEqual(before); + }); + + it('setOllama() throws on non-URL endpoint', async () => { + await expect( + svc.setOllama({ endpoint: 'not-a-url', model: 'gemma4:e4b' }) + ).rejects.toThrow(); + }); + + it('setOllama() persists to disk with valid JSON', async () => { + await svc.setOllama({ endpoint: 'http://localhost:11435', model: 'gemma4:e4b' }); + const raw = readFileSync(join(dir, 'settings.json'), 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed.ollama.endpoint).toBe('http://localhost:11435'); + expect(parsed.ollama.model).toBe('gemma4:e4b'); + }); + + it('setOllama() atomic write — tmp file does not remain', async () => { + await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' }); + expect(existsSync(join(dir, 'settings.json.tmp'))).toBe(false); + expect(existsSync(join(dir, 'settings.json'))).toBe(true); + }); +}); +``` + +- [ ] **Step 6: Run all tests** + +Run: `npm test -- SettingsService` +Expected: PASS — 6 cases. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/services/SettingsService.ts tests/unit/SettingsService.test.ts +git commit -m "$(cat <<'EOF' +feat(settings): SettingsService — JSON 영속화 + zod 검증 (v0.2.3.1) + +- `/settings.json` atomic write (temp + rename) +- 손상 JSON / 파일 없음 → 빈 객체 fallback (no throw) +- in-memory cache (load 1회 file read) +- zod .strict() schema for ollama { endpoint: URL, model: string } +- 단위 +6 cases + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: LocalOllamaProvider — AbortController exposure + model param + +**Files:** +- Modify: `src/main/ai/LocalOllamaProvider.ts` +- Test: `tests/unit/LocalOllamaProvider.test.ts` (확장) + +- [ ] **Step 1: Read current LocalOllamaProvider.ts** + +Read the file to confirm current structure. Method `generate()` 안에 `const controller = new AbortController()` 로 method-local 변수 사용 중. 외부에서 abort 못함. + +- [ ] **Step 2: Write failing test for abort()** + +Append inside `describe('LocalOllamaProvider', ...)` in `tests/unit/LocalOllamaProvider.test.ts`: + +```typescript + it('abort() cancels in-flight generate (rejects with AbortError)', async () => { + mock.get('http://localhost:11434').intercept({ + path: '/api/generate', method: 'POST' + }).reply(async () => { + await new Promise((r) => setTimeout(r, 5000)); // long-running + return { statusCode: 200, data: '{}' }; + }); + const provider = new LocalOllamaProvider({ timeoutMs: 30_000 }); + const generatePromise = provider.generate({ + text: 'x', todayKst: '2026-05-04', dueDateCandidates: [] + }); + setTimeout(() => provider.abort(), 50); + await expect(generatePromise).rejects.toThrow(); + }); + + it('constructor uses provided model param (not just default)', () => { + const provider = new LocalOllamaProvider({ model: 'gemma4:26b' }); + expect(provider.name).toBe('local-ollama/gemma4:26b'); + }); +``` + +- [ ] **Step 3: Run, verify fail** + +Run: `npm test -- LocalOllamaProvider` +Expected: FAIL — `provider.abort is not a function`. (Second test should pass already since model param exists.) + +- [ ] **Step 4: Modify LocalOllamaProvider.ts — expose abortController + add abort()** + +Open `src/main/ai/LocalOllamaProvider.ts`. Find the `generate(input)` method and refactor: + +```typescript +export class LocalOllamaProvider implements InferenceProvider { + readonly name: string; + private endpoint: string; + private model: string; + private timeoutMs: number; + private temperature: number; + private numPredict: number; + private abortController: AbortController | null = null; + + constructor(opts: LocalOllamaOptions = {}) { + this.endpoint = opts.endpoint ?? 'http://localhost:11434'; + this.model = opts.model ?? 'gemma4:e4b'; + this.timeoutMs = opts.timeoutMs ?? 120_000; + this.temperature = opts.temperature ?? 0.2; + this.numPredict = opts.numPredict ?? 512; + this.name = `local-ollama/${this.model}`; + } + + async generate(input: GenerateInput): Promise { + this.abortController = new AbortController(); + const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs); + try { + 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 } + }), + 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'); + let parsed: unknown; + try { parsed = JSON.parse(body.response); } + catch (err) { throw new Error(`invalid json in response: ${String(err)}`); } + return parseAiResponse(parsed); + } finally { + clearTimeout(timer); + this.abortController = null; + } + } + + /** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */ + abort(): void { + this.abortController?.abort(); + } + + async healthCheck(): Promise { + // 기존 구현 유지 + try { + const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' }); + if (res.statusCode !== 200) return { ok: false, reason: `tags http ${res.statusCode}` }; + const body = (await res.body.json()) as { models?: Array<{ name: string }> }; + const found = body.models?.some((m) => m.name === this.model); + 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}` }; + } + } +} +``` + +핵심 변경: `private abortController: AbortController | null = null;` 필드 추가, `generate()` 가 method-local 대신 인스턴스 필드 사용, `abort()` public 메서드 추가. + +- [ ] **Step 5: Run tests** + +Run: `npm test -- LocalOllamaProvider` +Expected: PASS — 모든 기존 케이스 + 2 신규. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts +git commit -m "$(cat <<'EOF' +feat(ollama): LocalOllamaProvider — abort() + AbortController instance field (v0.2.3.1) + +- abortController 가 method-local 에서 private instance field 로 이동 +- public abort() 메서드 — 외부에서 in-flight generate 강제 중단 +- ProviderHolder.replace() 시 호출되어 endpoint 변경 즉시 반영 +- 단위 +2 cases (abort cancellation, model 파라미터) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: ProviderHolder + AiWorker/HealthChecker refactor + +**Files:** +- Create: `src/main/ai/ProviderHolder.ts` +- Modify: `src/main/ai/AiWorker.ts` +- Modify: `src/main/services/HealthChecker.ts` +- Test: `tests/unit/ProviderHolder.test.ts` (신규) + +- [ ] **Step 1: Create ProviderHolder + tests** + +Create `tests/unit/ProviderHolder.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { ProviderHolder } from '@main/ai/ProviderHolder.js'; +import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js'; + +describe('ProviderHolder', () => { + it('replace() fires listener and get() returns new instance', () => { + const a = new LocalOllamaProvider({ endpoint: 'http://a:11434', model: 'm1' }); + const b = new LocalOllamaProvider({ endpoint: 'http://b:11434', model: 'm2' }); + const holder = new ProviderHolder(a); + const listener = vi.fn(); + holder.onReplace(listener); + expect(holder.get()).toBe(a); + holder.replace(b); + expect(holder.get()).toBe(b); + expect(listener).toHaveBeenCalledWith(b); + }); + + it('multiple listeners all fire on replace()', () => { + const a = new LocalOllamaProvider({ model: 'm1' }); + const b = new LocalOllamaProvider({ model: 'm2' }); + const holder = new ProviderHolder(a); + const l1 = vi.fn(); + const l2 = vi.fn(); + holder.onReplace(l1); + holder.onReplace(l2); + holder.replace(b); + expect(l1).toHaveBeenCalledWith(b); + expect(l2).toHaveBeenCalledWith(b); + }); +}); +``` + +- [ ] **Step 2: Run, verify fail** + +Run: `npm test -- ProviderHolder` +Expected: FAIL — `Cannot find module '@main/ai/ProviderHolder.js'`. + +- [ ] **Step 3: Implement ProviderHolder** + +Create `src/main/ai/ProviderHolder.ts`: + +```typescript +import type { LocalOllamaProvider } from './LocalOllamaProvider.js'; + +/** + * v0.2.3.1 — Mutable provider holder. AiWorker / HealthChecker 가 endpoint 변경 시 + * 새 LocalOllamaProvider 인스턴스를 받도록 indirection layer. + * + * 사용 패턴: + * const holder = new ProviderHolder(initialProvider); + * aiWorker = new AiWorker(repo, holder, opts); // holder 전달 + * health = new HealthChecker(holder, opts); + * health.onProviderReplace 가 holder.onReplace 통해 polling 즉시 새 provider 사용 + * + * // 사용자가 Settings 저장 시: + * holder.get().abort(); // in-flight 중단 + * holder.replace(newProvider); // 모든 consumer 가 새 인스턴스 사용 + */ +export class ProviderHolder { + private current: LocalOllamaProvider; + private listeners: Array<(p: LocalOllamaProvider) => void> = []; + + constructor(initial: LocalOllamaProvider) { + this.current = initial; + } + + get(): LocalOllamaProvider { + return this.current; + } + + replace(next: LocalOllamaProvider): void { + this.current = next; + for (const fn of this.listeners) fn(next); + } + + onReplace(fn: (p: LocalOllamaProvider) => void): void { + this.listeners.push(fn); + } +} +``` + +- [ ] **Step 4: Run tests, verify pass** + +Run: `npm test -- ProviderHolder` +Expected: PASS — 2 cases. + +- [ ] **Step 5: Refactor AiWorker — provider → holder.get()** + +Read `src/main/ai/AiWorker.ts` to confirm structure. Find constructor signature `private provider: InferenceProvider` 및 사용처 `this.provider.generate(...)` / `this.provider.name`. + +Replace constructor parameter: + +Find: +```typescript + constructor( + private repo: NoteRepository, + private provider: InferenceProvider, + opts: AiWorkerOptions = {} + ) { +``` + +Replace with: +```typescript + constructor( + private repo: NoteRepository, + private holder: ProviderHolder, + opts: AiWorkerOptions = {} + ) { +``` + +(Add `import { ProviderHolder } from './ProviderHolder.js';` at top if not present.) + +Then in `processJob`, find: +```typescript + const res = await this.provider.generate({ + text: note.rawText, + todayKst: todayIso, + dueDateCandidates: candidates, + vocab + }); +``` + +Replace with: +```typescript + const res = await this.holder.get().generate({ + text: note.rawText, + todayKst: todayIso, + dueDateCandidates: candidates, + vocab + }); +``` + +And: +```typescript + this.repo.updateAiResult(job.noteId, { + title: res.title, + summary: res.summary, + tags: res.tags, + provider: this.provider.name, + ... +``` + +Replace with: +```typescript + this.repo.updateAiResult(job.noteId, { + title: res.title, + summary: res.summary, + tags: res.tags, + provider: this.holder.get().name, + ... +``` + +Verify any other `this.provider` references and replace with `this.holder.get()`. + +- [ ] **Step 6: Refactor HealthChecker similarly** + +Read `src/main/services/HealthChecker.ts`. Same pattern — constructor `private provider: InferenceProvider` → `private holder: ProviderHolder`. Internal uses of `this.provider.healthCheck()` → `this.holder.get().healthCheck()`. + +If HealthChecker has internal state that depends on provider identity (e.g., last health snapshot), no special handling needed since provider replacement just causes next polling cycle to use new endpoint. + +- [ ] **Step 7: Run all tests, verify no regressions** + +Run: `npm test` +Expected: PASS — 기존 + 신규 모두. AiWorker / HealthChecker 기존 테스트가 `provider` 파라미터를 전달하던 부분이 fail 할 수 있음. + +If existing tests fail because they pass `provider` to AiWorker/HealthChecker constructor: update those tests to wrap in `new ProviderHolder(provider)`. Example: + +Find in test files: +```typescript +const worker = new AiWorker(repo, makeProvider(), { ... }); +``` + +Replace with: +```typescript +const provider = makeProvider(); +const holder = new ProviderHolder(provider); +const worker = new AiWorker(repo, holder, { ... }); +``` + +(Add `import { ProviderHolder } from '@main/ai/ProviderHolder.js';` at top of affected test files.) + +Search affected test files: `tests/unit/AiWorker.test.ts`, `tests/unit/HealthChecker.test.ts`. Update all `new AiWorker(...)` and `new HealthChecker(...)` constructor calls to use `ProviderHolder` wrapper. + +- [ ] **Step 8: Run all tests, verify pass** + +Run: `npm test` +Expected: PASS — 단위 411/411 (403 기존 + 6 SettingsService + 2 LocalOllamaProvider + 2 ProviderHolder = 413 if counting from start of T1; if some existing tests required modification then total stays 413). + +Actually wait — pre-T2 count was 405 (403 + 2 LocalOllamaProvider new). After T1 it's 411 (+6 SettingsService). After T3 it's 413 (+2 ProviderHolder). Goal: 413. + +- [ ] **Step 9: Commit** + +```bash +git add src/main/ai/ProviderHolder.ts src/main/ai/AiWorker.ts src/main/services/HealthChecker.ts tests/unit/ProviderHolder.test.ts tests/unit/AiWorker.test.ts tests/unit/HealthChecker.test.ts +git commit -m "$(cat <<'EOF' +feat(ollama): ProviderHolder + AiWorker/HealthChecker refactor (v0.2.3.1) + +- ProviderHolder: mutable holder + listeners, indirection layer +- AiWorker: constructor 가 InferenceProvider → ProviderHolder + this.provider.x → this.holder.get().x 전환 +- HealthChecker: 동일 패턴 +- 기존 AiWorker / HealthChecker 테스트의 constructor 호출에 ProviderHolder wrap +- 단위 +2 cases (ProviderHolder) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: index.ts 부팅 + IPC + preload + types + +**Files:** +- Modify: `src/main/index.ts` +- Modify: `src/main/ipc/inboxApi.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/shared/types.ts` + +- [ ] **Step 1: Modify index.ts — SettingsService 부팅 + holder 생성** + +Read `src/main/index.ts` to confirm current structure. Find provider 생성 부분 (around line 60-72): + +```typescript + const resolvedEndpoint = process.env.INKLING_OLLAMA_ENDPOINT ?? 'http://localhost:11434'; + logger.info('ai.endpoint', { + endpoint: resolvedEndpoint, + fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined + }); + const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint }); +``` + +Replace with: +```typescript + const settingsSvc = new SettingsService(paths.profileDir); + const settings = await settingsSvc.load(); + + const resolvedEndpoint = settings.ollama?.endpoint + ?? process.env.INKLING_OLLAMA_ENDPOINT + ?? 'http://localhost:11434'; + const resolvedModel = settings.ollama?.model ?? 'gemma4:e4b'; + + logger.info('ai.endpoint', { + endpoint: resolvedEndpoint, + model: resolvedModel, + source: settings.ollama?.endpoint + ? 'settings' + : (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default') + }); + + const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel }); + const providerHolder = new ProviderHolder(provider); +``` + +Add imports at top: +```typescript +import { SettingsService } from './services/SettingsService.js'; +import { ProviderHolder } from './ai/ProviderHolder.js'; +``` + +Then update HealthChecker / AiWorker construction below to pass `providerHolder` instead of `provider`: + +Find: +```typescript + const health = new HealthChecker(provider, { ... }); + ... + const aiWorker = new AiWorker(repo, provider, { ... }); +``` + +Replace with: +```typescript + const health = new HealthChecker(providerHolder, { ... }); + ... + const aiWorker = new AiWorker(repo, providerHolder, { ... }); +``` + +- [ ] **Step 2: Modify ipc/inboxApi.ts — 2 IPC handlers** + +Read `src/main/ipc/inboxApi.ts`. Add to deps interface (likely an object with services). Look at existing handlers to find pattern. + +Add 2 IPC handlers near other recent handlers (e.g., near recall handlers): + +```typescript + ipcMain.handle('inbox:loadOllamaSettings', async () => { + const s = await deps.settings.load(); + return s.ollama ?? null; + }); + + ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { + // 검증: 새 인스턴스로 healthCheck + const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); + const r = await trial.healthCheck(); + if (!r.ok) return { ok: false, reason: r.reason ?? 'unknown' }; + await deps.settings.setOllama(value); + deps.providerHolder.get().abort(); + deps.providerHolder.replace(trial); + await deps.health.recheck(); + return { ok: true }; + }); +``` + +Update deps interface to include `settings: SettingsService` and `providerHolder: ProviderHolder`. Add necessary imports: +```typescript +import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js'; +import type { SettingsService } from '../services/SettingsService.js'; +import type { ProviderHolder } from '../ai/ProviderHolder.js'; +``` + +- [ ] **Step 3: Modify index.ts — register inboxApi with new deps** + +Find existing `registerInboxApi` or similar call in index.ts. Add `settings: settingsSvc, providerHolder` to its deps object: + +```typescript +registerInboxApi(getInboxWindow, { + capture, repo, store, continuity, intent, + health, exporter, importer, sync, backup, telemetry, + settings: settingsSvc, // 신규 + providerHolder // 신규 +}); +``` + +(Adapt to existing argument shape — read inboxApi.ts to confirm.) + +- [ ] **Step 4: Modify preload/index.ts — 2 inboxApi methods** + +Find existing inboxApi object in preload. Add near other recent methods: + +```typescript + loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'), + saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v), +``` + +- [ ] **Step 5: Modify shared/types.ts — InboxApi signatures** + +Find `InboxApi` interface. Add 2 method signatures: + +```typescript + loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>; + saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>; +``` + +- [ ] **Step 6: Run typecheck + tests** + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: 413/413 pass (no new tests in this task, no regressions). + +- [ ] **Step 7: Commit** + +```bash +git add src/main/index.ts src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts +git commit -m "$(cat <<'EOF' +feat(ollama): index 부팅 + IPC + preload + types (v0.2.3.1) + +- index.ts: SettingsService.load() 후 endpoint/model 결정 (settings > env > default) +- providerHolder 생성 후 HealthChecker / AiWorker 에 전달 +- IPC: inbox:loadOllamaSettings + inbox:saveOllamaSettings + - save: 임시 provider 로 healthCheck 통과 시에만 영속화 + holder.replace + - 기존 in-flight generate 는 abort() +- preload + InboxApi shared types + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: OllamaSettingsModal + App.tsx mount + OllamaBanner 링크 + +**Files:** +- Create: `src/renderer/inbox/components/OllamaSettingsModal.tsx` +- Modify: `src/renderer/inbox/components/OllamaBanner.tsx` +- Modify: `src/renderer/inbox/App.tsx` + +- [ ] **Step 1: Create OllamaSettingsModal.tsx** + +Create `src/renderer/inbox/components/OllamaSettingsModal.tsx`: + +```typescript +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../api.js'; + +interface Props { + open: boolean; + onClose: () => void; +} + +export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null { + const [endpoint, setEndpoint] = useState('http://localhost:11434'); + const [model, setModel] = useState('gemma4:e4b'); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + // 마운트/open 시 현재 설정 fetch + useEffect(() => { + if (!open) return; + void inboxApi.loadOllamaSettings().then((s) => { + if (s) { + setEndpoint(s.endpoint); + setModel(s.model); + } + setError(null); + }); + }, [open]); + + if (!open) return null; + + async function handleSave() { + setSaving(true); + setError(null); + try { + const r = await inboxApi.saveOllamaSettings({ endpoint, model }); + if (r.ok) { + onClose(); + } else { + setError(r.reason); + } + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + } + + return ( +
+
+

Ollama 설정

+
+ + setEndpoint(e.target.value)} + placeholder="http://localhost:11434" + style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }} + disabled={saving} + /> +
+
+ + setModel(e.target.value)} + placeholder="gemma4:e4b" + style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }} + disabled={saving} + /> +
+ {error && ( +
+ 저장 실패: {error} +
+ )} +
+ + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Modify App.tsx — mount modal + open state + IPC channel listener** + +Read `src/renderer/inbox/App.tsx`. Add modal state + handler: + +```typescript +import { OllamaSettingsModal } from './components/OllamaSettingsModal.js'; + +export function App(): React.ReactElement { + // ... existing useInbox / useState ... + const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false); + + useEffect(() => { + // ... existing onNoteUpdated / onOllamaStatus ... + const unsubOpen = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true)); + return () => { /* existing */ unsubOpen(); }; + }, [/* existing deps */]); + + // ... 기존 return ... + return ( + <> +
{/* ... */}
+ {/* ... */} + setOllamaSettingsOpen(true)} /> + {/* ... 기존 banner stack ... */} + setOllamaSettingsOpen(false)} + /> + + ); +} +``` + +(Adapt to existing JSX structure. Verify return shape and integrate accordingly.) + +- [ ] **Step 3: Add `onOpenOllamaSettings` to preload** + +In `src/preload/index.ts`, add IPC listener bridge: + +```typescript + onOpenOllamaSettings: (cb: () => void) => { + const handler = () => cb(); + ipcRenderer.on('inbox:openOllamaSettings', handler); + return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler); + }, +``` + +In `src/shared/types.ts` InboxApi: +```typescript + onOpenOllamaSettings(cb: () => void): () => void; +``` + +- [ ] **Step 4: Modify OllamaBanner.tsx — "설정" 링크 추가** + +Read `src/renderer/inbox/components/OllamaBanner.tsx`. Add prop + button: + +```typescript +interface OllamaBannerProps { + onOpenSettings?: () => void; +} + +export function OllamaBanner({ onOpenSettings }: OllamaBannerProps): React.ReactElement | null { + // ... 기존 ... + + // 기존 Banner JSX 안에 적절한 위치에 button 추가: + return ( +
+ {/* 기존 메시지 */} + {onOpenSettings && ( + + )} + {/* 기존 재확인 버튼 등 */} +
+ ); +} +``` + +(Adapt to existing OllamaBanner JSX — keep "재확인" 버튼 같이.) + +- [ ] **Step 5: Run typecheck + tests** + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: 413/413. + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/inbox/components/OllamaSettingsModal.tsx src/renderer/inbox/components/OllamaBanner.tsx src/renderer/inbox/App.tsx src/preload/index.ts src/shared/types.ts +git commit -m "$(cat <<'EOF' +feat(ollama): OllamaSettingsModal + App mount + OllamaBanner 설정 링크 (v0.2.3.1) + +- OllamaSettingsModal: endpoint + model freetext 입력, 저장 시 healthCheck → 성공 닫기, 실패 inline 에러 +- App.tsx: ollamaSettingsOpen state + onOpenOllamaSettings IPC subscribe +- OllamaBanner: onOpenSettings prop 추가, 우측 "설정" 버튼 +- preload + types: onOpenOllamaSettings listener bridge + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: 트레이 메뉴 + IPC push channel + +**Files:** +- Modify: `src/main/tray.ts` +- Modify: `src/main/index.ts` + +- [ ] **Step 1: Modify tray.ts — "Ollama 설정..." 메뉴** + +Read `src/main/tray.ts`. Find `createTray` 함수의 positional callback list (현재 9개). 10번째 callback `runOpenOllamaSettings` 추가. + +Find existing menu template. Add new item near other config-style items: + +```typescript +{ label: 'Ollama 설정...', click: () => runOpenOllamaSettings() }, +``` + +Add new positional parameter: +```typescript +export function createTray( + // ... 기존 9개 callbacks ... + runRetryAllFailed: () => void, + runOpenOllamaSettings: () => void // 10번째 +): void { + // ... 기존 코드 ... +} +``` + +- [ ] **Step 2: Modify index.ts — pass runOpenOllamaSettings** + +Find `createTray(...)` call in index.ts. Add 10th argument: + +```typescript +createTray( + // ... 기존 9개 ... + () => { void capture.retryAllFailed(); }, + () => { + const win = getInboxWindow(); + if (win) win.webContents.send('inbox:openOllamaSettings'); + } +); +``` + +- [ ] **Step 3: Run typecheck + tests** + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: 413/413. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/tray.ts src/main/index.ts +git commit -m "$(cat <<'EOF' +feat(ollama): 트레이 메뉴 "Ollama 설정..." (v0.2.3.1) + +- createTray 10번째 positional callback runOpenOllamaSettings +- 트레이 → 메뉴 클릭 → main 이 inbox:openOllamaSettings IPC push +- renderer App.tsx 가 구독해 modal open + +backlog #4/#26 (TrayCallbacks object refactor) 와 합산 — v0.2.4 시 일괄 정리 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Closure — version bump + roadmap mark + final gates + +**Files:** +- Modify: `package.json` (0.2.3 → 0.2.3.1) +- Modify: `package-lock.json` + +- [ ] **Step 1: Verify final gate matrix** + +Run sequentially: +```bash +npm run typecheck +npm test +npm run test:e2e +``` + +Expected: +- typecheck: 0 errors +- 단위: **413/413** +- e2e: 1/1 + +If failures, fix before proceeding. + +- [ ] **Step 2: Bump version** + +In `package.json`: +```json + "version": "0.2.3.1", +``` + +In `package-lock.json` (top + nested package "" entry): +```json + "version": "0.2.3.1", +``` + +- [ ] **Step 3: Commit version bump** + +```bash +git add package.json package-lock.json +git commit -m "$(cat <<'EOF' +chore(release): v0.2.3.1 — Ollama 설정 in-app UI (patch cut) + +dogfood unblock 패치. v0.2.3 의 INKLING_OLLAMA_ENDPOINT env var 의존 → +in-app UI (트레이 + 배너) 에서 endpoint + model 변경 가능. + +게이트: typecheck 0 / 단위 413 / e2e 1 +다음: PR + 머지 후 binary 재빌드 + Gitea release v0.2.3.1 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 4: Push branch** + +```bash +git push -u origin feat/v0231-ollama-settings +``` + +- [ ] **Step 5: Create PR via Gitea API** + +Use file-based body (UTF-8 safety) with curl. Title: `feat(ollama): v0.2.3.1 — in-app endpoint/model 설정`. Body should reference spec and decisions. + +(Implementer: this is the controller's job — report DONE and let controller handle.) + +--- + +## Self-Review (executed by plan author inline) + +**1. Spec coverage:** +- ✅ Q1=B (Endpoint + Model) → T2 model param + T5 modal 입력란 2개 +- ✅ Q2=A (Freetext) → T5 `` 단순 +- ✅ Q3=B (JSON file) → T1 SettingsService +- ✅ 7개 invariants from spec §3.1: + - 저장=검증 통과 → T4 IPC handler 의 healthCheck 분기 + - Provider mutability via re-create → T3 ProviderHolder.replace + - Settings precedence → T4 index.ts settings ?? env ?? default + - 단일 settings file + atomic write → T1 SettingsService.setOllama + - HealthChecker rebind → T3 holder.onReplace listener (HealthChecker 가 자체 등록) + - Backward compat → T1 빈 객체 fallback test + - Cross-platform → T1 SettingsService.test 가 mkdtempSync 통해 OS-agnostic 경로 사용 +- ✅ Privacy invariant — telemetry emit 없음 (spec §5) +- ✅ All "Out of scope" 항목 미구현 + +**2. Placeholder scan:** +- ✅ "TBD" / "TODO" 없음 +- T3 step 6 의 "Update affected test files" 는 검증 가능한 instruction (search + replace 패턴 명시) +- T5 step 2 의 "Adapt to existing JSX structure" 는 read 후 통합이라 actionable + +**3. Type consistency:** +- `SettingsService.load(): Promise` (T1) === T4 `await deps.settings.load()` 사용 +- `ProviderHolder` 의 `get() / replace() / onReplace()` (T3) === T4 IPC handler 의 `deps.providerHolder.get().abort()` + `.replace(trial)` +- `OllamaSettings { endpoint, model }` 시그니처가 T1/T4/T5 모두 일치 +- `LocalOllamaProvider.abort()` (T2) === T4 IPC handler 의 `providerHolder.get().abort()` + +**Test count verification:** +- T1: 6 (SettingsService) +- T2: 2 (LocalOllamaProvider — abort + model) +- T3: 2 (ProviderHolder) +- Total new: **10**. 단위 403 + 10 = **413**. + +기존 AiWorker / HealthChecker 테스트는 ProviderHolder 로 wrap 만 하면 되므로 count 영향 없음. + +--- + +## Roadmap Relation + +- v0.2.3 dogfood unblock 패치. v0.2.3.1 (patch increment, 정식 cut #1~#7 와 별개) +- 머지 후 binary 재빌드 (Windows + Mac) + Gitea release v0.2.3.1 +- ≥1주 dogfood soak → v0.2.4 brainstorm 트리거 시점에 v0.2.4 backlog 38건 + 신규 피드백 + 본 cut 의 `ollama_settings_changed` count-only telemetry 후속 결정 일괄 triage From de895b8fec5d861820cc254d553e6a48fa8d942b Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:23:32 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat(settings):=20SettingsService=20?= =?UTF-8?q?=E2=80=94=20JSON=20=EC=98=81=EC=86=8D=ED=99=94=20+=20zod=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20(v0.2.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `/settings.json` atomic write (temp + rename) - 손상 JSON / 파일 없음 → 빈 객체 fallback (no throw) - in-memory cache (load 1회 file read) - zod .strict() schema for ollama { endpoint: URL, model: string } - 단위 +6 cases Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/SettingsService.ts | 47 +++++++++++++++++++++++ tests/unit/SettingsService.test.ts | 57 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/main/services/SettingsService.ts create mode 100644 tests/unit/SettingsService.test.ts diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts new file mode 100644 index 0000000..31e4d9e --- /dev/null +++ b/src/main/services/SettingsService.ts @@ -0,0 +1,47 @@ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { z } from 'zod'; + +const OllamaSettingsSchema = z.object({ + endpoint: z.string().url(), + model: z.string().min(1) +}).strict(); + +const SettingsSchema = z.object({ + ollama: OllamaSettingsSchema.optional() +}).strict(); + +export type Settings = z.infer; +export type OllamaSettings = z.infer; + +export class SettingsService { + private filePath: string; + private cache: Settings | null = null; + + constructor(profileDir: string) { + this.filePath = join(profileDir, 'settings.json'); + } + + async load(): Promise { + if (this.cache !== null) return this.cache; + try { + const raw = await readFile(this.filePath, 'utf8'); + const parsed = JSON.parse(raw); + this.cache = SettingsSchema.parse(parsed); + } catch { + this.cache = {}; + } + return this.cache; + } + + async setOllama(value: OllamaSettings): Promise { + const validated = OllamaSettingsSchema.parse(value); + const current = await this.load(); + const next: Settings = { ...current, ollama: validated }; + await mkdir(dirname(this.filePath), { recursive: true }); + const tmpPath = this.filePath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8'); + await rename(tmpPath, this.filePath); + this.cache = next; + } +} diff --git a/tests/unit/SettingsService.test.ts b/tests/unit/SettingsService.test.ts new file mode 100644 index 0000000..923050e --- /dev/null +++ b/tests/unit/SettingsService.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SettingsService } from '@main/services/SettingsService.js'; + +describe('SettingsService', () => { + let dir: string; + let svc: SettingsService; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'inkling-settings-')); + svc = new SettingsService(dir); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('load() returns empty object when file does not exist', async () => { + const s = await svc.load(); + expect(s).toEqual({}); + }); + + it('load() returns empty object on corrupted JSON (no throw)', async () => { + writeFileSync(join(dir, 'settings.json'), '{ this is not json'); + const s = await svc.load(); + expect(s).toEqual({}); + }); + + it('load() caches result — second call does not re-read file', async () => { + await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' }); + const before = await svc.load(); + // 외부에서 파일 변경 + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ ollama: { endpoint: 'http://lan:11434', model: 'gemma4:26b' } })); + const after = await svc.load(); + // 캐시 적용 — 파일 변경 무시 + expect(after).toEqual(before); + }); + + it('setOllama() throws on non-URL endpoint', async () => { + await expect( + svc.setOllama({ endpoint: 'not-a-url', model: 'gemma4:e4b' }) + ).rejects.toThrow(); + }); + + it('setOllama() persists to disk with valid JSON', async () => { + await svc.setOllama({ endpoint: 'http://localhost:11435', model: 'gemma4:e4b' }); + const raw = readFileSync(join(dir, 'settings.json'), 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed.ollama.endpoint).toBe('http://localhost:11435'); + expect(parsed.ollama.model).toBe('gemma4:e4b'); + }); + + it('setOllama() atomic write — tmp file does not remain', async () => { + await svc.setOllama({ endpoint: 'http://localhost:11434', model: 'gemma4:e4b' }); + expect(existsSync(join(dir, 'settings.json.tmp'))).toBe(false); + expect(existsSync(join(dir, 'settings.json'))).toBe(true); + }); +}); From c77c30be83cd0aabc48b3dd546b3996d9ceb91f0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:26:48 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat(ollama):=20LocalOllamaProvider=20?= =?UTF-8?q?=E2=80=94=20abort()=20+=20AbortController=20instance=20field=20?= =?UTF-8?q?(v0.2.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - abortController 가 method-local 에서 private instance field 로 이동 - public abort() 메서드 — 외부에서 in-flight generate 강제 중단 - ProviderHolder.replace() 시 호출되어 endpoint 변경 즉시 반영 - 단위 +2 cases (abort cancellation, model 파라미터) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/LocalOllamaProvider.ts | 13 ++++++++++--- tests/unit/LocalOllamaProvider.test.ts | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index d326ad8..44ce522 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -18,6 +18,7 @@ export class LocalOllamaProvider implements InferenceProvider { private timeoutMs: number; private temperature: number; private numPredict: number; + private abortController: AbortController | null = null; constructor(opts: LocalOllamaOptions = {}) { this.endpoint = opts.endpoint ?? 'http://localhost:11434'; @@ -29,8 +30,8 @@ export class LocalOllamaProvider implements InferenceProvider { } async generate(input: GenerateInput): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.timeoutMs); + this.abortController = new AbortController(); + const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs); try { const res = await request(`${this.endpoint}/api/generate`, { method: 'POST', @@ -42,7 +43,7 @@ export class LocalOllamaProvider implements InferenceProvider { stream: false, options: { temperature: this.temperature, num_predict: this.numPredict } }), - signal: controller.signal + signal: this.abortController.signal }); if (res.statusCode < 200 || res.statusCode >= 300) { throw new Error(`ollama http ${res.statusCode}`); @@ -55,9 +56,15 @@ export class LocalOllamaProvider implements InferenceProvider { return parseAiResponse(parsed); } finally { clearTimeout(timer); + this.abortController = null; } } + /** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */ + abort(): void { + this.abortController?.abort(); + } + async healthCheck(): Promise { try { const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' }); diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts index a9d60fb..eea7c5c 100644 --- a/tests/unit/LocalOllamaProvider.test.ts +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -89,4 +89,24 @@ describe('LocalOllamaProvider', () => { expect(h.ok).toBe(false); expect(h.reason).toMatch(/connect|refused|unreachable/i); }); + + it('abort() cancels in-flight generate (rejects with AbortError)', async () => { + mock.get('http://localhost:11434').intercept({ + path: '/api/generate', method: 'POST' + }).reply((async () => { + await new Promise((r) => setTimeout(r, 5000)); // long-running + return { statusCode: 200, data: '{}' }; + }) as never); + const provider = new LocalOllamaProvider({ timeoutMs: 30_000 }); + const generatePromise = provider.generate({ + text: 'x', todayKst: '2026-05-04', dueDateCandidates: [] + }); + setTimeout(() => provider.abort(), 50); + await expect(generatePromise).rejects.toThrow(); + }); + + it('constructor uses provided model param (not just default)', () => { + const provider = new LocalOllamaProvider({ model: 'gemma4:26b' }); + expect(provider.name).toBe('local-ollama/gemma4:26b'); + }); }); From 9fef2edb6e9760ec95e79353a75de08b1e299a37 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:32:20 +0900 Subject: [PATCH 05/13] feat(ollama): ProviderHolder + AiWorker/HealthChecker refactor (v0.2.3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProviderHolder: mutable holder + listeners, indirection layer - AiWorker: constructor InferenceProvider → ProviderHolder this.provider.x → this.holder.get().x 전환 - HealthChecker: 동일 패턴 - src/main/index.ts: provider 를 ProviderHolder 로 감싸서 생성 - 기존 AiWorker / HealthChecker 테스트의 constructor 호출에 ProviderHolder wrap - 단위 +2 cases (ProviderHolder) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/AiWorker.ts | 8 ++--- src/main/ai/ProviderHolder.ts | 36 +++++++++++++++++++++ src/main/index.ts | 6 ++-- src/main/services/HealthChecker.ts | 7 ++-- tests/unit/AiWorker.test.ts | 51 +++++++++++++++--------------- tests/unit/HealthChecker.test.ts | 15 +++++---- tests/unit/ProviderHolder.test.ts | 30 ++++++++++++++++++ 7 files changed, 112 insertions(+), 41 deletions(-) create mode 100644 src/main/ai/ProviderHolder.ts create mode 100644 tests/unit/ProviderHolder.test.ts diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index d08b348..b8bd651 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -1,6 +1,6 @@ import type { NoteRepository } from '../repository/NoteRepository.js'; -import type { InferenceProvider } from './InferenceProvider.js'; import type { Note } from '@shared/types'; +import { ProviderHolder } from './ProviderHolder.js'; import { parseAllCandidates } from '../services/dueDateParser.js'; import { ZodError } from 'zod'; @@ -66,7 +66,7 @@ export class AiWorker { constructor( private repo: NoteRepository, - private provider: InferenceProvider, + private holder: ProviderHolder, opts: AiWorkerOptions = {} ) { this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000]; @@ -135,7 +135,7 @@ export class AiWorker { const todayIso = todayKstAsIso(nowDate); const candidates = parseAllCandidates(note.rawText, todayDate); const vocab = this.repo.getTopUsedTags(20); - const res = await this.provider.generate({ + const res = await this.holder.get().generate({ text: note.rawText, todayKst: todayIso, dueDateCandidates: candidates, @@ -146,7 +146,7 @@ export class AiWorker { title: res.title, summary: res.summary, tags: res.tags, - provider: this.provider.name, + provider: this.holder.get().name, dueDate: res.dueDate ?? null }); this.unreachableBackoffStep = 0; // 성공 시 step reset diff --git a/src/main/ai/ProviderHolder.ts b/src/main/ai/ProviderHolder.ts new file mode 100644 index 0000000..7e3e28e --- /dev/null +++ b/src/main/ai/ProviderHolder.ts @@ -0,0 +1,36 @@ +import type { InferenceProvider } from './InferenceProvider.js'; + +/** + * v0.2.3.1 — Mutable provider holder. AiWorker / HealthChecker 가 endpoint 변경 시 + * 새 LocalOllamaProvider 인스턴스를 받도록 indirection layer. + * + * 사용 패턴: + * const holder = new ProviderHolder(initialProvider); + * aiWorker = new AiWorker(repo, holder, opts); + * health = new HealthChecker(holder, opts); + * + * // 사용자가 Settings 저장 시: + * holder.get().abort?.(); // in-flight 중단 (LocalOllamaProvider 전용) + * holder.replace(newProvider); // 모든 consumer 가 새 인스턴스 사용 + */ +export class ProviderHolder { + private current: InferenceProvider; + private listeners: Array<(p: InferenceProvider) => void> = []; + + constructor(initial: InferenceProvider) { + this.current = initial; + } + + get(): InferenceProvider { + return this.current; + } + + replace(next: InferenceProvider): void { + this.current = next; + for (const fn of this.listeners) fn(next); + } + + onReplace(fn: (p: InferenceProvider) => void): void { + this.listeners.push(fn); + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 2f0e13a..a4ccea5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,7 @@ import { HotkeyService } from './services/HotkeyService.js'; import { IntentService } from './services/IntentService.js'; 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 { registerCaptureApi } from './ipc/captureApi.js'; import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js'; @@ -69,7 +70,8 @@ app.whenReady().then(async () => { fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined }); const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint }); - const health = new HealthChecker(provider, { + const providerHolder = new ProviderHolder(provider); + const health = new HealthChecker(providerHolder, { onUpdate: (status) => { logger.info('ai.health', { ...status } as Record); pushOllamaStatus(getInboxWindow, status); @@ -87,7 +89,7 @@ app.whenReady().then(async () => { }); health.start(); - const worker = new AiWorker(repo, provider, { + const worker = new AiWorker(repo, providerHolder, { onUpdate: (note) => { pushNoteUpdated(getInboxWindow, note); // F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신. diff --git a/src/main/services/HealthChecker.ts b/src/main/services/HealthChecker.ts index 14b3df5..e70ba03 100644 --- a/src/main/services/HealthChecker.ts +++ b/src/main/services/HealthChecker.ts @@ -1,4 +1,5 @@ -import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js'; +import type { HealthResult } from '../ai/InferenceProvider.js'; +import { ProviderHolder } from '../ai/ProviderHolder.js'; export type HealthTelemetryEvent = | { kind: 'ollama_unreachable'; reason: string } @@ -28,7 +29,7 @@ export class HealthChecker { private now: () => number; constructor( - private provider: InferenceProvider, + private holder: ProviderHolder, private opts: HealthCheckerOptions = {} ) { this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS; @@ -48,7 +49,7 @@ export class HealthChecker { } private async doRunOnce(): Promise { - const next = await this.provider.healthCheck(); + const next = await this.holder.get().healthCheck(); const prev = this.last; const okChanged = prev.ok !== next.ok; const reasonChanged = prev.reason !== next.reason; diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index afc0bbe..81fbb5f 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -6,6 +6,7 @@ import { AiWorker } from '@main/ai/AiWorker.js'; import type { AiTelemetryEmitter } from '@main/ai/AiWorker.js'; import type { InferenceProvider } from '@main/ai/InferenceProvider.js'; import type { AiResponse } from '@main/ai/schema.js'; +import { ProviderHolder } from '@main/ai/ProviderHolder.js'; type EmittedEvent = { kind: string; payload: unknown }; @@ -33,7 +34,7 @@ describe('AiWorker', () => { it('processes a pending job and marks done', async () => { const { id } = repo.create({ rawText: 'x' }); const updates: string[] = []; - const w = new AiWorker(repo, makeProvider(), { + const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0], onUpdate: (note) => updates.push(note.aiStatus) }); @@ -48,7 +49,7 @@ describe('AiWorker', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('boom'); }) }); - const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] }); await w.enqueue(id); await w.drain(); const note = repo.findById(id)!; @@ -60,7 +61,7 @@ describe('AiWorker', () => { it('loadFromDb re-queues all pending', async () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; - const w = new AiWorker(repo, makeProvider(), { backoffsMs: [0, 0, 0] }); + const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0] }); await w.loadFromDb(); await w.drain(); expect(repo.findById(a)?.aiStatus).toBe('done'); @@ -79,7 +80,7 @@ describe('AiWorker', () => { return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null }; }) }); - const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] }); for (const id of ids) await w.enqueue(id); await w.drain(); expect(max).toBe(1); @@ -96,7 +97,7 @@ describe('AiWorker', () => { }), healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T00:00:00.000Z') }); @@ -118,7 +119,7 @@ describe('AiWorker', () => { }), healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T00:00:00.000Z') }); @@ -140,7 +141,7 @@ describe('AiWorker', () => { }), healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T00:00:00.000Z') }); @@ -162,7 +163,7 @@ describe('AiWorker', () => { }, healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T15:00:00.000Z') // 04-27 00:00 KST }); @@ -184,7 +185,7 @@ describe('AiWorker', () => { }, healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T00:00:00.000Z') }); @@ -216,7 +217,7 @@ describe('AiWorker telemetry emit', () => { it('emits ai_succeeded with durationMs/attempts on success', async () => { const { id } = repo.create({ rawText: '수요일 회의 메모' }); - const w = new AiWorker(repo, makeProvider(), { + const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0], telemetry: collectingTelemetry }); @@ -236,7 +237,7 @@ describe('AiWorker telemetry emit', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10], telemetry: collectingTelemetry @@ -254,7 +255,7 @@ describe('AiWorker telemetry emit', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new ZodError([]); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: collectingTelemetry }); @@ -270,7 +271,7 @@ describe('AiWorker telemetry emit', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('mystery'); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: collectingTelemetry }); @@ -300,7 +301,7 @@ describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => { db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z'); const generate = vi.fn(); const provider = makeProvider({ generate: generate as any }); - const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] }); await w.loadFromDb(); await w.drain(); expect(generate).not.toHaveBeenCalled(); @@ -322,7 +323,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('ECONNREFUSED'); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 30_000, 120_000], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10] }); @@ -341,7 +342,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('Request timeout'); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 30_000, 120_000], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10] }); @@ -360,7 +361,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { }) }); const events: any[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: async (e) => { events.push(e); } } }); @@ -379,7 +380,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { generate: vi.fn(async () => { throw new Error('something weird'); }) }); const events: any[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: async (e) => { events.push(e); } } }); @@ -392,7 +393,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { }); it('unreachable backoff schedule — nextBackoffMs(step) cap at index 5 (15분)', async () => { - const w = new AiWorker(repo, makeProvider(), { + const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 30_000, 120_000], unreachableBackoffsMs: [30_000, 60_000, 120_000, 240_000, 480_000, 900_000] }); @@ -411,7 +412,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { return { title: 't', summary: 's', tags: [], dueDate: null }; }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10] }); @@ -443,7 +444,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { const generateMock = vi.fn(async () => ({ title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null })); - const w = new AiWorker(repo, makeProvider({ generate: generateMock }), { + const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), { backoffsMs: [0, 0, 0] }); await w.enqueue(id); @@ -467,7 +468,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { })) }); const emits: EmittedEvent[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) @@ -497,7 +498,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { })) }); const emits: EmittedEvent[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); @@ -522,7 +523,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { })) }); const emits: EmittedEvent[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); @@ -546,7 +547,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { })) }); const emits: EmittedEvent[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); diff --git a/tests/unit/HealthChecker.test.ts b/tests/unit/HealthChecker.test.ts index b3f0b84..95c8251 100644 --- a/tests/unit/HealthChecker.test.ts +++ b/tests/unit/HealthChecker.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HealthChecker, type HealthTelemetryEvent } from '@main/services/HealthChecker.js'; import type { InferenceProvider, HealthResult, GenerateInput } from '@main/ai/InferenceProvider.js'; import type { AiResponse } from '@main/ai/schema.js'; +import { ProviderHolder } from '@main/ai/ProviderHolder.js'; class FakeProvider implements InferenceProvider { readonly name = 'fake'; @@ -24,7 +25,7 @@ describe('HealthChecker — start/stop polling', () => { it('start() runs runOnce immediately + every intervalMs', async () => { const provider = new FakeProvider(); provider.results = [{ ok: true }, { ok: true }, { ok: true }]; - const hc = new HealthChecker(provider, { intervalMs: 1000 }); + const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 }); hc.start(); await vi.runOnlyPendingTimersAsync(); await vi.advanceTimersByTimeAsync(1000); @@ -36,7 +37,7 @@ describe('HealthChecker — start/stop polling', () => { it('start() is idempotent — second call does not duplicate timer', async () => { const provider = new FakeProvider(); provider.results = [{ ok: true }]; - const hc = new HealthChecker(provider, { intervalMs: 1000 }); + const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 }); hc.start(); hc.start(); // 즉시 1회 + 1s 후 1회 = 정확히 2. 두 timer 가 잘못 등록됐으면 4 (각 timer 마다 즉시+1s). @@ -48,7 +49,7 @@ describe('HealthChecker — start/stop polling', () => { it('stop() clears timer (no further runOnce)', async () => { const provider = new FakeProvider(); provider.results = [{ ok: true }, { ok: true }]; - const hc = new HealthChecker(provider, { intervalMs: 1000 }); + const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 }); hc.start(); await vi.runOnlyPendingTimersAsync(); const before = (provider as any).idx; @@ -64,7 +65,7 @@ describe('HealthChecker — delta transitions + telemetry', () => { provider.results = [{ ok: true }, { ok: false, reason: 'connection refused' }]; const updates: HealthResult[] = []; const events: HealthTelemetryEvent[] = []; - const hc = new HealthChecker(provider, { + const hc = new HealthChecker(new ProviderHolder(provider), { onUpdate: (s) => updates.push(s), onTelemetry: (e) => events.push(e) }); @@ -79,7 +80,7 @@ describe('HealthChecker — delta transitions + telemetry', () => { provider.results = [{ ok: false, reason: 'refused' }, { ok: true }]; const events: HealthTelemetryEvent[] = []; let nowCounter = 0; - const hc = new HealthChecker(provider, { + const hc = new HealthChecker(new ProviderHolder(provider), { onTelemetry: (e) => events.push(e), now: () => { nowCounter += 1; return nowCounter * 1000; } }); @@ -97,7 +98,7 @@ describe('HealthChecker — delta transitions + telemetry', () => { ]; const updates: HealthResult[] = []; const events: HealthTelemetryEvent[] = []; - const hc = new HealthChecker(provider, { + const hc = new HealthChecker(new ProviderHolder(provider), { onUpdate: (s) => updates.push(s), onTelemetry: (e) => events.push(e) }); @@ -111,7 +112,7 @@ describe('HealthChecker — delta transitions + telemetry', () => { const provider = new FakeProvider(); provider.results = [{ ok: true }]; const events: HealthTelemetryEvent[] = []; - const hc = new HealthChecker(provider, { onTelemetry: (e) => events.push(e) }); + const hc = new HealthChecker(new ProviderHolder(provider), { onTelemetry: (e) => events.push(e) }); await hc.runOnce({ manual: true }); expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]); }); diff --git a/tests/unit/ProviderHolder.test.ts b/tests/unit/ProviderHolder.test.ts new file mode 100644 index 0000000..83b77fe --- /dev/null +++ b/tests/unit/ProviderHolder.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ProviderHolder } from '@main/ai/ProviderHolder.js'; +import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js'; + +describe('ProviderHolder', () => { + it('replace() fires listener and get() returns new instance', () => { + const a = new LocalOllamaProvider({ endpoint: 'http://a:11434', model: 'm1' }); + const b = new LocalOllamaProvider({ endpoint: 'http://b:11434', model: 'm2' }); + const holder = new ProviderHolder(a); + const listener = vi.fn(); + holder.onReplace(listener); + expect(holder.get()).toBe(a); + holder.replace(b); + expect(holder.get()).toBe(b); + expect(listener).toHaveBeenCalledWith(b); + }); + + it('multiple listeners all fire on replace()', () => { + const a = new LocalOllamaProvider({ model: 'm1' }); + const b = new LocalOllamaProvider({ model: 'm2' }); + const holder = new ProviderHolder(a); + const l1 = vi.fn(); + const l2 = vi.fn(); + holder.onReplace(l1); + holder.onReplace(l2); + holder.replace(b); + expect(l1).toHaveBeenCalledWith(b); + expect(l2).toHaveBeenCalledWith(b); + }); +}); From d1f36250e707a0f3f1fa57d84d7402a4992c85df Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:34:17 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix(ollama):=20InferenceProvider=20?= =?UTF-8?q?=E2=80=94=20abort=3F:=20()=20=3D>=20void=20optional=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(T3=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3 가 ProviderHolder 를 InferenceProvider 로 추상화. 단 IPC handler 가 holder.get().abort() 호출 예정 — interface 에 method 가 없으면 typecheck 실패. abort 는 in-flight generate 중단용이라 모든 provider 가 지원할 필요는 없음 → optional method 로 추가. caller 는 holder.get().abort?.() 패턴 사용. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/InferenceProvider.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/ai/InferenceProvider.ts b/src/main/ai/InferenceProvider.ts index f3aa2ef..941d9a7 100644 --- a/src/main/ai/InferenceProvider.ts +++ b/src/main/ai/InferenceProvider.ts @@ -14,4 +14,6 @@ export interface InferenceProvider { readonly name: string; generate(input: GenerateInput): Promise; healthCheck(): Promise; + /** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */ + abort?: () => void; } From cee39a90aac390dfaa2e974255f3d542935470e9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:36:46 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat(ollama):=20index=20=EB=B6=80?= =?UTF-8?q?=ED=8C=85=20+=20IPC=20+=20preload=20+=20types=20(v0.2.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.ts: SettingsService.load() 후 endpoint/model 결정 (settings > env > default) - IPC: inbox:loadOllamaSettings + inbox:saveOllamaSettings - save: 임시 provider 로 healthCheck 통과 시에만 영속화 + holder.replace - 기존 in-flight generate 는 abort?.() (optional method) - preload + InboxApi shared types Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/index.ts | 20 ++++++++++++++++---- src/main/ipc/inboxApi.ts | 22 ++++++++++++++++++++++ src/preload/index.ts | 4 +++- src/shared/types.ts | 2 ++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index a4ccea5..f5c0d56 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import { ExportService } from './services/ExportService.js'; import { ImportService } from './services/ImportService.js'; import { SyncService } from './services/SyncService.js'; import { TelemetryService } from './services/TelemetryService.js'; +import { SettingsService } from './services/SettingsService.js'; const HIDDEN_ARG = '--hidden'; const startedHidden = process.argv.includes(HIDDEN_ARG); @@ -64,12 +65,23 @@ app.whenReady().then(async () => { const continuity = new ContinuityService(db); const intent = new IntentService(repo); - const resolvedEndpoint = process.env.INKLING_OLLAMA_ENDPOINT ?? 'http://localhost:11434'; + const settingsSvc = new SettingsService(paths.profileDir); + const settings = await settingsSvc.load(); + + const resolvedEndpoint = settings.ollama?.endpoint + ?? process.env.INKLING_OLLAMA_ENDPOINT + ?? 'http://localhost:11434'; + const resolvedModel = settings.ollama?.model ?? 'gemma4:e4b'; + logger.info('ai.endpoint', { endpoint: resolvedEndpoint, - fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined + model: resolvedModel, + source: settings.ollama?.endpoint + ? 'settings' + : (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default') }); - const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint }); + + const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel }); const providerHolder = new ProviderHolder(provider); const health = new HealthChecker(providerHolder, { onUpdate: (status) => { @@ -116,7 +128,7 @@ app.whenReady().then(async () => { registerCaptureApi(capture, getQuickCaptureWindow); registerInboxApi({ repo, continuity, capture, health, intent, - getInboxWindow + getInboxWindow, settings: settingsSvc, providerHolder }); const hotkeys = new HotkeyService(); diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 5579cb8..312de17 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -8,6 +8,9 @@ import type { HealthChecker } from '../services/HealthChecker.js'; import type { IntentService } from '../services/IntentService.js'; import type { Note } from '@shared/types'; import type { HealthResult } from '../ai/InferenceProvider.js'; +import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js'; +import type { SettingsService } from '../services/SettingsService.js'; +import type { ProviderHolder } from '../ai/ProviderHolder.js'; export interface InboxIpcDeps { repo: NoteRepository; @@ -16,6 +19,8 @@ export interface InboxIpcDeps { health: HealthChecker; intent: IntentService; getInboxWindow: () => BrowserWindow | null; + settings: SettingsService; + providerHolder: ProviderHolder; } export function registerInboxApi(deps: InboxIpcDeps): void { @@ -142,6 +147,23 @@ export function registerInboxApi(deps: InboxIpcDeps): void { ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id)); ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id)); ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id)); + + ipcMain.handle('inbox:loadOllamaSettings', async () => { + const s = await deps.settings.load(); + return s.ollama ?? null; + }); + + ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { + // 검증: 새 인스턴스로 healthCheck + const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); + const r = await trial.healthCheck(); + if (!r.ok) return { ok: false, reason: r.reason ?? 'unknown' }; + await deps.settings.setOllama(value); + deps.providerHolder.get().abort?.(); + deps.providerHolder.replace(trial); + // HealthChecker 의 다음 polling cycle (~60s) 에 새 endpoint 반영 + return { ok: true }; + }); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { diff --git a/src/preload/index.ts b/src/preload/index.ts index eaa043f..b81e134 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,7 +44,9 @@ const api: InklingApi = { 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) + emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id), + loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'), + saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v) } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index e1dccea..1ff4146 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -89,6 +89,8 @@ export interface InboxApi { dismissRecall(id: string): Promise<{ note: Note }>; emitRecallShown(id: string): Promise; emitRecallSnoozed(id: string): Promise; + loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>; + saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>; } export interface InklingApi { From 4153284af1b1e8b0a1c19e4e9686d147d8117d41 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:37:57 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix(ollama):=20saveOllamaSettings=20?= =?UTF-8?q?=EA=B0=80=20health.runOnce()=20=EC=A6=89=EC=8B=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20(T4=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T4 fallback comment "60s polling cycle" 대신 HealthChecker 의 기존 public method runOnce() 사용. 사용자가 settings 저장하자마자 OllamaBanner 갱신. runOnce 는 이미 inbox:ollamaRecheck IPC 가 사용 중인 패턴. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ipc/inboxApi.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 312de17..644317e 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -161,7 +161,8 @@ export function registerInboxApi(deps: InboxIpcDeps): void { await deps.settings.setOllama(value); deps.providerHolder.get().abort?.(); deps.providerHolder.replace(trial); - // HealthChecker 의 다음 polling cycle (~60s) 에 새 endpoint 반영 + // 즉시 health 재확인 → onUpdate callback 통해 OllamaBanner 자동 갱신 + await deps.health.runOnce(); return { ok: true }; }); } From 833a59836817b5da9a657671ce70713ba3100ebf Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:40:31 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat(ollama):=20OllamaSettingsModal=20+?= =?UTF-8?q?=20App=20mount=20+=20OllamaBanner=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20(v0.2.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OllamaSettingsModal: endpoint + model freetext 입력, 저장 시 healthCheck → 성공 닫기, 실패 inline 에러 - App.tsx: ollamaSettingsOpen state + onOpenOllamaSettings IPC subscribe - OllamaBanner: onOpenSettings prop 추가, 우측 "설정" 버튼 - preload + types: onOpenOllamaSettings listener bridge Co-Authored-By: Claude Opus 4.7 (1M context) --- src/preload/index.ts | 7 +- src/renderer/inbox/App.tsx | 11 +- .../inbox/components/OllamaBanner.tsx | 19 ++- .../inbox/components/OllamaSettingsModal.tsx | 117 ++++++++++++++++++ src/shared/types.ts | 1 + 5 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 src/renderer/inbox/components/OllamaSettingsModal.tsx diff --git a/src/preload/index.ts b/src/preload/index.ts index b81e134..1ba817c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -46,7 +46,12 @@ const api: InklingApi = { emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id), emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id), loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'), - saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v) + saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v), + onOpenOllamaSettings: (cb: () => void) => { + const handler = () => cb(); + ipcRenderer.on('inbox:openOllamaSettings', handler); + return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler); + }, } }; diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index b9b8719..0926b68 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -12,6 +12,7 @@ import { TagUndoToast } from './components/TagUndoToast.js'; import { ExpiryBanner } from './components/ExpiryBanner.js'; import { FailedBanner } from './components/FailedBanner.js'; import { RecallBanner } from './components/RecallBanner.js'; +import { OllamaSettingsModal } from './components/OllamaSettingsModal.js'; export function App(): React.ReactElement { const { @@ -21,6 +22,7 @@ export function App(): React.ReactElement { toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash } = useInbox(); const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); + const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false); useEffect(() => { void loadInitial(); @@ -31,9 +33,10 @@ export function App(): React.ReactElement { const unsubOllama = inboxApi.onOllamaStatus((status) => { useInbox.setState({ ollamaStatus: status }); }); + const unsubOllamaSettings = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true)); const onFocus = () => { void refreshMeta(); }; window.addEventListener('focus', onFocus); - return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); }; + return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); window.removeEventListener('focus', onFocus); }; // onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라 // deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제. }, [loadInitial, refreshMeta, upsertNote]); @@ -79,7 +82,7 @@ export function App(): React.ReactElement {
{!showTrash && ( <> - + setOllamaSettingsOpen(true)} /> { markRecoveryDismissed(); setRecoveryDismissed(true); }} @@ -155,6 +158,10 @@ export function App(): React.ReactElement { )}
+ setOllamaSettingsOpen(false)} + /> ); } diff --git a/src/renderer/inbox/components/OllamaBanner.tsx b/src/renderer/inbox/components/OllamaBanner.tsx index 0ecfd6d..2de6fa8 100644 --- a/src/renderer/inbox/components/OllamaBanner.tsx +++ b/src/renderer/inbox/components/OllamaBanner.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { useInbox } from '../store.js'; -export function OllamaBanner(): React.ReactElement | null { +interface OllamaBannerProps { + onOpenSettings?: () => void; +} + +export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.ReactElement | null { const status = useInbox((s) => s.ollamaStatus); const recheckOllama = useInbox((s) => s.recheckOllama); if (status.ok) return null; @@ -28,6 +32,19 @@ export function OllamaBanner(): React.ReactElement | null { > 재확인 + {onOpenSettings && ( + + )} {status.reason ? ( diff --git a/src/renderer/inbox/components/OllamaSettingsModal.tsx b/src/renderer/inbox/components/OllamaSettingsModal.tsx new file mode 100644 index 0000000..ac39122 --- /dev/null +++ b/src/renderer/inbox/components/OllamaSettingsModal.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../api.js'; + +interface Props { + open: boolean; + onClose: () => void; +} + +export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null { + const [endpoint, setEndpoint] = useState('http://localhost:11434'); + const [model, setModel] = useState('gemma4:e4b'); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + // 마운트/open 시 현재 설정 fetch + useEffect(() => { + if (!open) return; + void inboxApi.loadOllamaSettings().then((s) => { + if (s) { + setEndpoint(s.endpoint); + setModel(s.model); + } + setError(null); + }); + }, [open]); + + if (!open) return null; + + async function handleSave() { + setSaving(true); + setError(null); + try { + const r = await inboxApi.saveOllamaSettings({ endpoint, model }); + if (r.ok) { + onClose(); + } else { + setError(r.reason); + } + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + } + + return ( +
+
+

Ollama 설정

+
+ + setEndpoint(e.target.value)} + placeholder="http://localhost:11434" + style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }} + disabled={saving} + /> +
+
+ + setModel(e.target.value)} + placeholder="gemma4:e4b" + style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }} + disabled={saving} + /> +
+ {error && ( +
+ 저장 실패: {error} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 1ff4146..dd79b2c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -91,6 +91,7 @@ export interface InboxApi { emitRecallSnoozed(id: string): Promise; loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>; saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>; + onOpenOllamaSettings(cb: () => void): () => void; } export interface InklingApi { From 0c0327ddb6af52ef0579271e9c6f085e38904160 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:43:00 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat(ollama):=20=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EB=A9=94=EB=89=B4=20"Ollama=20=EC=84=A4=EC=A0=95..?= =?UTF-8?q?."=20(v0.2.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createTray 10번째 positional callback runOpenOllamaSettings - 트레이 → 메뉴 클릭 → main 이 inbox:openOllamaSettings IPC push - renderer App.tsx 가 구독해 modal open backlog #4/#26 (TrayCallbacks object refactor) 와 합산 — v0.2.4 시 일괄 정리 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/index.ts | 6 +++++- src/main/tray.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index f5c0d56..14a16c1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -359,7 +359,11 @@ app.whenReady().then(async () => { } }, /* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); }, - /* runRetryAllFailed */ () => { void capture.retryAllFailed(); } + /* runRetryAllFailed */ () => { void capture.retryAllFailed(); }, + /* runOpenOllamaSettings */ () => { + const win = getInboxWindow(); + if (win) win.webContents.send('inbox:openOllamaSettings'); + } ); // F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신. diff --git a/src/main/tray.ts b/src/main/tray.ts index 9d7e8bc..38af617 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -15,6 +15,7 @@ let _ollamaOk = true; let _todayCount = 0; let _runRetryAllFailed: () => void = () => {}; let _failedCount = 0; +let _runOpenOllamaSettings: () => void = () => {}; function buildMenu() { const items: MenuItemConstructorOptions[] = []; @@ -41,6 +42,7 @@ function buildMenu() { enabled: _failedCount > 0, click: _runRetryAllFailed }); + items.push({ label: 'Ollama 설정...', click: () => _runOpenOllamaSettings() }); if (app.isPackaged) { const { openAtLogin } = app.getLoginItemSettings(); items.push({ @@ -71,7 +73,8 @@ export function createTray( runSync: () => void, runExportTelemetry: () => void, runOllamaRecheck: () => void, - runRetryAllFailed: () => void + runRetryAllFailed: () => void, + runOpenOllamaSettings: () => void ): TrayType { _showInbox = showInbox; _showCapture = showCapture; @@ -82,6 +85,7 @@ export function createTray( _runExportTelemetry = runExportTelemetry; _runOllamaRecheck = runOllamaRecheck; _runRetryAllFailed = runRetryAllFailed; + _runOpenOllamaSettings = runOpenOllamaSettings; const icon = nativeImage.createEmpty(); tray = new Tray(icon); tray.setToolTip(`Inkling — 오늘 ${_todayCount}`); From 3a2ff1a35c35638ce9b0cd8a7fa45acb7a13174b Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:44:45 +0900 Subject: [PATCH 11/13] =?UTF-8?q?chore(release):=20v0.2.3.1=20=E2=80=94=20?= =?UTF-8?q?Ollama=20=EC=84=A4=EC=A0=95=20in-app=20UI=20(patch=20cut)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dogfood unblock 패치. v0.2.3 의 INKLING_OLLAMA_ENDPOINT env var 의존 → in-app UI (트레이 + 배너) 에서 endpoint + model 변경 가능. 게이트: typecheck 0 / 단위 413 / e2e 1 다음: PR + 머지 후 binary 재빌드 + Gitea release v0.2.3.1 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4c7491c..0371cbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inkling", - "version": "0.2.3", + "version": "0.2.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inkling", - "version": "0.2.3", + "version": "0.2.3.1", "dependencies": { "better-sqlite3": "12.9.0", "electron-log": "5.2.0", diff --git a/package.json b/package.json index afb2889..864cd91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkling", - "version": "0.2.3", + "version": "0.2.3.1", "private": true, "description": "Inkling — local-first 한 줄 보관 도구", "author": "altair823 ", From 6f95e894562f4b18f616c67dfd478ceaa1571fcc Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:53:42 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix(ollama):=20PR=20#21=20review=20round?= =?UTF-8?q?=201=20=E2=80=94=20m1+m3+m4+n1=20(v0.2.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - m1 (Minor): saveOllamaSettings IPC가 setOllama throw 시 try/catch → { ok: false, reason: 'persist failed: ...' } 대칭 응답 - m3 (Minor): Modal ESC=close + Enter=save 키 핸들러 + 첫 input autoFocus - m4 (Minor): handleSave 첫 줄 if (saving) return; — sync double-click 가드 - n1 (Nit): 'gemma4:e4b' / 'http://localhost:11434' magic → src/shared/constants.ts 의 DEFAULT_OLLAMA_MODEL / DEFAULT_OLLAMA_ENDPOINT defer to v0.2.4 backlog: - m2: ollama_unreachable.reason 에 endpoint URL 노출 (PII 우회) — telemetry masking 정책 skip: - i1 (race UX): acknowledge only, 정확성 영향 0 - m5 (abort try/catch): 현재 LocalOllamaProvider.abort 는 throw X - m6 (first-boot blocking): 무시 가능 - n2 (offReplace): 현재 listener callsite 0건 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/LocalOllamaProvider.ts | 5 +++-- src/main/index.ts | 5 +++-- src/main/ipc/inboxApi.ts | 6 ++++- .../inbox/components/OllamaSettingsModal.tsx | 22 ++++++++++++++----- src/shared/constants.ts | 2 ++ 5 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 src/shared/constants.ts diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index 44ce522..b276b23 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -2,6 +2,7 @@ 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 { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js'; export interface LocalOllamaOptions { endpoint?: string; @@ -21,8 +22,8 @@ export class LocalOllamaProvider implements InferenceProvider { private abortController: AbortController | null = null; constructor(opts: LocalOllamaOptions = {}) { - this.endpoint = opts.endpoint ?? 'http://localhost:11434'; - this.model = opts.model ?? 'gemma4:e4b'; + this.endpoint = opts.endpoint ?? DEFAULT_OLLAMA_ENDPOINT; + this.model = opts.model ?? DEFAULT_OLLAMA_MODEL; this.timeoutMs = opts.timeoutMs ?? 120_000; this.temperature = opts.temperature ?? 0.2; this.numPredict = opts.numPredict ?? 512; diff --git a/src/main/index.ts b/src/main/index.ts index 14a16c1..f4f3932 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,6 +31,7 @@ import { ImportService } from './services/ImportService.js'; import { SyncService } from './services/SyncService.js'; import { TelemetryService } from './services/TelemetryService.js'; import { SettingsService } from './services/SettingsService.js'; +import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js'; const HIDDEN_ARG = '--hidden'; const startedHidden = process.argv.includes(HIDDEN_ARG); @@ -70,8 +71,8 @@ app.whenReady().then(async () => { const resolvedEndpoint = settings.ollama?.endpoint ?? process.env.INKLING_OLLAMA_ENDPOINT - ?? 'http://localhost:11434'; - const resolvedModel = settings.ollama?.model ?? 'gemma4:e4b'; + ?? DEFAULT_OLLAMA_ENDPOINT; + const resolvedModel = settings.ollama?.model ?? DEFAULT_OLLAMA_MODEL; logger.info('ai.endpoint', { endpoint: resolvedEndpoint, diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 644317e..97a9819 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -158,7 +158,11 @@ export function registerInboxApi(deps: InboxIpcDeps): void { const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); const r = await trial.healthCheck(); if (!r.ok) return { ok: false, reason: r.reason ?? 'unknown' }; - await deps.settings.setOllama(value); + try { + await deps.settings.setOllama(value); + } catch (e) { + return { ok: false, reason: `persist failed: ${(e as Error).message}` }; + } deps.providerHolder.get().abort?.(); deps.providerHolder.replace(trial); // 즉시 health 재확인 → onUpdate callback 통해 OllamaBanner 자동 갱신 diff --git a/src/renderer/inbox/components/OllamaSettingsModal.tsx b/src/renderer/inbox/components/OllamaSettingsModal.tsx index ac39122..5eadc22 100644 --- a/src/renderer/inbox/components/OllamaSettingsModal.tsx +++ b/src/renderer/inbox/components/OllamaSettingsModal.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { inboxApi } from '../api.js'; +import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../../shared/constants.js'; interface Props { open: boolean; @@ -7,8 +8,8 @@ interface Props { } export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null { - const [endpoint, setEndpoint] = useState('http://localhost:11434'); - const [model, setModel] = useState('gemma4:e4b'); + const [endpoint, setEndpoint] = useState(DEFAULT_OLLAMA_ENDPOINT); + const [model, setModel] = useState(DEFAULT_OLLAMA_MODEL); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); @@ -27,6 +28,7 @@ export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElemen if (!open) return null; async function handleSave() { + if (saving) return; // m4 fix: synchronous double-click 가드 setSaving(true); setError(null); try { @@ -44,10 +46,17 @@ export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElemen } return ( -
+
{ + if (e.key === 'Escape' && !saving) onClose(); + if (e.key === 'Enter' && !saving) void handleSave(); + }} + tabIndex={-1} + style={{ + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', + display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 + }} + >
setEndpoint(e.target.value)} placeholder="http://localhost:11434" + autoFocus style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }} disabled={saving} /> diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 0000000..a258531 --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_OLLAMA_MODEL = 'gemma4:e4b'; +export const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434'; From d974335ee41fb77a11e156b867d780ddd0fd47bf Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:54:50 +0900 Subject: [PATCH 13/13] =?UTF-8?q?docs(backlog):=20v0.2.3.1=20round=201=20r?= =?UTF-8?q?eview=20m2/i1=20+=20=EC=8B=A0=EA=B7=9C=20=ED=95=AD=EB=AA=A9=205?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #21 round 1 review 에서 deferred 항목들 backlog 38 → 43: - #39 (m2): ollama_unreachable.reason 의 endpoint URL PII 우회 노출 - #40 (i1): save vs HealthChecker tick race UX flicker - #41: OllamaSettingsModal 인라인 스타일 (#24 와 합산) - #42: Modal client-side URL validation 부재 - #43: createTray 10번째 positional callback (#4/#26 blocker) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/superpowers/v024-backlog.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/superpowers/v024-backlog.md b/docs/superpowers/v024-backlog.md index 3b014e4..b9ba10e 100644 --- a/docs/superpowers/v024-backlog.md +++ b/docs/superpowers/v024-backlog.md @@ -111,6 +111,18 @@ v0.2.4 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로: 37. **NoteCard `id="note-${id}"` load-bearing** — RecallBanner 의 `scrollIntoView` target. 단순 DOM lookup 이라 shadow DOM / portal 미지원. v0.2.4 에서 다른 surface (예: 검색 결과에서 스크롤) 등장 시 ref-forwarding 패턴 검토. +## v0.2.3.1 Ollama Settings 누적 (2026-05-04) + +39. **`ollama_unreachable.reason` 에 endpoint URL 노출 (PII 우회)** — `LocalOllamaProvider.healthCheck` 가 catch err 시 `reason: \`unreachable: ${err.message}\`` 로 emit. `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능. v0.2.3.1 의 in-app endpoint UI 가 LAN 사용을 흔하게 만들어 PII 우회 노출 경로 확대. v0.2.4 telemetry 하드닝 시: error class only (network/dns/timeout/...) 또는 host 마스킹 (`:11434`) 정책. PR #21 round 1 m2 deferred. + +40. **Settings 저장 vs HealthChecker 60s tick race** — `saveOllamaSettings` IPC 가 `health.runOnce()` 호출, 동시에 60s 주기 tick 도 `inFlight` 가드 통해 같이 실행 시도. 정확성 영향 0 (가드로 dedup), 단 modal 닫기 직전 banner flicker 가능. PR #21 round 1 i1 acknowledge only. v0.2.4 dogfood 에서 실제 빈도 확인 후 결정 (visible 빈도 낮으면 무시). + +41. **`OllamaSettingsModal` 인라인 스타일** — 60+ 줄 inline style. backlog #24 (banner CSS 추출) 와 합산. v0.2.4 에서 CSS module / theme variables 추출 시 함께. + +42. **Modal 의 client-side URL validation 부재** — endpoint freetext 가 잘못된 형식 (예: 빈 문자열, 한글) 일 때 server-side healthCheck 만 검증. zod URL error message 가 opaque ("Invalid url"). v0.2.4 에서 client-side z.string().url() pre-check + 친화적 에러 메시지. + +43. **`createTray` 10번째 positional callback** — v0.2.3.1 cut 에서 10개 도달 (`runOpenOllamaSettings` 추가). backlog #4/#26 (TrayCallbacks object refactor) blocker 수준. v0.2.4 첫 cleanup 항목 후보. + ## post-cut next-step (status, not backlog) 38. **v0.2.3 cut 7/7 완료 → binary 빌드 단계** — slice §7 strict-pin patch 증분으로 v0.2.3 binary 빌드 + dogfood 핸드오프. ≥1주 soak 후 telemetry export 분석으로 v0.2.4 brainstorm 트리거. (✓ 2026-05-02 빌드 완료, hotfix #20 + publish:null 포함, release 재생성 완료)