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>
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: InferenceProvider → private 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
onOpenOllamaSettingsto 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) === T4await deps.settings.load()사용ProviderHolder의get() / 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_changedcount-only telemetry 후속 결정 일괄 triage