# 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-create** — `setEndpoint()` 메서드 추가 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 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<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`

```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<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` + `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 `<dialog>` 또는 portal — 별도 BrowserWindow X (단순함)

### 4.7 OllamaBanner "설정" 링크

기존 `OllamaBanner.tsx` 에 endpoint 변경 링크 추가:
```typescript
<button onClick={() => setSettingsOpen(true)}>설정</button>
```
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
