From 97ca119b55a4d2cca8f1bf5e8f27e66532f3620b Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:17:23 +0900 Subject: [PATCH] =?UTF-8?q?docs(ollama-settings):=20v0.2.3.1=20spec=20?= =?UTF-8?q?=E2=80=94=20in-app=20endpoint/model=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mini-brainstorm 3개 결정: - Q1=B: Endpoint + Model 둘 다 포함 - Q2=A: Freetext input (dropdown 은 v0.2.4 영역) - Q3=B: JSON file (`/settings.json`, migration v4 회피) 자명 결정 (질문 없이 패턴): - precedence: settings > env > default - in-flight: AbortController abort + provider re-create - UI: 트레이 + OllamaBanner 진입점, React modal - validation: save 전 healthCheck Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-04-v0231-ollama-settings-design.md | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md diff --git a/docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md b/docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md new file mode 100644 index 0000000..1f375f0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md @@ -0,0 +1,334 @@ +# v0.2.3.1 Ollama 설정 In-App UI — Design Spec + +> 작성: 2026-05-04 · v0.2.3 dogfood unblock 용 patch cut. 환경변수 의존 제거, 사용자 친화 endpoint/model 변경 path. + +## 1. Goal + +Inkling 사용자가 트레이 메뉴 / OllamaBanner 에서 Ollama endpoint + model 을 직접 변경 가능하도록. 현재는 `INKLING_OLLAMA_ENDPOINT` env var 만 지원 — Windows 의 dynamic port 점유 (Hyper-V/WSL2 NAT) 같은 환경 이슈에 즉시 대응 못함. patch cut 으로 dogfood unblock 후 1주 soak 진입. + +## 2. Decisions (mini-brainstorm 합의) + +| # | 질문 | 선택 | 이유 | +|---|---|---|---| +| Q1 | Model 설정 포함 여부 | **B** Endpoint + Model 둘 다 | endpoint 만으로는 커버 불충분 (LAN 서버 fallback 시 model 도 다를 수 있음) | +| Q2 | Model input 형태 | **A** Freetext | "급한" patch cut, healthCheck 가 검증, dropdown 은 v0.2.4 영역 | +| Q3 | Settings 영속화 | **B** JSON file (`/settings.json`) | migration v4 회피, 항목 2개 라 transaction 불필요, user 직접 편집 가능 | + +자명 결정 (질문 없이 패턴 따름): +- env var precedence: **settings > env > default** +- in-flight job 처리: **AbortController abort + provider re-create** +- UI placement: **트레이 메뉴 "Ollama 설정..." + OllamaBanner 의 "설정" 링크** +- validation: **save 전 healthCheck** + +## 3. Architecture & data flow + +``` +사용자 액션: + 1. 트레이 → "Ollama 설정..." 클릭 (또는 OllamaBanner 의 "설정" 링크) + 2. Settings modal 열림 — endpoint + model 입력란 + "저장" 버튼 + 3. 사용자 endpoint/model 입력 → "저장" 클릭 + +저장 흐름: + ├─ Renderer: inboxApi.saveOllamaSettings({ endpoint, model }) + ├─ Main IPC: 임시 LocalOllamaProvider 생성 → healthCheck() + │ ├─ ok=true → JSON 영속화 + provider/health 교체 + │ └─ ok=false → 저장 거부 + reason 반환 (modal 안에 inline 에러) + ├─ 기존 in-flight AI job 처리: + │ └─ AbortController abort → 현재 generate 중단 → unreachable 분류 → + │ AiWorker 의 무한 retry 가 새 endpoint 로 재시도 (자동 회복) + └─ HealthChecker.recheck() → OllamaBanner 즉시 갱신 + +부팅 흐름 (precedence: settings > env > default): + index.ts: + const settings = await settingsSvc.load() // JSON 또는 빈 객체 + const endpoint = settings.ollama?.endpoint + ?? process.env.INKLING_OLLAMA_ENDPOINT + ?? 'http://localhost:11434' + const model = settings.ollama?.model ?? 'gemma4:e4b' +``` + +### 3.1 핵심 invariants + +1. **저장 = 검증 통과 전제** — healthCheck ok=false 면 JSON 안 씀. 사용자 잘못된 값 영속화 방지 +2. **Provider mutability via re-create** — `setEndpoint()` 메서드 추가 X. `ProviderHolder` 가 새 인스턴스 보유, listeners 알림. `AbortController` 가 in-flight 중단 +3. **Settings precedence**: settings.json > env var > hardcoded default. UI 가 source of truth +4. **단일 settings file** — `/settings.json`. atomic write (`writeFile` temp → `rename`). 손상 시 빈 객체 fallback (no app crash) +5. **HealthChecker rebind** — `ProviderHolder.onReplace` 통해 새 provider 받아 polling endpoint 즉시 갱신 +6. **Backward compat** — settings.json 없는 첫 부팅: env var → default 순. 기존 사용자 영향 0 +7. **Cross-platform 자동** — `app.getPath('userData')` + `node:path.join` + `node:fs/promises` 가 OS 별 경로/separator/UTF-8 자동. 별도 분기 0 + +## 4. Components + +### 4.1 `SettingsService` (신규) + +**파일**: `src/main/services/SettingsService.ts` + +```typescript +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { z } from 'zod'; + +const OllamaSettingsSchema = z.object({ + endpoint: z.string().url(), + model: z.string().min(1) +}).strict(); + +const SettingsSchema = z.object({ + ollama: OllamaSettingsSchema.optional() +}).strict(); + +export type Settings = z.infer; +export type OllamaSettings = z.infer; + +export class SettingsService { + private filePath: string; + private cache: Settings | null = null; + + constructor(profileDir: string) { + this.filePath = join(profileDir, 'settings.json'); + } + + async load(): Promise { + if (this.cache !== null) return this.cache; + try { + const raw = await readFile(this.filePath, 'utf8'); + const parsed = JSON.parse(raw); + this.cache = SettingsSchema.parse(parsed); + } catch { + this.cache = {}; // 파일 없음 또는 손상 → 빈 객체 fallback + } + return this.cache; + } + + async setOllama(value: OllamaSettings): Promise { + const validated = OllamaSettingsSchema.parse(value); + const current = await this.load(); + const next: Settings = { ...current, ollama: validated }; + await mkdir(dirname(this.filePath), { recursive: true }); + const tmpPath = this.filePath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8'); + await rename(tmpPath, this.filePath); + this.cache = next; + } +} +``` + +### 4.2 `ProviderHolder` (신규) + +**파일**: `src/main/ai/ProviderHolder.ts` + +```typescript +import type { LocalOllamaProvider } from './LocalOllamaProvider.js'; + +export class ProviderHolder { + private current: LocalOllamaProvider; + private listeners: Array<(p: LocalOllamaProvider) => void> = []; + + constructor(initial: LocalOllamaProvider) { + this.current = initial; + } + + get(): LocalOllamaProvider { + return this.current; + } + + replace(next: LocalOllamaProvider): void { + this.current = next; + for (const fn of this.listeners) fn(next); + } + + onReplace(fn: (p: LocalOllamaProvider) => void): void { + this.listeners.push(fn); + } +} +``` + +### 4.3 `LocalOllamaProvider` AbortController + 사용처 변경 + +**파일**: `src/main/ai/LocalOllamaProvider.ts` (수정) + +```typescript +export class LocalOllamaProvider implements InferenceProvider { + private abortController: AbortController | null = null; + + async generate(input: GenerateInput): Promise { + this.abortController = new AbortController(); + const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs); + try { + const res = await request(`${this.endpoint}/api/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: this.model, + prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []), + format: 'json', + stream: false, + options: { temperature: this.temperature, num_predict: this.numPredict } + }), + signal: this.abortController.signal + }); + // ... 기존 응답 처리 ... + } finally { + clearTimeout(timer); + this.abortController = null; + } + } + + abort(): void { + this.abortController?.abort(); + } +} +``` + +기존 `signal: controller.signal` 부분에서 `controller` 가 method-local 이었음 → `this.abortController` 로 이동 (외부 abort 가능). + +### 4.4 `AiWorker` + `HealthChecker` 가 `ProviderHolder` 사용 + +`AiWorker` constructor: `provider: InferenceProvider` → `private holder: ProviderHolder` +- `processJob` 내 `this.provider.generate(...)` → `this.holder.get().generate(...)` +- `provider: this.provider.name` → `provider: this.holder.get().name` + +`HealthChecker` 도 동일 패턴 + `onReplace` listener 등록 → 새 provider 즉시 polling. + +### 4.5 IPC + Preload + InboxApi types + +```typescript +// src/main/ipc/inboxApi.ts +ipcMain.handle('inbox:loadOllamaSettings', async () => { + const s = await deps.settings.load(); + return s.ollama ?? null; +}); + +ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { + // 검증: 새 인스턴스로 healthCheck + const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); + const r = await trial.healthCheck(); + if (!r.ok) return { ok: false, reason: r.reason }; + await deps.settings.setOllama(value); + // in-flight 중단 후 holder 교체 + deps.providerHolder.get().abort(); + deps.providerHolder.replace(trial); + await deps.health.recheck(); + return { ok: true }; +}); +``` + +```typescript +// src/preload/index.ts +loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'), +saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v), +``` + +```typescript +// src/shared/types.ts InboxApi +loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>; +saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>; +``` + +### 4.6 `OllamaSettingsModal` 컴포넌트 + +**파일**: `src/renderer/inbox/components/OllamaSettingsModal.tsx` + +- Props: `open: boolean`, `onClose: () => void` +- 입력란 2개 (endpoint, model) + "저장" / "취소" +- 마운트 시 `inboxApi.loadOllamaSettings()` → 초기값 prefill +- 저장 시 `saveOllamaSettings(...)` → 성공 닫기 + 토스트, 실패 inline 에러 +- React `` 또는 portal — 별도 BrowserWindow X (단순함) + +### 4.7 OllamaBanner "설정" 링크 + +기존 `OllamaBanner.tsx` 에 endpoint 변경 링크 추가: +```typescript + +``` +modal state 는 App.tsx 가 보유 + OllamaBanner 와 OllamaSettingsModal 둘 다에 넘김. + +### 4.8 트레이 메뉴 + IPC 채널 + +`tray.ts` 의 `createTray` 가 10번째 positional callback 받음 → backlog #4/#26 (TrayCallbacks object refactor) 와 합산 가능. 본 cut 에선 일관성 우선 positional 추가: + +```typescript +{ label: 'Ollama 설정...', click: () => runOpenOllamaSettings() } +``` + +`index.ts` 가 `runOpenOllamaSettings = () => mainWindow.webContents.send('inbox:openOllamaSettings')` 푸시. Renderer App.tsx 가 이 channel 구독해 modal 열기. + +### 4.9 `index.ts` 부팅 흐름 변경 + +```typescript +const settingsSvc = new SettingsService(paths.profileDir); +const settings = await settingsSvc.load(); + +const resolvedEndpoint = settings.ollama?.endpoint + ?? process.env.INKLING_OLLAMA_ENDPOINT + ?? 'http://localhost:11434'; +const resolvedModel = settings.ollama?.model ?? 'gemma4:e4b'; + +logger.info('ai.endpoint', { + endpoint: resolvedEndpoint, + source: settings.ollama?.endpoint + ? 'settings' + : (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default') +}); + +const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel }); +const holder = new ProviderHolder(provider); + +const health = new HealthChecker(holder, { ... }); +const aiWorker = new AiWorker(repo, holder, { ... }); +``` + +## 5. Privacy invariant + +- `settings.json` 은 local only, telemetry emit X +- 잠재적 `ollama_settings_changed` event 추가 시 endpoint URL 노출 → privacy 위반 → **emit 안 함** (본 cut) +- 향후 v0.2.4 dogfood telemetry 에서 변경 빈도 측정 필요 시 `{ count: number }` payload (URL 자체 X) 형태로 추가 + +## 6. Tests (≥10개) + +### SettingsService.test.ts (신규, 6) +1. `load()` 파일 없음 → 빈 객체 +2. `load()` 손상 JSON (parse 실패) → 빈 객체 fallback (no throw) +3. `load()` 캐시 동작 — 두 번째 호출 시 file read 안 함 +4. `setOllama()` zod 검증 실패 (non-URL endpoint) → throw +5. `setOllama()` 정상 저장 → 디스크 file 존재 + 내용 일치 +6. `setOllama()` atomic write — temp file 남지 않음 (rename 후 cleanup) + +### ProviderHolder.test.ts (신규, 2) +7. `replace()` 시 listener 발화 + `get()` 가 새 인스턴스 반환 +8. listener 여러 개 등록 시 모두 발화 + +### LocalOllamaProvider.test.ts (확장, 2) +9. `abort()` 호출 시 in-flight `generate()` rejects (AbortError) +10. constructor `model` 파라미터 적용 (default `gemma4:e4b` 외 임의 model) + +총 신규 단위 **10개**. 기존 403 + 10 = **413**. + +(Renderer modal 컴포넌트 단위 테스트 X — Inkling 패턴 따라 store-only. IPC handler 자체도 service-level test 가 logic 보유.) + +## 7. Out of scope + +- Multi-provider abstraction (OpenAI, Anthropic, etc) — strategy.md local-first 정책 충돌, v0.2.4+ +- Settings UI 안에서 다른 기능 (telemetry retention, vocab top-N 등) — 별 cut +- Cross-machine settings sync — 단일 머신 dogfood 패턴 +- Model dropdown / 자동 list refresh — Q2=A 결정 (freetext) +- `ollama pull` 자동 안내 — over-scope +- Settings export/import / version migration — over-scope +- Settings 변경 history / undo — over-scope +- Settings UI 안에 model healthCheck 결과 시각화 (loading spinner 등) — minimal toast 만 +- `ollama_settings_changed` telemetry — privacy invariant 보호 (v0.2.4 검토 시 count-only) +- Settings 변경 로그 파일 — env-debug 영역, v0.2.4 검토 + +## 8. Gates (roadmap §3.1) + +- typecheck 0 +- 단위 403 → 413 (+10) +- e2e 1/1 (smoke 회귀 X) +- backward compat: settings.json 없는 부팅 → env var → default 폴백 정상 +- cross-platform: SettingsService.test 가 `app.getPath` mock 으로 Win/macOS/Linux 시뮬레이션 (별도 case 또는 path matrix) + +## 9. Roadmap relation + +- v0.2.3 dogfood unblock 패치. 정식 v0.2.3 cut (#1~#7) 와 별개 patch +- 머지 후 v0.2.3.1 binary 재빌드 + Gitea release (existing `v0.2.3` tag → `v0.2.3.1` 신규 tag, release 별도) +- ≥1주 dogfood soak 후 telemetry export + 신규 피드백 + v0.2.4 backlog 38건 일괄 triage → v0.2.4 brainstorm