Files
inkling/docs/superpowers/plans/2026-05-04-v0231-ollama-settings.md
altair823 71ec79ae19 docs(ollama-settings): v0.2.3.1 plan — 7 tasks TDD + 10 단위 cases
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) <noreply@anthropic.com>
2026-05-04 23:21:00 +09:00

38 KiB

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:

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:

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<typeof SettingsSchema>;
export type OllamaSettings = z.infer<typeof OllamaSettingsSchema>;

export class SettingsService {
  private filePath: string;
  private cache: Settings | null = null;

  constructor(profileDir: string) {
    this.filePath = join(profileDir, 'settings.json');
  }

  async load(): Promise<Settings> {
    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<void> {
    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:

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

- `<profileDir>/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) <noreply@anthropic.com>
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:

  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<void>((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:

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<AiResponse> {
    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<HealthResult> {
    // 기존 구현 유지
    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
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) <noreply@anthropic.com>
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:

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:

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:

  constructor(
    private repo: NoteRepository,
    private provider: InferenceProvider,
    opts: AiWorkerOptions = {}
  ) {

Replace with:

  constructor(
    private repo: NoteRepository,
    private holder: ProviderHolder,
    opts: AiWorkerOptions = {}
  ) {

(Add import { ProviderHolder } from './ProviderHolder.js'; at top if not present.)

Then in processJob, find:

        const res = await this.provider.generate({
          text: note.rawText,
          todayKst: todayIso,
          dueDateCandidates: candidates,
          vocab
        });

Replace with:

        const res = await this.holder.get().generate({
          text: note.rawText,
          todayKst: todayIso,
          dueDateCandidates: candidates,
          vocab
        });

And:

        this.repo.updateAiResult(job.noteId, {
          title: res.title,
          summary: res.summary,
          tags: res.tags,
          provider: this.provider.name,
          ...

Replace with:

        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: InferenceProviderprivate 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:

const worker = new AiWorker(repo, makeProvider(), { ... });

Replace with:

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
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) <noreply@anthropic.com>
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):

  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:

  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:

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:

  const health = new HealthChecker(provider, { ... });
  ...
  const aiWorker = new AiWorker(repo, provider, { ... });

Replace with:

  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):

  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:

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:

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:

    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:

  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
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) <noreply@anthropic.com>
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:

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<string | null>(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 (
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
      display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
    }}>
      <div style={{
        background: '#fff', borderRadius: 8, padding: 20, minWidth: 400, maxWidth: 500,
        boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
      }}>
        <h2 style={{ margin: '0 0 12px 0', fontSize: 16 }}>Ollama 설정</h2>
        <div style={{ marginBottom: 12 }}>
          <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
            Endpoint URL
          </label>
          <input
            type="text"
            value={endpoint}
            onChange={(e) => setEndpoint(e.target.value)}
            placeholder="http://localhost:11434"
            style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
            disabled={saving}
          />
        </div>
        <div style={{ marginBottom: 12 }}>
          <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
            Model
          </label>
          <input
            type="text"
            value={model}
            onChange={(e) => setModel(e.target.value)}
            placeholder="gemma4:e4b"
            style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
            disabled={saving}
          />
        </div>
        {error && (
          <div style={{
            background: '#fce4e4', color: '#a33', padding: '6px 10px', borderRadius: 4,
            fontSize: 12, marginBottom: 12
          }}>
            저장 실패: {error}
          </div>
        )}
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <button
            onClick={onClose}
            disabled={saving}
            style={{
              background: 'transparent', color: '#666',
              border: '1px solid #ccc', borderRadius: 4,
              padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
            }}
          >
            취소
          </button>
          <button
            onClick={() => void handleSave()}
            disabled={saving}
            style={{
              background: saving ? '#999' : '#0a4b80', color: '#fff',
              border: 'none', borderRadius: 4,
              padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
            }}
          >
            {saving ? '검증 중...' : '저장'}
          </button>
        </div>
      </div>
    </div>
  );
}
  • Step 2: Modify App.tsx — mount modal + open state + IPC channel listener

Read src/renderer/inbox/App.tsx. Add modal state + handler:

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 (
    <>
      <div className="header">{/* ... */}</div>
      {/* ... */}
      <OllamaBanner onOpenSettings={() => setOllamaSettingsOpen(true)} />
      {/* ... 기존 banner stack ... */}
      <OllamaSettingsModal
        open={ollamaSettingsOpen}
        onClose={() => 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:

    onOpenOllamaSettings: (cb: () => void) => {
      const handler = () => cb();
      ipcRenderer.on('inbox:openOllamaSettings', handler);
      return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler);
    },

In src/shared/types.ts InboxApi:

  onOpenOllamaSettings(cb: () => void): () => void;
  • Step 4: Modify OllamaBanner.tsx — "설정" 링크 추가

Read src/renderer/inbox/components/OllamaBanner.tsx. Add prop + button:

interface OllamaBannerProps {
  onOpenSettings?: () => void;
}

export function OllamaBanner({ onOpenSettings }: OllamaBannerProps): React.ReactElement | null {
  // ... 기존 ...

  // 기존 Banner JSX 안에 적절한 위치에 button 추가:
  return (
    <div style={{ /* 기존 style */ }}>
      <span>{/* 기존 메시지 */}</span>
      {onOpenSettings && (
        <button
          onClick={onOpenSettings}
          style={{
            marginLeft: 'auto',
            background: 'transparent', color: 'inherit',
            border: '1px solid currentColor', borderRadius: 4,
            padding: '2px 8px', fontSize: 12, cursor: 'pointer'
          }}
        >
          설정
        </button>
      )}
      {/* 기존 재확인 버튼 등 */}
    </div>
  );
}

(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
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) <noreply@anthropic.com>
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:

{ label: 'Ollama 설정...', click: () => runOpenOllamaSettings() },

Add new positional parameter:

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:

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
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) <noreply@anthropic.com>
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:

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:

  "version": "0.2.3.1",

In package-lock.json (top + nested package "" entry):

  "version": "0.2.3.1",
  • Step 3: Commit version bump
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) <noreply@anthropic.com>
EOF
)"
  • Step 4: Push branch
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 <input type="text"> 단순
  • 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<Settings> (T1) === T4 await deps.settings.load() 사용
  • ProviderHolderget() / 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