Files
inkling/docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md
altair823 97ca119b55 docs(ollama-settings): v0.2.3.1 spec — in-app endpoint/model 설정
mini-brainstorm 3개 결정:
- Q1=B: Endpoint + Model 둘 다 포함
- Q2=A: Freetext input (dropdown 은 v0.2.4 영역)
- Q3=B: JSON file (`<profileDir>/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) <noreply@anthropic.com>
2026-05-04 23:17:23 +09:00

13 KiB

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 (<profileDir>/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-createsetEndpoint() 메서드 추가 X. ProviderHolder 가 새 인스턴스 보유, listeners 알림. AbortController 가 in-flight 중단
  3. Settings precedence: settings.json > env var > hardcoded default. UI 가 source of truth
  4. 단일 settings file<profileDir>/settings.json. atomic write (writeFile temp → rename). 손상 시 빈 객체 fallback (no app crash)
  5. HealthChecker rebindProviderHolder.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

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 = {}; // 파일 없음 또는 손상 → 빈 객체 fallback
    }
    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;
  }
}

4.2 ProviderHolder (신규)

파일: src/main/ai/ProviderHolder.ts

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 (수정)

export class LocalOllamaProvider implements InferenceProvider {
  private abortController: AbortController | null = null;

  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
      });
      // ... 기존 응답 처리 ...
    } finally {
      clearTimeout(timer);
      this.abortController = null;
    }
  }

  abort(): void {
    this.abortController?.abort();
  }
}

기존 signal: controller.signal 부분에서 controller 가 method-local 이었음 → this.abortController 로 이동 (외부 abort 가능).

4.4 AiWorker + HealthCheckerProviderHolder 사용

AiWorker constructor: provider: InferenceProviderprivate holder: ProviderHolder

  • processJobthis.provider.generate(...)this.holder.get().generate(...)
  • provider: this.provider.nameprovider: this.holder.get().name

HealthChecker 도 동일 패턴 + onReplace listener 등록 → 새 provider 즉시 polling.

4.5 IPC + Preload + InboxApi types

// 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 };
});
// src/preload/index.ts
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
// 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 <dialog> 또는 portal — 별도 BrowserWindow X (단순함)

4.7 OllamaBanner "설정" 링크

기존 OllamaBanner.tsx 에 endpoint 변경 링크 추가:

<button onClick={() => setSettingsOpen(true)}>설정</button>

modal state 는 App.tsx 가 보유 + OllamaBanner 와 OllamaSettingsModal 둘 다에 넘김.

4.8 트레이 메뉴 + IPC 채널

tray.tscreateTray 가 10번째 positional callback 받음 → backlog #4/#26 (TrayCallbacks object refactor) 와 합산 가능. 본 cut 에선 일관성 우선 positional 추가:

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

index.tsrunOpenOllamaSettings = () => mainWindow.webContents.send('inbox:openOllamaSettings') 푸시. Renderer App.tsx 가 이 channel 구독해 modal 열기.

4.9 index.ts 부팅 흐름 변경

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)

  1. replace() 시 listener 발화 + get() 가 새 인스턴스 반환
  2. listener 여러 개 등록 시 모두 발화

LocalOllamaProvider.test.ts (확장, 2)

  1. abort() 호출 시 in-flight generate() rejects (AbortError)
  2. 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