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>
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
- 저장 = 검증 통과 전제 — healthCheck ok=false 면 JSON 안 씀. 사용자 잘못된 값 영속화 방지
- Provider mutability via re-create —
setEndpoint()메서드 추가 X.ProviderHolder가 새 인스턴스 보유, listeners 알림.AbortController가 in-flight 중단 - Settings precedence: settings.json > env var > hardcoded default. UI 가 source of truth
- 단일 settings file —
<profileDir>/settings.json. atomic write (writeFiletemp →rename). 손상 시 빈 객체 fallback (no app crash) - HealthChecker rebind —
ProviderHolder.onReplace통해 새 provider 받아 polling endpoint 즉시 갱신 - Backward compat — settings.json 없는 첫 부팅: env var → default 순. 기존 사용자 영향 0
- 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 + 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
// 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.ts 의 createTray 가 10번째 positional callback 받음 → backlog #4/#26 (TrayCallbacks object refactor) 와 합산 가능. 본 cut 에선 일관성 우선 positional 추가:
{ label: 'Ollama 설정...', click: () => runOpenOllamaSettings() }
index.ts 가 runOpenOllamaSettings = () => 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_changedevent 추가 시 endpoint URL 노출 → privacy 위반 → emit 안 함 (본 cut) - 향후 v0.2.4 dogfood telemetry 에서 변경 빈도 측정 필요 시
{ count: number }payload (URL 자체 X) 형태로 추가
6. Tests (≥10개)
SettingsService.test.ts (신규, 6)
load()파일 없음 → 빈 객체load()손상 JSON (parse 실패) → 빈 객체 fallback (no throw)load()캐시 동작 — 두 번째 호출 시 file read 안 함setOllama()zod 검증 실패 (non-URL endpoint) → throwsetOllama()정상 저장 → 디스크 file 존재 + 내용 일치setOllama()atomic write — temp file 남지 않음 (rename 후 cleanup)
ProviderHolder.test.ts (신규, 2)
replace()시 listener 발화 +get()가 새 인스턴스 반환- listener 여러 개 등록 시 모두 발화
LocalOllamaProvider.test.ts (확장, 2)
abort()호출 시 in-flightgenerate()rejects (AbortError)- constructor
model파라미터 적용 (defaultgemma4: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_changedtelemetry — 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.getPathmock 으로 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.3tag →v0.2.3.1신규 tag, release 별도) - ≥1주 dogfood soak 후 telemetry export + 신규 피드백 + v0.2.4 backlog 38건 일괄 triage → v0.2.4 brainstorm