From 71ec79ae19a05c2c530ce23d5dbe59897168d595 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:21:00 +0900 Subject: [PATCH] =?UTF-8?q?docs(ollama-settings):=20v0.2.3.1=20plan=20?= =?UTF-8?q?=E2=80=94=207=20tasks=20TDD=20+=2010=20=EB=8B=A8=EC=9C=84=20cas?= =?UTF-8?q?es?= 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