Merge pull request 'feat(ollama): v0.2.3.1 — in-app endpoint/model 설정' (#21) from feat/v0231-ollama-settings into main
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
1156
docs/superpowers/plans/2026-05-04-v0231-ollama-settings.md
Normal file
1156
docs/superpowers/plans/2026-05-04-v0231-ollama-settings.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 (`<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
|
||||
@@ -111,6 +111,18 @@ v0.2.4 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
|
||||
|
||||
37. **NoteCard `id="note-${id}"` load-bearing** — RecallBanner 의 `scrollIntoView` target. 단순 DOM lookup 이라 shadow DOM / portal 미지원. v0.2.4 에서 다른 surface (예: 검색 결과에서 스크롤) 등장 시 ref-forwarding 패턴 검토.
|
||||
|
||||
## v0.2.3.1 Ollama Settings 누적 (2026-05-04)
|
||||
|
||||
39. **`ollama_unreachable.reason` 에 endpoint URL 노출 (PII 우회)** — `LocalOllamaProvider.healthCheck` 가 catch err 시 `reason: \`unreachable: ${err.message}\`` 로 emit. `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능. v0.2.3.1 의 in-app endpoint UI 가 LAN 사용을 흔하게 만들어 PII 우회 노출 경로 확대. v0.2.4 telemetry 하드닝 시: error class only (network/dns/timeout/...) 또는 host 마스킹 (`<host>:11434`) 정책. PR #21 round 1 m2 deferred.
|
||||
|
||||
40. **Settings 저장 vs HealthChecker 60s tick race** — `saveOllamaSettings` IPC 가 `health.runOnce()` 호출, 동시에 60s 주기 tick 도 `inFlight` 가드 통해 같이 실행 시도. 정확성 영향 0 (가드로 dedup), 단 modal 닫기 직전 banner flicker 가능. PR #21 round 1 i1 acknowledge only. v0.2.4 dogfood 에서 실제 빈도 확인 후 결정 (visible 빈도 낮으면 무시).
|
||||
|
||||
41. **`OllamaSettingsModal` 인라인 스타일** — 60+ 줄 inline style. backlog #24 (banner CSS 추출) 와 합산. v0.2.4 에서 CSS module / theme variables 추출 시 함께.
|
||||
|
||||
42. **Modal 의 client-side URL validation 부재** — endpoint freetext 가 잘못된 형식 (예: 빈 문자열, 한글) 일 때 server-side healthCheck 만 검증. zod URL error message 가 opaque ("Invalid url"). v0.2.4 에서 client-side z.string().url() pre-check + 친화적 에러 메시지.
|
||||
|
||||
43. **`createTray` 10번째 positional callback** — v0.2.3.1 cut 에서 10개 도달 (`runOpenOllamaSettings` 추가). backlog #4/#26 (TrayCallbacks object refactor) blocker 수준. v0.2.4 첫 cleanup 항목 후보.
|
||||
|
||||
## post-cut next-step (status, not backlog)
|
||||
|
||||
38. **v0.2.3 cut 7/7 완료 → binary 빌드 단계** — slice §7 strict-pin patch 증분으로 v0.2.3 binary 빌드 + dogfood 핸드오프. ≥1주 soak 후 telemetry export 분석으로 v0.2.4 brainstorm 트리거. (✓ 2026-05-02 빌드 완료, hotfix #20 + publish:null 포함, release 재생성 완료)
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.3.1",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.3.1",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { InferenceProvider } from './InferenceProvider.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import { ProviderHolder } from './ProviderHolder.js';
|
||||
import { parseAllCandidates } from '../services/dueDateParser.js';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
@@ -66,7 +66,7 @@ export class AiWorker {
|
||||
|
||||
constructor(
|
||||
private repo: NoteRepository,
|
||||
private provider: InferenceProvider,
|
||||
private holder: ProviderHolder,
|
||||
opts: AiWorkerOptions = {}
|
||||
) {
|
||||
this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000];
|
||||
@@ -135,7 +135,7 @@ export class AiWorker {
|
||||
const todayIso = todayKstAsIso(nowDate);
|
||||
const candidates = parseAllCandidates(note.rawText, todayDate);
|
||||
const vocab = this.repo.getTopUsedTags(20);
|
||||
const res = await this.provider.generate({
|
||||
const res = await this.holder.get().generate({
|
||||
text: note.rawText,
|
||||
todayKst: todayIso,
|
||||
dueDateCandidates: candidates,
|
||||
@@ -146,7 +146,7 @@ export class AiWorker {
|
||||
title: res.title,
|
||||
summary: res.summary,
|
||||
tags: res.tags,
|
||||
provider: this.provider.name,
|
||||
provider: this.holder.get().name,
|
||||
dueDate: res.dueDate ?? null
|
||||
});
|
||||
this.unreachableBackoffStep = 0; // 성공 시 step reset
|
||||
|
||||
@@ -14,4 +14,6 @@ export interface InferenceProvider {
|
||||
readonly name: string;
|
||||
generate(input: GenerateInput): Promise<AiResponse>;
|
||||
healthCheck(): Promise<HealthResult>;
|
||||
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
|
||||
abort?: () => void;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { request } from 'undici';
|
||||
import { parseAiResponse, type AiResponse } from './schema.js';
|
||||
import { buildPrompt } from './prompt.js';
|
||||
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';
|
||||
|
||||
export interface LocalOllamaOptions {
|
||||
endpoint?: string;
|
||||
@@ -18,10 +19,11 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
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.endpoint = opts.endpoint ?? DEFAULT_OLLAMA_ENDPOINT;
|
||||
this.model = opts.model ?? DEFAULT_OLLAMA_MODEL;
|
||||
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
this.temperature = opts.temperature ?? 0.2;
|
||||
this.numPredict = opts.numPredict ?? 512;
|
||||
@@ -29,8 +31,8 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
}
|
||||
|
||||
async generate(input: GenerateInput): Promise<AiResponse> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
this.abortController = new AbortController();
|
||||
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
method: 'POST',
|
||||
@@ -42,7 +44,7 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
}),
|
||||
signal: controller.signal
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw new Error(`ollama http ${res.statusCode}`);
|
||||
@@ -55,9 +57,15 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
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' });
|
||||
|
||||
36
src/main/ai/ProviderHolder.ts
Normal file
36
src/main/ai/ProviderHolder.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { InferenceProvider } from './InferenceProvider.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);
|
||||
* health = new HealthChecker(holder, opts);
|
||||
*
|
||||
* // 사용자가 Settings 저장 시:
|
||||
* holder.get().abort?.(); // in-flight 중단 (LocalOllamaProvider 전용)
|
||||
* holder.replace(newProvider); // 모든 consumer 가 새 인스턴스 사용
|
||||
*/
|
||||
export class ProviderHolder {
|
||||
private current: InferenceProvider;
|
||||
private listeners: Array<(p: InferenceProvider) => void> = [];
|
||||
|
||||
constructor(initial: InferenceProvider) {
|
||||
this.current = initial;
|
||||
}
|
||||
|
||||
get(): InferenceProvider {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
replace(next: InferenceProvider): void {
|
||||
this.current = next;
|
||||
for (const fn of this.listeners) fn(next);
|
||||
}
|
||||
|
||||
onReplace(fn: (p: InferenceProvider) => void): void {
|
||||
this.listeners.push(fn);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { HotkeyService } from './services/HotkeyService.js';
|
||||
import { IntentService } from './services/IntentService.js';
|
||||
import { HealthChecker } from './services/HealthChecker.js';
|
||||
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
|
||||
import { ProviderHolder } from './ai/ProviderHolder.js';
|
||||
import { AiWorker } from './ai/AiWorker.js';
|
||||
import { registerCaptureApi } from './ipc/captureApi.js';
|
||||
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
|
||||
@@ -29,6 +30,8 @@ import { ExportService } from './services/ExportService.js';
|
||||
import { ImportService } from './services/ImportService.js';
|
||||
import { SyncService } from './services/SyncService.js';
|
||||
import { TelemetryService } from './services/TelemetryService.js';
|
||||
import { SettingsService } from './services/SettingsService.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
|
||||
|
||||
const HIDDEN_ARG = '--hidden';
|
||||
const startedHidden = process.argv.includes(HIDDEN_ARG);
|
||||
@@ -63,13 +66,25 @@ app.whenReady().then(async () => {
|
||||
const continuity = new ContinuityService(db);
|
||||
const intent = new IntentService(repo);
|
||||
|
||||
const resolvedEndpoint = process.env.INKLING_OLLAMA_ENDPOINT ?? 'http://localhost:11434';
|
||||
const settingsSvc = new SettingsService(paths.profileDir);
|
||||
const settings = await settingsSvc.load();
|
||||
|
||||
const resolvedEndpoint = settings.ollama?.endpoint
|
||||
?? process.env.INKLING_OLLAMA_ENDPOINT
|
||||
?? DEFAULT_OLLAMA_ENDPOINT;
|
||||
const resolvedModel = settings.ollama?.model ?? DEFAULT_OLLAMA_MODEL;
|
||||
|
||||
logger.info('ai.endpoint', {
|
||||
endpoint: resolvedEndpoint,
|
||||
fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined
|
||||
model: resolvedModel,
|
||||
source: settings.ollama?.endpoint
|
||||
? 'settings'
|
||||
: (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default')
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint });
|
||||
const health = new HealthChecker(provider, {
|
||||
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
|
||||
const providerHolder = new ProviderHolder(provider);
|
||||
const health = new HealthChecker(providerHolder, {
|
||||
onUpdate: (status) => {
|
||||
logger.info('ai.health', { ...status } as Record<string, unknown>);
|
||||
pushOllamaStatus(getInboxWindow, status);
|
||||
@@ -87,7 +102,7 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
health.start();
|
||||
|
||||
const worker = new AiWorker(repo, provider, {
|
||||
const worker = new AiWorker(repo, providerHolder, {
|
||||
onUpdate: (note) => {
|
||||
pushNoteUpdated(getInboxWindow, note);
|
||||
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
|
||||
@@ -114,7 +129,7 @@ app.whenReady().then(async () => {
|
||||
registerCaptureApi(capture, getQuickCaptureWindow);
|
||||
registerInboxApi({
|
||||
repo, continuity, capture, health, intent,
|
||||
getInboxWindow
|
||||
getInboxWindow, settings: settingsSvc, providerHolder
|
||||
});
|
||||
|
||||
const hotkeys = new HotkeyService();
|
||||
@@ -345,7 +360,11 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
},
|
||||
/* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); },
|
||||
/* runRetryAllFailed */ () => { void capture.retryAllFailed(); }
|
||||
/* runRetryAllFailed */ () => { void capture.retryAllFailed(); },
|
||||
/* runOpenOllamaSettings */ () => {
|
||||
const win = getInboxWindow();
|
||||
if (win) win.webContents.send('inbox:openOllamaSettings');
|
||||
}
|
||||
);
|
||||
|
||||
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { HealthChecker } from '../services/HealthChecker.js';
|
||||
import type { IntentService } from '../services/IntentService.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { HealthResult } from '../ai/InferenceProvider.js';
|
||||
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import type { ProviderHolder } from '../ai/ProviderHolder.js';
|
||||
|
||||
export interface InboxIpcDeps {
|
||||
repo: NoteRepository;
|
||||
@@ -16,6 +19,8 @@ export interface InboxIpcDeps {
|
||||
health: HealthChecker;
|
||||
intent: IntentService;
|
||||
getInboxWindow: () => BrowserWindow | null;
|
||||
settings: SettingsService;
|
||||
providerHolder: ProviderHolder;
|
||||
}
|
||||
|
||||
export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
@@ -142,6 +147,28 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
|
||||
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
|
||||
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
|
||||
|
||||
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' };
|
||||
try {
|
||||
await deps.settings.setOllama(value);
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `persist failed: ${(e as Error).message}` };
|
||||
}
|
||||
deps.providerHolder.get().abort?.();
|
||||
deps.providerHolder.replace(trial);
|
||||
// 즉시 health 재확인 → onUpdate callback 통해 OllamaBanner 자동 갱신
|
||||
await deps.health.runOnce();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js';
|
||||
import type { HealthResult } from '../ai/InferenceProvider.js';
|
||||
import { ProviderHolder } from '../ai/ProviderHolder.js';
|
||||
|
||||
export type HealthTelemetryEvent =
|
||||
| { kind: 'ollama_unreachable'; reason: string }
|
||||
@@ -28,7 +29,7 @@ export class HealthChecker {
|
||||
private now: () => number;
|
||||
|
||||
constructor(
|
||||
private provider: InferenceProvider,
|
||||
private holder: ProviderHolder,
|
||||
private opts: HealthCheckerOptions = {}
|
||||
) {
|
||||
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
@@ -48,7 +49,7 @@ export class HealthChecker {
|
||||
}
|
||||
|
||||
private async doRunOnce(): Promise<HealthResult> {
|
||||
const next = await this.provider.healthCheck();
|
||||
const next = await this.holder.get().healthCheck();
|
||||
const prev = this.last;
|
||||
const okChanged = prev.ok !== next.ok;
|
||||
const reasonChanged = prev.reason !== next.reason;
|
||||
|
||||
47
src/main/services/SettingsService.ts
Normal file
47
src/main/services/SettingsService.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ let _ollamaOk = true;
|
||||
let _todayCount = 0;
|
||||
let _runRetryAllFailed: () => void = () => {};
|
||||
let _failedCount = 0;
|
||||
let _runOpenOllamaSettings: () => void = () => {};
|
||||
|
||||
function buildMenu() {
|
||||
const items: MenuItemConstructorOptions[] = [];
|
||||
@@ -41,6 +42,7 @@ function buildMenu() {
|
||||
enabled: _failedCount > 0,
|
||||
click: _runRetryAllFailed
|
||||
});
|
||||
items.push({ label: 'Ollama 설정...', click: () => _runOpenOllamaSettings() });
|
||||
if (app.isPackaged) {
|
||||
const { openAtLogin } = app.getLoginItemSettings();
|
||||
items.push({
|
||||
@@ -71,7 +73,8 @@ export function createTray(
|
||||
runSync: () => void,
|
||||
runExportTelemetry: () => void,
|
||||
runOllamaRecheck: () => void,
|
||||
runRetryAllFailed: () => void
|
||||
runRetryAllFailed: () => void,
|
||||
runOpenOllamaSettings: () => void
|
||||
): TrayType {
|
||||
_showInbox = showInbox;
|
||||
_showCapture = showCapture;
|
||||
@@ -82,6 +85,7 @@ export function createTray(
|
||||
_runExportTelemetry = runExportTelemetry;
|
||||
_runOllamaRecheck = runOllamaRecheck;
|
||||
_runRetryAllFailed = runRetryAllFailed;
|
||||
_runOpenOllamaSettings = runOpenOllamaSettings;
|
||||
const icon = nativeImage.createEmpty();
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
|
||||
|
||||
@@ -44,7 +44,14 @@ const api: InklingApi = {
|
||||
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
|
||||
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
|
||||
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
|
||||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id)
|
||||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
|
||||
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
|
||||
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
|
||||
onOpenOllamaSettings: (cb: () => void) => {
|
||||
const handler = () => cb();
|
||||
ipcRenderer.on('inbox:openOllamaSettings', handler);
|
||||
return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TagUndoToast } from './components/TagUndoToast.js';
|
||||
import { ExpiryBanner } from './components/ExpiryBanner.js';
|
||||
import { FailedBanner } from './components/FailedBanner.js';
|
||||
import { RecallBanner } from './components/RecallBanner.js';
|
||||
import { OllamaSettingsModal } from './components/OllamaSettingsModal.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
@@ -21,6 +22,7 @@ export function App(): React.ReactElement {
|
||||
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
|
||||
} = useInbox();
|
||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||
const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
@@ -31,9 +33,10 @@ export function App(): React.ReactElement {
|
||||
const unsubOllama = inboxApi.onOllamaStatus((status) => {
|
||||
useInbox.setState({ ollamaStatus: status });
|
||||
});
|
||||
const unsubOllamaSettings = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true));
|
||||
const onFocus = () => { void refreshMeta(); };
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
|
||||
return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); window.removeEventListener('focus', onFocus); };
|
||||
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
|
||||
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
|
||||
}, [loadInitial, refreshMeta, upsertNote]);
|
||||
@@ -79,7 +82,7 @@ export function App(): React.ReactElement {
|
||||
<main className="main">
|
||||
{!showTrash && (
|
||||
<>
|
||||
<OllamaBanner />
|
||||
<OllamaBanner onOpenSettings={() => setOllamaSettingsOpen(true)} />
|
||||
<RecoveryToast
|
||||
show={showRecovery}
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
@@ -155,6 +158,10 @@ export function App(): React.ReactElement {
|
||||
)}
|
||||
</main>
|
||||
<TagUndoToast />
|
||||
<OllamaSettingsModal
|
||||
open={ollamaSettingsOpen}
|
||||
onClose={() => setOllamaSettingsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
export function OllamaBanner(): React.ReactElement | null {
|
||||
interface OllamaBannerProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.ReactElement | null {
|
||||
const status = useInbox((s) => s.ollamaStatus);
|
||||
const recheckOllama = useInbox((s) => s.recheckOllama);
|
||||
if (status.ok) return null;
|
||||
@@ -28,6 +32,19 @@ export function OllamaBanner(): React.ReactElement | null {
|
||||
>
|
||||
재확인
|
||||
</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
style={{
|
||||
background: 'transparent', color: 'inherit',
|
||||
border: '1px solid currentColor', borderRadius: 4,
|
||||
padding: '2px 8px', fontSize: 12, cursor: 'pointer',
|
||||
marginLeft: 6
|
||||
}}
|
||||
>
|
||||
설정
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{status.reason ? (
|
||||
<span style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
|
||||
|
||||
127
src/renderer/inbox/components/OllamaSettingsModal.tsx
Normal file
127
src/renderer/inbox/components/OllamaSettingsModal.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../../shared/constants.js';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null {
|
||||
const [endpoint, setEndpoint] = useState(DEFAULT_OLLAMA_ENDPOINT);
|
||||
const [model, setModel] = useState(DEFAULT_OLLAMA_MODEL);
|
||||
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() {
|
||||
if (saving) return; // m4 fix: synchronous double-click 가드
|
||||
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
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !saving) onClose();
|
||||
if (e.key === 'Enter' && !saving) void handleSave();
|
||||
}}
|
||||
tabIndex={-1}
|
||||
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"
|
||||
autoFocus
|
||||
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>
|
||||
);
|
||||
}
|
||||
2
src/shared/constants.ts
Normal file
2
src/shared/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const DEFAULT_OLLAMA_MODEL = 'gemma4:e4b';
|
||||
export const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434';
|
||||
@@ -89,6 +89,9 @@ export interface InboxApi {
|
||||
dismissRecall(id: string): Promise<{ note: Note }>;
|
||||
emitRecallShown(id: string): Promise<void>;
|
||||
emitRecallSnoozed(id: string): Promise<void>;
|
||||
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
|
||||
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
onOpenOllamaSettings(cb: () => void): () => void;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AiWorker } from '@main/ai/AiWorker.js';
|
||||
import type { AiTelemetryEmitter } from '@main/ai/AiWorker.js';
|
||||
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
|
||||
import type { AiResponse } from '@main/ai/schema.js';
|
||||
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
|
||||
|
||||
type EmittedEvent = { kind: string; payload: unknown };
|
||||
|
||||
@@ -33,7 +34,7 @@ describe('AiWorker', () => {
|
||||
it('processes a pending job and marks done', async () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const updates: string[] = [];
|
||||
const w = new AiWorker(repo, makeProvider(), {
|
||||
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
onUpdate: (note) => updates.push(note.aiStatus)
|
||||
});
|
||||
@@ -48,7 +49,7 @@ describe('AiWorker', () => {
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('boom'); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
const note = repo.findById(id)!;
|
||||
@@ -60,7 +61,7 @@ describe('AiWorker', () => {
|
||||
it('loadFromDb re-queues all pending', async () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const w = new AiWorker(repo, makeProvider(), { backoffsMs: [0, 0, 0] });
|
||||
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0] });
|
||||
await w.loadFromDb();
|
||||
await w.drain();
|
||||
expect(repo.findById(a)?.aiStatus).toBe('done');
|
||||
@@ -79,7 +80,7 @@ describe('AiWorker', () => {
|
||||
return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
})
|
||||
});
|
||||
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
|
||||
for (const id of ids) await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(max).toBe(1);
|
||||
@@ -96,7 +97,7 @@ describe('AiWorker', () => {
|
||||
}),
|
||||
healthCheck: async () => ({ ok: true })
|
||||
} as any;
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0],
|
||||
now: () => new Date('2026-04-26T00:00:00.000Z')
|
||||
});
|
||||
@@ -118,7 +119,7 @@ describe('AiWorker', () => {
|
||||
}),
|
||||
healthCheck: async () => ({ ok: true })
|
||||
} as any;
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0],
|
||||
now: () => new Date('2026-04-26T00:00:00.000Z')
|
||||
});
|
||||
@@ -140,7 +141,7 @@ describe('AiWorker', () => {
|
||||
}),
|
||||
healthCheck: async () => ({ ok: true })
|
||||
} as any;
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0],
|
||||
now: () => new Date('2026-04-26T00:00:00.000Z')
|
||||
});
|
||||
@@ -162,7 +163,7 @@ describe('AiWorker', () => {
|
||||
},
|
||||
healthCheck: async () => ({ ok: true })
|
||||
} as any;
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0],
|
||||
now: () => new Date('2026-04-26T15:00:00.000Z') // 04-27 00:00 KST
|
||||
});
|
||||
@@ -184,7 +185,7 @@ describe('AiWorker', () => {
|
||||
},
|
||||
healthCheck: async () => ({ ok: true })
|
||||
} as any;
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0],
|
||||
now: () => new Date('2026-04-26T00:00:00.000Z')
|
||||
});
|
||||
@@ -216,7 +217,7 @@ describe('AiWorker telemetry emit', () => {
|
||||
|
||||
it('emits ai_succeeded with durationMs/attempts on success', async () => {
|
||||
const { id } = repo.create({ rawText: '수요일 회의 메모' });
|
||||
const w = new AiWorker(repo, makeProvider(), {
|
||||
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: collectingTelemetry
|
||||
});
|
||||
@@ -236,7 +237,7 @@ describe('AiWorker telemetry emit', () => {
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10],
|
||||
telemetry: collectingTelemetry
|
||||
@@ -254,7 +255,7 @@ describe('AiWorker telemetry emit', () => {
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new ZodError([]); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: collectingTelemetry
|
||||
});
|
||||
@@ -270,7 +271,7 @@ describe('AiWorker telemetry emit', () => {
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('mystery'); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: collectingTelemetry
|
||||
});
|
||||
@@ -300,7 +301,7 @@ describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => {
|
||||
db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z');
|
||||
const generate = vi.fn();
|
||||
const provider = makeProvider({ generate: generate as any });
|
||||
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
|
||||
await w.loadFromDb();
|
||||
await w.drain();
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
@@ -322,7 +323,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('ECONNREFUSED'); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 30_000, 120_000],
|
||||
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
|
||||
});
|
||||
@@ -341,7 +342,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('Request timeout'); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 30_000, 120_000],
|
||||
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
|
||||
});
|
||||
@@ -360,7 +361,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
|
||||
})
|
||||
});
|
||||
const events: any[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: async (e) => { events.push(e); } }
|
||||
});
|
||||
@@ -379,7 +380,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
|
||||
generate: vi.fn(async () => { throw new Error('something weird'); })
|
||||
});
|
||||
const events: any[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: async (e) => { events.push(e); } }
|
||||
});
|
||||
@@ -392,7 +393,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
|
||||
});
|
||||
|
||||
it('unreachable backoff schedule — nextBackoffMs(step) cap at index 5 (15분)', async () => {
|
||||
const w = new AiWorker(repo, makeProvider(), {
|
||||
const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
|
||||
backoffsMs: [0, 30_000, 120_000],
|
||||
unreachableBackoffsMs: [30_000, 60_000, 120_000, 240_000, 480_000, 900_000]
|
||||
});
|
||||
@@ -411,7 +412,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
|
||||
return { title: 't', summary: 's', tags: [], dueDate: null };
|
||||
})
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
|
||||
});
|
||||
@@ -443,7 +444,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
const generateMock = vi.fn(async () => ({
|
||||
title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null
|
||||
}));
|
||||
const w = new AiWorker(repo, makeProvider({ generate: generateMock }), {
|
||||
const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), {
|
||||
backoffsMs: [0, 0, 0]
|
||||
});
|
||||
await w.enqueue(id);
|
||||
@@ -467,7 +468,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: {
|
||||
emit: vi.fn(async (input) => { emits.push(input); })
|
||||
@@ -497,7 +498,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
|
||||
});
|
||||
@@ -522,7 +523,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
|
||||
});
|
||||
@@ -546,7 +547,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HealthChecker, type HealthTelemetryEvent } from '@main/services/HealthChecker.js';
|
||||
import type { InferenceProvider, HealthResult, GenerateInput } from '@main/ai/InferenceProvider.js';
|
||||
import type { AiResponse } from '@main/ai/schema.js';
|
||||
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
|
||||
|
||||
class FakeProvider implements InferenceProvider {
|
||||
readonly name = 'fake';
|
||||
@@ -24,7 +25,7 @@ describe('HealthChecker — start/stop polling', () => {
|
||||
it('start() runs runOnce immediately + every intervalMs', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }, { ok: true }, { ok: true }];
|
||||
const hc = new HealthChecker(provider, { intervalMs: 1000 });
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
|
||||
hc.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
@@ -36,7 +37,7 @@ describe('HealthChecker — start/stop polling', () => {
|
||||
it('start() is idempotent — second call does not duplicate timer', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }];
|
||||
const hc = new HealthChecker(provider, { intervalMs: 1000 });
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
|
||||
hc.start();
|
||||
hc.start();
|
||||
// 즉시 1회 + 1s 후 1회 = 정확히 2. 두 timer 가 잘못 등록됐으면 4 (각 timer 마다 즉시+1s).
|
||||
@@ -48,7 +49,7 @@ describe('HealthChecker — start/stop polling', () => {
|
||||
it('stop() clears timer (no further runOnce)', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }, { ok: true }];
|
||||
const hc = new HealthChecker(provider, { intervalMs: 1000 });
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
|
||||
hc.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
const before = (provider as any).idx;
|
||||
@@ -64,7 +65,7 @@ describe('HealthChecker — delta transitions + telemetry', () => {
|
||||
provider.results = [{ ok: true }, { ok: false, reason: 'connection refused' }];
|
||||
const updates: HealthResult[] = [];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
const hc = new HealthChecker(provider, {
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), {
|
||||
onUpdate: (s) => updates.push(s),
|
||||
onTelemetry: (e) => events.push(e)
|
||||
});
|
||||
@@ -79,7 +80,7 @@ describe('HealthChecker — delta transitions + telemetry', () => {
|
||||
provider.results = [{ ok: false, reason: 'refused' }, { ok: true }];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
let nowCounter = 0;
|
||||
const hc = new HealthChecker(provider, {
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), {
|
||||
onTelemetry: (e) => events.push(e),
|
||||
now: () => { nowCounter += 1; return nowCounter * 1000; }
|
||||
});
|
||||
@@ -97,7 +98,7 @@ describe('HealthChecker — delta transitions + telemetry', () => {
|
||||
];
|
||||
const updates: HealthResult[] = [];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
const hc = new HealthChecker(provider, {
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), {
|
||||
onUpdate: (s) => updates.push(s),
|
||||
onTelemetry: (e) => events.push(e)
|
||||
});
|
||||
@@ -111,7 +112,7 @@ describe('HealthChecker — delta transitions + telemetry', () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
const hc = new HealthChecker(provider, { onTelemetry: (e) => events.push(e) });
|
||||
const hc = new HealthChecker(new ProviderHolder(provider), { onTelemetry: (e) => events.push(e) });
|
||||
await hc.runOnce({ manual: true });
|
||||
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
|
||||
});
|
||||
|
||||
@@ -89,4 +89,24 @@ describe('LocalOllamaProvider', () => {
|
||||
expect(h.ok).toBe(false);
|
||||
expect(h.reason).toMatch(/connect|refused|unreachable/i);
|
||||
});
|
||||
|
||||
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: '{}' };
|
||||
}) as never);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
30
tests/unit/ProviderHolder.test.ts
Normal file
30
tests/unit/ProviderHolder.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
57
tests/unit/SettingsService.test.ts
Normal file
57
tests/unit/SettingsService.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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({});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user