Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a51f241b94 | ||
| 8bc33da954 | |||
|
|
a991008689 | ||
|
|
54e2f5b10f | ||
|
|
8b2920fee4 | ||
|
|
0447b69b82 | ||
|
|
476a519fb5 | ||
|
|
9230ebff9d | ||
|
|
983306e004 | ||
|
|
05c45c1e10 | ||
|
|
a2c17a8b0d | ||
|
|
3cfa60bbba | ||
|
|
075f395b6d | ||
|
|
e485b77888 | ||
|
|
e2c53a28dc | ||
|
|
df27a9637e | ||
|
|
6fdb72101f | ||
|
|
341f55505d | ||
|
|
b3e16ff5bc | ||
| 8f2b9adb3a | |||
|
|
7187aea0a9 | ||
| 49c29f34c3 | |||
|
|
d213d45f92 | ||
|
|
298d1c6182 | ||
|
|
d3dfe1e4e2 | ||
|
|
c87c248e89 | ||
|
|
ef5d3daf4c | ||
|
|
4bde148cdc | ||
|
|
8ba43d939e | ||
| fee982a6e6 | |||
|
|
d974335ee4 | ||
|
|
6f95e89456 | ||
|
|
3a2ff1a35c | ||
|
|
0c0327ddb6 | ||
|
|
833a598368 | ||
|
|
4153284af1 | ||
|
|
cee39a90aa | ||
|
|
d1f36250e7 | ||
|
|
9fef2edb6e | ||
|
|
c77c30be83 | ||
|
|
de895b8fec | ||
|
|
71ec79ae19 | ||
|
|
97ca119b55 | ||
|
|
b259734aa0 |
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
1133
docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md
Normal file
1133
docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.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
|
||||
@@ -0,0 +1,54 @@
|
||||
# v0.2.4 Patch Cleanup — Design Spec (Brief)
|
||||
|
||||
> 작성: 2026-05-05 · 0.2.3.1 semver 위반 (`X.Y.Z.W` 4-part) → 0.2.4 minor bump 이용해 backlog 의 simple cleanup 5건 + 사용자 가치 1건 합쳐서 묶음 cut. v0.2.4 정식 brainstorm 은 v0.2.5 로 이동.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
PR #21 머지 후 0.2.3.1 binary 빌드 시도가 electron-builder 의 semver validation 으로 실패. 0.2.4 minor bump 으로 우회. 이번 cut 에는 dogfood unblock 외 backlog 의 risk 낮은 cleanup + 사용자 가치 항목 동봉.
|
||||
|
||||
## 2. Scope (5 backlog 항목 + version bump)
|
||||
|
||||
| backlog # | 항목 | 가치 | 작업량 |
|
||||
|---|---|---|---|
|
||||
| #1 | `TelemetryService.emit` 의 `now()` 2번 호출 → 1번 추출 | cosmetic (KST midnight straddle 이론) | 1줄 |
|
||||
| #2 | `DAY_MS = 24*60*60*1000` magic number → 모듈 상단 상수 | cosmetic | 1줄 |
|
||||
| #6 | `media.gc.run()` `.catch` 누락 → backup pattern 통일 | consistency | 1줄 |
|
||||
| #13 | NoteCard `mode='trash'` 의 `onDeleted` dead-code prop 제거 | API 청소 | 작음 |
|
||||
| #44 | 트레이 메뉴 + Inbox footer 에 "Inkling 0.2.4" 버전 정보 | **사용자 dogfood 가치** | 1 task |
|
||||
| - | version bump 0.2.3.1 → 0.2.4 | semver 표준 | trivial |
|
||||
|
||||
## 3. Out of scope
|
||||
|
||||
- **#45 (자동실행 버그)**: Windows registry 디버깅 필요, simple X. 별도 cut.
|
||||
- **#3/#4/#26 (KST 통합 / TrayCallbacks refactor)**: multi-file, 크다. 별도.
|
||||
- **#5/#22 (Union 통합 / hydrate cleanup)**: repo-wide.
|
||||
- **#39~#43 (PR #21 deferred)**: telemetry masking 등 의미 있는 결정 필요. v0.2.5 brainstorm 영역.
|
||||
- 기타 backlog 39건.
|
||||
|
||||
## 4. Architecture changes
|
||||
|
||||
본 cut 은 의미 있는 architecture 변경 없음. 기존 pattern 강화만:
|
||||
- `TelemetryService.emit` 의 atomic timestamp 보장 (now() 1회)
|
||||
- 모듈 상단 magic number 상수화 패턴 (다른 파일은 이미 그 패턴, TelemetryService 만 예외)
|
||||
- `.catch` consistency (backup.runDaily / telemetry.cleanupOldFiles 와 동일 wrapper)
|
||||
- React props 청소 (현재 호출되지 않는 prop 제거)
|
||||
- 신규 surface: 트레이 메뉴 "Inkling 정보..." → modal 또는 dialog
|
||||
|
||||
## 5. Tests
|
||||
|
||||
테스트 추가 없음 (모두 cosmetic / refactor). 기존 단위 413/413 회귀 X 확인만.
|
||||
|
||||
#44 의 modal 은 컴포넌트 단위 테스트 X (Inkling 패턴 — store-only).
|
||||
|
||||
## 6. Gates
|
||||
|
||||
- typecheck 0
|
||||
- 단위 413/413 (회귀 X)
|
||||
- e2e 1/1
|
||||
- backward compat: 기존 사용자 영향 0 (cosmetic + 새 surface)
|
||||
|
||||
## 7. Roadmap relation
|
||||
|
||||
- 0.2.3 cut 7/7 (PR #13~#19) + 0.2.3.1 patch (PR #21) 누적 후 binary 빌드를 위한 v0.2.4 minor bump
|
||||
- v0.2.5 brainstorm 트리거: dogfood ≥1주 soak + telemetry export + backlog 39건 (=45-5-1) + 신규 피드백 일괄 triage
|
||||
- backlog 명명 `v024-backlog.md` → 본 cut 후 `v025-backlog.md` 로 rename 검토 (또는 v024-backlog.md 유지하고 내용만 갱신)
|
||||
133
docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md
Normal file
133
docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# v0.2.6 Bugs + Cleanup — Design Spec
|
||||
|
||||
> 작성: 2026-05-05 · 정식 v0.2.6 cut. backlog 16건 (bug 4 + cleanup 12, 13 task 로 cluster) 통합 처리. dogfood telemetry 미수집 영역 (#7/#16/#18/#25/#33/#35/#36/#39/#40 등 14건) 은 v0.2.7 brainstorm 영역으로 별도.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
dogfood UX 마찰 (autostart 풀림, trashCount 부정확, restore 시 AI 미재처리) 즉시 해소 + 코드베이스 cleanup (KST helper 통합, TrayCallbacks 객체화, AiFailedReason union 통합 등) 으로 v0.2.7 brainstorm 시 신규 feature 작업 friction 제거.
|
||||
|
||||
## 2. Scope (16 backlog 항목 → 13 task)
|
||||
|
||||
### Bug fixes (B1~B4)
|
||||
|
||||
| Task | 항목 | 작업 요약 |
|
||||
|---|---|---|
|
||||
| **B1** | #10 | `NoteRepository.restoreNote(id)` 가 `ai_status='failed'` 인 노트 복구 시 `ai_status='pending'` reset + `pending_jobs INSERT` |
|
||||
| **B2** | #12 | `NoteRepository.countTrashed()` 추가 + IPC `inbox:trashCount` 가 SQL 정확 N 반환 (UI 200 cap 제거) |
|
||||
| **B3** | #45 | autostart 풀림: `app.getLoginItemSettings({ args: ['--hidden'] })` (args 비교 정확도) + path canonicalization 검토. fallback: 진단 로그만 추가 시 backlog 유지 |
|
||||
| **B4** | #46 | `app.requestSingleInstanceLock(additionalData)` + `second-instance(event, argv, cwd, additionalData)` 에서 hidden flag 체크 → 두 번째 hidden 이면 inbox 창 안 띄움 |
|
||||
|
||||
### Cleanup refactor (C1~C9)
|
||||
|
||||
| Task | 항목 (cluster) | 작업 요약 |
|
||||
|---|---|---|
|
||||
| **C1** | #3 + #19 + #34 | KST helper 통합 → `src/shared/util/kstDate.ts`. 4 callsite migrate (`TelemetryService.todayKstIso`, `telemetryStats.kstDate`, `AiWorker.todayKstAsDate/Iso`, store `snoozeExpired/snoozeRecall`) |
|
||||
| **C2** | #4 + #23 + #26 | `interface TrayCallbacks` + `createTray(callbacks: TrayCallbacks)` 1-arg refactor. positional 10개 → object |
|
||||
| **C3** | #27 | `refreshTrayFailedCount` module-scoped state 제거 → TrayCallbacks 객체 안 reactive 함수 또는 store-driven 패턴 |
|
||||
| **C4** | #5 | `export type AiFailedReason = 'unreachable' \| 'schema' \| 'timeout' \| 'other'` 단일 export + zod `z.enum` 의 `z.infer` 로 type 파생. 3 callsite migrate |
|
||||
| **C5** | #21 | `hasNoteId(ev: TelemetryEvent): ev is TelemetryEventWithNoteId` type predicate helper → `tests/unit/TelemetryService.test.ts` 의 4-line narrowing 체인 단축 |
|
||||
| **C6** | #22 | NoteRepository hydrate 의 `as any[]` → `Record<string, unknown>[]` (또는 explicit row interface) 일괄 cleanup |
|
||||
| **C7** | #24 + #41 | `<Banner severity="warning"\|"error"\|"info">` shared component → ExpiryBanner / OllamaBanner / FailedBanner / RecallBanner / OllamaSettingsModal 5 callsite migrate |
|
||||
| **C8** | #8 | `telemetryStats.aggregateStats` if/else if 끝에 `else { const _: never = ev; }` exhaustiveness check |
|
||||
| **C9** | #15 + #29 + #42 + #9 | microfixes 묶음: `inbox:delete`→`inbox:trash` rename / `getTopUsedTags(20)` → `VOCAB_TOP_N` const / `OllamaSettingsModal` zod URL pre-check / 휴지통 회수율 ratio 코멘트 1줄 |
|
||||
|
||||
## 3. Out of scope
|
||||
|
||||
- Telemetry 데이터 필요 (14건): #7 reason 분포 / #16 permanent_delete 빈도 / #18 loadExpired consumer / #20 telemetry .catch silent / #25 HealthChecker dedup / #28 unreachableBackoffStep / #29 top-N 튜닝값 (extract 만 본 cut, 튜닝은 v0.2.7) / #30 LIMIT-then-filter 정책 / #31 vocabSet COLLATE / #32 per-tag emit 병렬화 / #33 promptVersion payload / #35 recall_shown lifetime / #36 IPC handle vs on / #39 ollama reason PII / #40 Settings race flicker
|
||||
- 별도 brainstorm 영역 (3건): #11 restoreNote precondition / #14 ARIA 패턴 / #17 dialog 버튼 순서 / #37 NoteCard id ref-forwarding
|
||||
|
||||
## 4. Architecture changes
|
||||
|
||||
대부분 cosmetic refactor 또는 isolated bug fix. 주목할 architecture-level 변경:
|
||||
|
||||
### 4.1 KST helper 통합 (C1)
|
||||
- 신규 `src/shared/util/kstDate.ts` (main + renderer 양쪽 import 가능)
|
||||
- 기존 4 callsite 의 inline KST 계산 제거
|
||||
- API: `kstTodayIso(now?: Date): string`, `nextKstMidnightMs(now?: Date): number`
|
||||
- KST_OFFSET_MS 상수 단일
|
||||
|
||||
### 4.2 TrayCallbacks 객체화 (C2 + C3)
|
||||
- `interface TrayCallbacks` — 10+ 개 callback + state getter
|
||||
- `createTray(callbacks: TrayCallbacks): void` — 1-arg signature
|
||||
- module state (_failedCount, _todayCount, _ollamaOk) 는 TrayCallbacks 의 reactive getter / setter 패턴 또는 explicit refresh 함수 (`refreshTray(state: { todayCount, failedCount, ollamaOk })`)
|
||||
|
||||
### 4.3 Banner shared component (C7)
|
||||
- `<Banner severity="warning"|"error"|"info" icon? title? children>` — wrapping/styling 일원화
|
||||
- 5 callsite 가 themed inline style 제거 → severity prop
|
||||
- CSS variables 또는 hardcoded theme map (single source)
|
||||
|
||||
### 4.4 NoteRepository.restoreNote behavior change (B1)
|
||||
- 기존: `UPDATE notes SET deleted_at = NULL WHERE id = ?`
|
||||
- 변경: 추가로 `ai_status='failed'` 였을 경우 → `ai_status='pending'` reset + `INSERT OR IGNORE INTO pending_jobs`
|
||||
- atomic transaction
|
||||
- AiWorker 가 자동으로 다음 loop iteration 에서 처리
|
||||
|
||||
## 5. Tests
|
||||
|
||||
추정 +17 cases (413 → 430):
|
||||
|
||||
| Task | 신규 단위 |
|
||||
|---|---|
|
||||
| B1 | +3 (restore failed note re-enqueues, restore done note 영향 X, restore cancelled note 영향 X) |
|
||||
| B2 | +2 (countTrashed 정확, dialog message 정확 N) |
|
||||
| B3 | +1-2 (autostart args 비교, 가능하다면 mock electron app) |
|
||||
| B4 | +1 (additionalData hidden flag 가 second-instance 에 전달, mock test) |
|
||||
| C1 | +2 (kstTodayIso, nextKstMidnightMs) — 기존 4 callsite test 가 자동 검증 |
|
||||
| C2 | refactor only, 기존 tray 테스트 유지 |
|
||||
| C3 | refactor only |
|
||||
| C4 | refactor only |
|
||||
| C5 | +2 (hasNoteId predicate) |
|
||||
| C6 | refactor only |
|
||||
| C7 | refactor only (UI 컴포넌트 unit test X 패턴) |
|
||||
| C8 | +1 (exhaustive guard 컴파일 단계) |
|
||||
| C9 | +1 (Modal URL pre-check), 나머지 refactor only |
|
||||
|
||||
총 신규: ~13-15 (보수적). 단위 413 → **~426-428** 예상.
|
||||
|
||||
## 6. Privacy invariant
|
||||
|
||||
- B1/B2: telemetry 영향 없음
|
||||
- B3/B4: telemetry emit 없음 (autostart event 미수집)
|
||||
- C 시리즈: 모두 cosmetic refactor — invariant 영향 0
|
||||
- 본 cut 에서 신규 telemetry kind 추가 0
|
||||
|
||||
## 7. Gates (roadmap §3.1)
|
||||
|
||||
- typecheck 0
|
||||
- 단위 413 → ~427 (+13~15)
|
||||
- e2e 1/1
|
||||
- backward compat: 기존 사용자 데이터 + UI 동작 영향 0 (단 B1 은 의도적 동작 추가, B2 는 UI N 표시 정확화)
|
||||
|
||||
## 8. Risk + Fallback
|
||||
|
||||
### B3 (autostart 풀림) 진단 불확실
|
||||
가장 risky. Windows registry 디버깅 결과 깨끗한 fix 안 나올 수 있음. **Fallback 정책**:
|
||||
- 진단 절차 적용해도 fix 안 되면 → 진단 로그만 추가 (`logger.info('autostart.state', { stored, current, mismatch })`) → backlog #45 유지 → 본 cut 에서 task drop
|
||||
- 다른 task 영향 없음 (각 task 독립적)
|
||||
|
||||
### C1 KST helper 의 alias 경계
|
||||
`src/shared/util/kstDate.ts` 가 main + renderer 양쪽에서 import 되어야. 기존 `@main/util/kstDate.ts` 는 renderer 에서 import 불가 (alias 분리). `src/shared/` 가 양쪽 가능 패턴. 검증 필요.
|
||||
|
||||
### C2 TrayCallbacks 객체화 의 backward compat
|
||||
기존 createTray 호출자 (index.ts 1곳) 한 군데만 변경 → 안전. tray 테스트 영향 최소.
|
||||
|
||||
## 9. 작업 순서
|
||||
|
||||
순서대로 subagent dispatch. 의존성:
|
||||
- B1, B2: 독립
|
||||
- B3: 독립 (Windows-specific, mock 어려움)
|
||||
- B4: 독립
|
||||
- C1 → 다른 task 영향 X (shared util 추가)
|
||||
- C2 → C3 (TrayCallbacks 객체에 refreshTrayFailedCount 흡수)
|
||||
- C4, C5, C6, C7, C8, C9: 독립
|
||||
|
||||
권장 순서: **B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2 → C3 → C7 → C9**.
|
||||
|
||||
이유: B3 (위험) 을 cleanup 시작 직전에 두어 fail 시 빠르게 회피. C2/C3 cluster 는 묶어서. C7 (Banner shared) 는 isolated UI cleanup, 마지막 그룹.
|
||||
|
||||
## 10. Roadmap relation
|
||||
|
||||
- v0.2.6 정식 cut (이전 v0.2.4/v0.2.5 는 patch / hotfix)
|
||||
- 머지 후 binary 빌드 v0.2.6 (Windows + Mac) + Gitea release
|
||||
- v0.2.7 brainstorm 트리거: dogfood ≥1주 soak + telemetry export 모인 후, 잔여 backlog 14건 (data-dependent) + 신규 피드백 일괄 triage
|
||||
- backlog file 본 cut 후 prune (16 건 처리 완료 표기) + rename 검토 (`v027-backlog.md` 또는 `feature-backlog.md`)
|
||||
212
docs/superpowers/v024-backlog.md
Normal file
212
docs/superpowers/v024-backlog.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# v0.2.x Backlog
|
||||
|
||||
> 누적 backlog. v0.2.3 cut (7항목 / PR #13~#19) 시점부터 PR review deferred + dogfood 발견 모두 합산. **파일명은 historic** (`v024-backlog.md`) — v0.2.4 ~ v0.2.6 cut 후에도 이어 사용. **v0.2.7 brainstorm 시** 신규 피드백 + 잔여 일괄 triage.
|
||||
|
||||
**누적 시작일:** 2026-05-01 (#7 telemetry skeleton 머지 시점)
|
||||
**최종 갱신:** 2026-05-05 (v0.2.6 정식 cut 16건 처리 완료, PR #24 머지 `8bc33da`)
|
||||
**총 항목 수:** 46 (#1 stale 포함)
|
||||
**잔여:** 24건 (=46 − 처리 21 − stale 1)
|
||||
|
||||
## 처리 이력 / 진행 흐름
|
||||
|
||||
| 항목 | 상태 | Cut |
|
||||
|---|---|---|
|
||||
| #1 (`now()` 2번 호출) | ✅ 이미 fix (PR #13 round 1 — backlog stale) | - |
|
||||
| #2 (`DAY_MS` magic) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
|
||||
| #6 (`media.gc.run()` `.catch`) | ✅ 처리 | v0.2.4 (commit `ef5d3da`) |
|
||||
| #13 (NoteCard `onDeleted` dead-code) | ✅ 처리 | v0.2.4 (commit `c87c248`) |
|
||||
| #44 (버전 정보 surface) | ✅ 처리 (트레이 "Inkling 정보..." + native dialog) | v0.2.4 (commit `d3dfe1e`) |
|
||||
| **out-of-backlog**: multi-instance bug (single-instance lock) | ✅ critical hotfix | v0.2.5 (PR #23, `7187aea`) |
|
||||
| #10 (restoreNote + pending_jobs) | ✅ 처리 (repo 메서드 + CaptureService production path) | v0.2.6 (commit `df27a96` + `a991008`) |
|
||||
| #12 (trashCount cap) | ✅ 이미 fix (v0.2.3 #4) — tests +2 추가 | v0.2.6 (commit `e2c53a2`) |
|
||||
| #45 (자동실행 풀림 버그) | 진단 fallback (args 명시 + 진단 로그). dogfood verify 후 v0.2.7 deeper fix | v0.2.6 부분처리 (commit `075f395`), 잔여 v0.2.7 |
|
||||
| #46 (hidden-start race) | ✅ 처리 (`additionalData` + handler hidden flag) | v0.2.6 (commit `e485b77`) |
|
||||
| #3+#19+#34 (KST helper 통합) | ✅ 처리 → `src/shared/util/kstDate.ts` (4 callsite migrate) | v0.2.6 (commit `3cfa60b`) |
|
||||
| #5 (AiFailedReason union) | ✅ 처리 (zod z.infer 단일 export) | v0.2.6 (commit `a2c17a8`) |
|
||||
| #21 (hasNoteId predicate) | ✅ 처리 (NO_NOTE_ID_KINDS Set + type predicate) | v0.2.6 (commit `05c45c1`) |
|
||||
| #22 (hydrate `as any[]`) | ✅ 처리 (`as Record<string, unknown>[]` 통일) | v0.2.6 (commit `983306e`) |
|
||||
| #8 (stats exhaustiveness) | ✅ 처리 (`else { _: never = ev }`) | v0.2.6 (commit `9230ebf`) |
|
||||
| #4+#23+#26+#27 (TrayCallbacks 객체화) | ✅ 처리 (1-arg + `Partial<TrayState>`) | v0.2.6 (commit `476a519`) |
|
||||
| #24+#41 (Banner shared component) | ✅ 처리 (`Banner severity=...` 4 callsite) | v0.2.6 (commit `0447b69`) |
|
||||
| #15 (IPC channel rename) | ✅ 처리 (`inbox:delete` → `inbox:trash`) | v0.2.6 (commit `8b2920f`) |
|
||||
| #29 (VOCAB_TOP_N const) | ✅ 처리 (튜닝 자체는 telemetry 후) | v0.2.6 (commit `8b2920f`) |
|
||||
| #42 (Modal URL pre-check) | ✅ 처리 (zod safeParse) | v0.2.6 (commit `8b2920f`) |
|
||||
| #9 (휴지통 회수율 ratio 코멘트) | ✅ 처리 (1줄 코멘트) | v0.2.6 (commit `8b2920f`) |
|
||||
|
||||
### v0.2.6 PR #24 round 1 발견 (Critical fix)
|
||||
|
||||
| 항목 | 상태 | Cut |
|
||||
|---|---|---|
|
||||
| **B1 production path** (CaptureService.restoreNote 가 옛 `repo.restore` 호출) | ✅ Critical fix (commit `a991008`) | v0.2.6 round 1 |
|
||||
|
||||
### v0.2.6 final reviewer + round 1 minors (deferred)
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| NoteRepository.countToday inline KST_OFFSET_MS | v0.2.7 cleanup (C1 spec 외) |
|
||||
| BackupService / ContinuityService inline KST_OFFSET_MS | v0.2.7 cleanup |
|
||||
| NoteRepository.test.ts:125 `as any` | v0.2.7 (C6 spec 외) |
|
||||
| OllamaSettingsModal `#fce4e4` inline (C7 spec 5번째) | modal 컨텍스트라 보류 |
|
||||
| `kstDate(ts)` semantic naming (telemetryStats) | v0.2.7 |
|
||||
| store.ts:177 trashCount race on `trashExpiredBatch` | pre-existing, v0.2.7 |
|
||||
| ExpiryBanner useEffect 24h+ closure | edge case, defer |
|
||||
|
||||
**잔여 24건** (= 46 − 처리 21 − stale 1). v0.2.7 brainstorm 시 신규 dogfood 피드백 + #45 deeper fix + 위 deferred 항목 일괄 triage.
|
||||
|
||||
## 명명 노트
|
||||
|
||||
- v0.2.3.1 / v0.2.4 / v0.2.5 는 **dogfood unblock patch** (semver bump 강제 / hotfix)
|
||||
- v0.2.6 = 첫 정식 cut (16 backlog 항목 처리)
|
||||
- v0.2.7 = 다음 정식 feature cut (telemetry data-dependent 14건 + 신규 피드백 + 잔여 deferred)
|
||||
- 본 backlog 파일은 v0.2.7 cut 시점에 prune + rename 검토 (`v027-backlog.md` 또는 stable 한 `feature-backlog.md`)
|
||||
|
||||
## Defer 사유 카테고리
|
||||
|
||||
각 항목은 머지 전 inline fix 보다 v0.2.4 영역으로 미룬 명시적 사유 가짐:
|
||||
|
||||
1. **Cross-cutting refactor** — 한 PR 안에서 부분만 고치면 inconsistency. 일괄 cleanup task 영역. (예: KST helper 4 callsite 통합, `createTray` positional callbacks 전체 객체화)
|
||||
2. **Data-dependent** — dogfood telemetry 분포 보고 결정해야 의미. (예: top-N 튜닝, recall_shown lifetime dedup 정책)
|
||||
3. **Cosmetic / style** — 동작 영향 0, 다른 일괄 cleanup task. (예: `now()` 두 번 호출, `as any[]` 통합)
|
||||
|
||||
## How to apply
|
||||
|
||||
v0.2.6 brainstorm 시 본 리스트를 1차 backlog 로 사용. 항목별로:
|
||||
|
||||
- (a) 그대로 cleanup
|
||||
- (b) #4~#6 영향 받아 변형
|
||||
- (c) defer-further 결정
|
||||
- (d) drop (만에 하나 outdated 또는 v0.2.4/v0.2.5 patch 가 우회 처리)
|
||||
|
||||
## v0.2.3 #7 Telemetry skeleton 누적 (2026-05-01)
|
||||
|
||||
1. **`now()` 두 번 호출** — `TelemetryService.emit` (`src/main/services/TelemetryService.ts:58, :60`) 가 같은 emit 안에서 `this.now()` 두 번. 이론적 midnight straddle 가능 (ts vs filePath 다른 KST 일자), 실제 영향 cosmetic. cleanup: `const nowDate = this.now()` 한 번 추출.
|
||||
|
||||
2. **`DAY_MS = 24*60*60*1000` magic number** — `cleanupOldFiles:39` + `readAllRecent:78` (+ `KST_OFFSET_MS` 간접). 모듈 상단에 `const DAY_MS = 24 * 60 * 60 * 1000;` 추출.
|
||||
|
||||
3. **KST helper duplication** — `TelemetryService.todayKstIso` + `telemetryStats.kstDate` + `AiWorker.todayKstAsDate`/`todayKstAsIso`. 4번째 caller (예: 회상 schedule, 만료 batching) 등장 시 `src/main/util/kst.ts` 로 통합.
|
||||
|
||||
4. **`createTray` positional 폭주** — `tray.ts:51` 가 7 positional callbacks. #1 ollama 회복 / #4 휴지통 비우기 등 트레이 메뉴 추가 시 8+ 도달 → readability threshold 넘김. `TrayCallbacks` object 로 refactor.
|
||||
|
||||
5. **`AiFailedReason` union 3 곳 중복** — `'unreachable' | 'schema' | 'timeout' | 'other'` 가 `telemetryEvents.ts:15` (zod), `TelemetryService.ts:21` (EmitInput), `AiWorker.ts:19, :34` (classifier + emitter) 에 분산. `export type AiFailedReason` 하나로 통합. (단 zod enum + TS literal 의 inherent dual-define 은 어쩔 수 없음 — `z.infer` 통해 type 파생만)
|
||||
|
||||
6. **`media.gc.run()` 의 `.catch` 누락** — T11 에서 `telemetry.cleanupOldFiles` 의 `.catch` 일관성 처리 시 `media.gc` 도 같은 패턴 (`.catch` 없음) 발견. `backup.runDaily()` 와 컨벤션 통일 위해 `.catch((e) => logger.warn('media.gc.failed', { reason: String(e) }))` 추가.
|
||||
|
||||
7. **stats.md 의 reason 분포 미포함** — `telemetryStats.aggregateStats` 가 AI 성공률만 계산, `ai_failed.payload.reason` 의 분포 (unreachable/schema/timeout/other counts) 는 미집계. roadmap §6.2 의 "Ollama unreachable 빈도?" 질문이 부분적으로만 답해짐. v0.2.3 dogfood 후 실제 reason 분포 보고 결정.
|
||||
|
||||
## v0.2.3 #4 휴지통 누적 (2026-05-01)
|
||||
|
||||
8. **stats.md exhaustiveness check** — `telemetryStats.aggregateStats` 의 7-arm if/else if 가 union 확장 시 silent fall-through. `else { const _: never = ev; }` 추가로 컴파일 단계 가드.
|
||||
|
||||
9. **휴지통 회수율 ratio 의미 코멘트** — `restore / trash` 가 event-level ratio (한 노트 trash-restore 반복 시 100% 가능). spec §6.2 의 "회수 도구 동작?" 질문에는 충분, 단 unique-note 회수율로 오해할 여지. 코드 옆 1줄 코멘트.
|
||||
|
||||
10. **`restore` 시 AI 결과 보존 + pending_jobs 미재생성** — restore 가 `deleted_at = NULL` 만, pending_jobs 안 재생성. 사용자가 trash 도중 AI fail 한 노트를 restore 시 재처리 경로 부재. v0.2.3 dogfood 에서 빈도 보고 결정 — drop / per-note retry 버튼 / 자동 재처리 중.
|
||||
|
||||
11. **`restoreNote(id)` precondition 노출** — store 의 낙관적 갱신이 `trashNotes` 에 노트가 있어야 동작. 명령 팔레트 / 프로그래밍 호출 케이스 시 silently no-op. 현재는 trash view 한정이라 OK. main 이 trash/restore 시 `pushNoteUpdated` 보내도록 변경하면 더 견고.
|
||||
|
||||
12. **`inbox:trashCount` cap 200 silent undercount** — UI 만 200 cap, `repo.emptyTrash()` SQL 은 unbounded. 350 노트 trash 시 dialog "200개 영구 삭제" 표시되지만 실제 350 모두 삭제. `repo.countTrashed()` 추가로 둘 다 정확히. **(잠재 UX 버그 — pull-forward 후보)**
|
||||
|
||||
13. **NoteCard `mode='trash'` 의 `onDeleted` dead-code** — trash 카드는 `onPermanentDelete`/`onRestore` 만 사용. `onDeleted` prop 은 호출되지 않음 (App.tsx 가 pass-through). API 깔끔히 — `onDeleted?` optional + trash 분기 미전달.
|
||||
|
||||
14. **탭 ARIA 패턴** — `aria-pressed` 로 toggle 버튼 표현. canonical 은 `role="tab"` + `aria-selected`. screen reader 동작 OK 지만 a11y audit 시 정정 후보.
|
||||
|
||||
15. **`inbox:delete` 채널 rename** — semantic 이 hard → soft 인데 채널 이름 그대로. v0.2.4 에서 `inbox:trash` 로 rename 검토 (기존 호출 0건 보장 후).
|
||||
|
||||
16. **per-note 영구 삭제 telemetry 사용량** — v0.2.3 dogfood 에서 `permanent_delete` event 빈도 확인. 거의 0 이면 v0.2.4 에서 per-card "영구 삭제" 버튼 제거 + bulk emptyTrash 만 (UX 단순화). 빈번하면 유지.
|
||||
|
||||
## v0.2.3 #5 만료 추천 누적 (2026-05-01)
|
||||
|
||||
17. **dialog 버튼 순서 vs spec §5.3** — spec 은 `['취소','옮기기'], default=0`, 구현은 `['옮기기','취소'], defaultId=1, cancelId=1` (`inboxApi.ts:117`). 효과 동일 (default = cancel). v0.2.4 에서 spec 또는 impl 한쪽 통일.
|
||||
|
||||
18. **`loadExpired()` 미사용** — `loadInitial`/`refreshMeta` 가 inline fetch, App.tsx 도 호출 안 함 (test 만 exercise). v0.2.4 dogfood 후에도 consumer 미발생 시 제거 검토.
|
||||
|
||||
19. **store `KST_OFFSET_MS` inline duplication** — `store.ts:166` 의 `snoozeExpired` 가 inline KST 계산. `@main/util/kstDate.ts` 와 동일 알고리즘이지만 alias 경계 (main vs renderer) 로 import 불가. `src/shared/util/kstDate.ts` 로 lift 검토. (#3, #34 와 합산 가능)
|
||||
|
||||
20. **telemetry emit `.catch(() => {})` 가 silent** — `CaptureService.listExpired`/`trashExpiredBatch` 가 그대로. v0.2.4 telemetry 하드닝 시 debug log path (project pattern 통일) 추가 검토.
|
||||
|
||||
21. **TelemetryService.test.ts 의 noteId 가드 widening** — `e.kind !== 'empty_trash' && e.kind !== 'expired_banner_shown' && e.kind !== 'expired_batch_trash'` 체인이 #6 추가 시 더 길어짐. `hasNoteId(ev)` type predicate helper 추출 검토.
|
||||
|
||||
22. **NoteRepository hydrate 의 `as any[]` 일괄 cleanup** — `findExpiredCandidates` round 1 review 의 nit 가 단독 fix 시 다른 hydrate-using methods 와 inconsistency. `db.prepare().all()` 의 row type 을 `Record<string, unknown>[]` 또는 explicit row interface 로 통일하는 repo-wide refactor.
|
||||
|
||||
## v0.2.3 #1 ollama 회복 누적 (2026-05-01)
|
||||
|
||||
23. **`createTray` 8 positional callbacks** — #1 cut 에서 8개 도달, v0.2.4 backlog #4 와 정합 (TrayCallbacks object refactor 약속). #2 retry 또는 #6 reminder cut 에서 추가 항목 (예: "재시도 N건") 등장 시 9+ 회피 위해 본격 refactor.
|
||||
|
||||
24. **Banner CSS 스타일 inline 중복** — ExpiryBanner (`#fff7e6 / #d99500 / #946100` 황색) / OllamaBanner (동) / FailedBanner (`#fce4e4 / #a33` 적색) / RecallBanner (`#e8f0fe / #4a7ec0` 청색) 모두 색상 hardcode. v0.2.4 에서 CSS variables 또는 banner shared component (`<Banner severity="warning|error|info" />`) 추출 검토.
|
||||
|
||||
25. **HealthChecker `inFlight` 가드의 manual emit ordering** — manual emit 이 inFlight 체크 전 발생해 user 가 빠르게 N번 클릭하면 N개 manual telemetry. spec 의도 (1:1 보장) 와 정합이지만, 향후 dedup 정책 (예: 1초 윈도우) 으로 변형 가능성. v0.2.4 dogfood soak 결과로 결정.
|
||||
|
||||
## v0.2.3 #2 AI retry 누적 (2026-05-02)
|
||||
|
||||
26. **`createTray` 9 positional callbacks** — #2 cut 에서 9개 도달 (refreshTrayFailedCount 포함). #4 `TrayCallbacks` object refactor 가 이제 readability blocker. #3 / #6 cut 어느 쪽이든 추가 callback 더 들어오기 전에 우선 처리.
|
||||
|
||||
27. **`refreshTrayFailedCount` exported singleton state** — `tray.ts` 에 `_failedCount` module-scoped state + setter 패턴. 모듈 캡슐화로 작동하지만 multi-window 또는 multi-tray 시 broken. v0.2.4 refactor 시 TrayController class 또는 store-driven 으로 정리.
|
||||
|
||||
28. **`AiWorker.unreachableBackoffStep` 단일 카운터 vs job-level** — 모든 job 이 step counter 공유. 1 job timeout → step↑, 다른 job 정상 처리해도 step reset. 현재는 cross-job correlation 없으니 OK 가정 (Ollama daemon 단일이라 모든 job 이 같은 백엔드 의존). multi-provider 가 들어오면 provider-level step 으로 분리 필요.
|
||||
|
||||
## v0.2.3 #3 태그 vocab 누적 (2026-05-02)
|
||||
|
||||
29. **`getTopUsedTags(20)` magic number** — `AiWorker.processJob:137` 가 `repo.getTopUsedTags(20)` hardcoded. spec §7 Out 에 "top-N 튜닝" 명시. v0.2.4 dogfood telemetry (`tag_vocab_hit/miss` ratio) 보고 `VOCAB_TOP_N` 모듈 상수 추출 + 튜닝 결정.
|
||||
|
||||
30. **`getTopUsedTags` LIMIT-then-filter 의미** — SQL 가 limit 만큼 가져온 후 JS regex 가 후처리 → top-20 안에 한글/공백 태그 섞이면 결과 length < limit. dogfood 규모 OK 가정 + 테스트 lock-in (v0.2.3 round 1 m2 fix). v0.2.4 에서 vocab pool 확장 시 SQL `GLOB` 으로 SQL-side 필터 대안 검토 (또는 `LIMIT ?*2` overfetch+slice).
|
||||
|
||||
31. **`vocabSet` strict-eq vs DB COLLATE NOCASE 불일치** — `vocabSet = new Set(vocab)` 은 JS 대소문자 strict, `tags.name` 은 COLLATE NOCASE. 현재는 kebab-case 필터로 vocab 이 항상 lowercase + AI prompt 도 lowercase 강제라 충돌 없지만, vocab pool 확장 시 (예: `'Design'` 사용자 직접 추가) `getTagIdByName('Design')` 은 매치하지만 `vocabSet.has('Design')` 은 miss → tagId 없는 hit 가 silently skip. v0.2.4 에서 `vocabSet = new Set(vocab.map(v => v.toLowerCase()))` + `vocabSet.has(tagName.toLowerCase())` 로 normalize 검토.
|
||||
|
||||
32. **AiWorker per-tag emit serial await** — `for (const tag of new Set(...))` 안의 `await this.telemetry.emit(...)` 가 직렬. 3 태그 시 file-append 3 round-trip. `Promise.all` 로 병렬화 가능, 단 `ai_succeeded` emit 도 serial 이라 패턴 일관성 우선 skip. v0.2.4 telemetry 하드닝 시 일괄 변경 검토.
|
||||
|
||||
33. **`PROMPT_VERSION` telemetry payload 미포함** — v0.2.3 cut 에선 단일 버전 (4) 만 굴러가서 무의미. v0.2.4/v0.2.5 prompt 튜닝 후 어느 버전이 어떤 hit-rate 만든지 추적 시 `tag_vocab_hit/miss` payload 에 `promptVersion` 추가 검토. spec §7 Out 명시.
|
||||
|
||||
## v0.2.3 #6 RecallBanner 누적 (2026-05-02)
|
||||
|
||||
34. **KST midnight inline calc 4번째 복제** — `store.ts` 의 `snoozeRecall` (#6) + `snoozeExpired` (#5) + `NoteCard.todayKstIso` + 다른 1곳, 그리고 `kstDate.ts` util 도 별도 존재. 4 callsite 모두 동일 알고리즘. v0.2.4 에서 `nextKstMidnightMs()` / `kstTodayIso()` 단일 util 통합 + alias 경계 (main vs renderer) 해결책. backlog #3, #19 와 합산.
|
||||
|
||||
35. **`recall_shown` per-banner-lifetime emit 보장** — useState→useRef 로 race 차단했지만 RecallBanner 컴포넌트 unmount/remount 시 reset. 사용자가 페이지 이동 후 돌아오면 같은 노트가 재emit 가능. v0.2.4 dogfood telemetry 에서 동일 noteId 의 `recall_shown` 빈도 보고 결정 (per-noteId 24h dedup 또는 per-noteId 영속 마커).
|
||||
|
||||
36. **`emitRecallShown` / `emitRecallSnoozed` 가 fire-and-forget 인데 `ipcMain.handle` 사용** — 더 honest 한 패턴은 `ipcMain.on` (return value 없음). 현재는 다른 IPC 와 패턴 일관성 우선. v0.2.4 IPC 정리 시 `handle` vs `on` 구분 일괄 검토.
|
||||
|
||||
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 항목 후보.
|
||||
|
||||
## v0.2.3 / v0.2.3.1 dogfood 발견 (2026-05-05)
|
||||
|
||||
> 본 cut 들의 머지 후 사용자가 dogfood 중 발견한 항목. PR review deferred 와 달리 raw UX/bug 발견.
|
||||
|
||||
44. **버전 및 프로그램 정보 표시 방법 부재** — 현재 사용자가 설치된 Inkling 의 버전 (package.json `0.2.3.1`) 을 UI 에서 확인할 path 없음. 트레이 메뉴 / Inbox 푸터 / 별도 "About Inkling" 모달 어느 surface 에도 정보 없음. 핸드오프 후 다른 머신에서 같은 버전인지 사용자가 직접 검증 불가. v0.2.4 에서 트레이 메뉴 "Inkling 0.2.3.1 정보..." 또는 Inbox 우하단 footer 형태로 추가 검토. 곁들여: 빌드 commit SHA, electron/node 버전, OS, profileDir 경로 등 디버그 정보 노출 (사용자가 issue report 시 첨부 가능).
|
||||
|
||||
45. **윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그** — 트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 시 체크박스가 풀려서 표시됨. 코드 (`src/main/tray.ts:47-58`) 가 `app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] })` 호출 후 다음 부팅 시 `app.getLoginItemSettings().openAtLogin` 이 false 반환. 추정 원인:
|
||||
- (a) Windows registry 에 쓴 exe path 와 현재 프로세스 path 가 다름 (NSIS 설치 위치 변경 / 버전 업데이트 시 새 디렉터리)
|
||||
- (b) Electron `setLoginItemSettings` Windows 구현 의 path canonicalization 이슈
|
||||
- (c) 우리 `args: ['--hidden']` 와 actual launch 시 args 비교 mismatch
|
||||
- 영향: dogfood UX 핵심 마찰 — autostart 가 핸드오프 시 매번 수동 재설정 필요. 자동 실행 의도 자체가 dogfood "잊지 않고 매일 사용" 목적인데 깨짐.
|
||||
- v0.2.6 에서 우선순위 높음. 진단 절차: (1) `app.getLoginItemSettings({ args: ['--hidden'] })` 형태로 args 전달해 비교 정확도 올리기, (2) registry 직접 inspect (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling`) 로 path/args 확인, (3) executable path canonicalization (electron 이 short path 변환 적용 여부).
|
||||
|
||||
## v0.2.5 critical hotfix 누적 (2026-05-05)
|
||||
|
||||
> v0.2.5 single-instance lock hotfix (PR #23) 의 reviewer deferred 항목.
|
||||
|
||||
46. **Hidden-start race (NSIS installer 자동 실행 + 사용자 클릭 충돌)** — NSIS installer 가 설치 직후 사용자가 시작메뉴 / 데스크톱 아이콘 클릭 (`inkling.exe`) + autostart entry (`inkling.exe --hidden`) 을 짧은 간격에 둘 다 시도 시 — 첫 lock 보유자에 따라 visible 여부 race. 본 cut 의 `second-instance` handler 는 무조건 inbox 창 띄움 (사용자 클릭 = 보고 싶다는 강한 시그널 가정). 매우 드문 시나리오 + lock 자체는 정상 동작 (한 쪽만 살아남음).
|
||||
- 영향: drm-edge 케이스만, 실 사용 거의 X
|
||||
- v0.2.6 에서: `app.requestSingleInstanceLock(additionalData)` 의 `additionalData: { hidden: startedHidden }` 전달 → `second-instance(event, argv, cwd, additionalData)` 에서 두 번째 호출이 hidden 이면 창 안 띄우는 정책. 첫 instance 가 자기 자신의 hidden 상태와 비교해 visible 결정.
|
||||
- PR #23 round 1 reviewer Important — acknowledge only, defer to v0.2.6.
|
||||
|
||||
## post-cut next-step (status, not backlog)
|
||||
|
||||
38. **빌드 / release 흐름 (status)** — v0.2.3 cut 7/7 (PR #13~#19) → binary v0.2.3 release → 11434 포트 reserved 발견 → v0.2.3.1 attempt (PR #21) → semver 거부 → v0.2.4 (PR #22, backlog 5건 + Ollama 설정 UI) → release → multi-instance bug 발견 → **v0.2.5 critical hotfix** (PR #23, single-instance lock) → release ✅ (2026-05-05). 다음: dogfood ≥1주 soak → telemetry export + 신규 피드백 → **v0.2.6 brainstorm 트리거** (잔여 backlog 40건 일괄 triage).
|
||||
|
||||
## v0.2.3 cut 후 final reviewer 가 칭찬한 부분
|
||||
|
||||
- 2-layer privacy invariant (zod outer + payload `.strict()`) 가 강한 defense
|
||||
- KST 처리 일관성 — 4 callsites 동일 패턴
|
||||
- backward compat — 기존 13 테스트 (Capture 4 + AiWorker 9) 무수정 통과
|
||||
- 신규 dep 0 (zip 회피로 폴더 + 2 file 정책)
|
||||
- TelemetryService surface 가 깔끔한 foundation — 다음 항목들이 (a) zod schema 추가, (b) EmitInput arm 추가, (c) emit 호출만 하면 됨
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.6",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.6",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { InferenceProvider } from './InferenceProvider.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { AiFailedReason } from '../services/telemetryEvents.js';
|
||||
import { ProviderHolder } from './ProviderHolder.js';
|
||||
import { parseAllCandidates } from '../services/dueDateParser.js';
|
||||
import { ZodError } from 'zod';
|
||||
import { kstTodayAsDate, kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
// v0.2.6 #29 — backlog 의 top-N 튜닝은 dogfood telemetry 후 (현재 magic 만 추출).
|
||||
const VOCAB_TOP_N = 20;
|
||||
|
||||
function todayKstAsDate(now: Date): Date {
|
||||
// Returns a Date object whose UTC year/month/day match KST today
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
|
||||
}
|
||||
|
||||
function todayKstAsIso(now: Date): string {
|
||||
return todayKstAsDate(now).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' {
|
||||
function classifyReason(err: unknown): AiFailedReason {
|
||||
if (err instanceof ZodError) return 'schema';
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) {
|
||||
@@ -31,7 +24,7 @@ function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'o
|
||||
export interface AiTelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
|
||||
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
|
||||
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
|
||||
): Promise<void>;
|
||||
@@ -66,7 +59,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];
|
||||
@@ -131,11 +124,11 @@ export class AiWorker {
|
||||
const note = this.repo.findById(job.noteId);
|
||||
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
|
||||
const nowDate = this.now();
|
||||
const todayDate = todayKstAsDate(nowDate);
|
||||
const todayIso = todayKstAsIso(nowDate);
|
||||
const todayDate = kstTodayAsDate(nowDate);
|
||||
const todayIso = kstTodayIso(nowDate);
|
||||
const candidates = parseAllCandidates(note.rawText, todayDate);
|
||||
const vocab = this.repo.getTopUsedTags(20);
|
||||
const res = await this.provider.generate({
|
||||
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||
const res = await this.holder.get().generate({
|
||||
text: note.rawText,
|
||||
todayKst: todayIso,
|
||||
dueDateCandidates: candidates,
|
||||
@@ -146,7 +139,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';
|
||||
@@ -22,17 +23,43 @@ import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
|
||||
import {
|
||||
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
|
||||
} from './windows/quickCaptureWindow.js';
|
||||
import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js';
|
||||
import { createTray, refreshTray } from './tray.js';
|
||||
import { MediaGc } from './services/MediaGc.js';
|
||||
import { BackupService } from './services/BackupService.js';
|
||||
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);
|
||||
|
||||
// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46).
|
||||
// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창
|
||||
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
|
||||
const additionalData = { hidden: startedHidden };
|
||||
const gotLock = app.requestSingleInstanceLock(additionalData);
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', (_e, _argv, _cwd, secondData) => {
|
||||
const data = secondData as { hidden?: boolean } | undefined;
|
||||
// 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음
|
||||
if (data?.hidden === true) return;
|
||||
// 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → inbox 창 보이게
|
||||
const win = getInboxWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
if (!win.isVisible()) win.show();
|
||||
win.focus();
|
||||
} else {
|
||||
createInboxWindow();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
initLogger();
|
||||
logger.info('app.start', {
|
||||
@@ -56,6 +83,14 @@ app.whenReady().then(async () => {
|
||||
writeFileSync(initFlag, new Date().toISOString());
|
||||
logger.info('autostart.enabled.firstRun');
|
||||
}
|
||||
// v0.2.6 #45 진단 — 실제 LoginItem 상태 확인 (args 전달 vs 미전달 차이)
|
||||
const withArgs = app.getLoginItemSettings({ args: [HIDDEN_ARG] });
|
||||
const noArgs = app.getLoginItemSettings();
|
||||
logger.info('autostart.state', {
|
||||
withArgs: { openAtLogin: withArgs.openAtLogin, executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin },
|
||||
noArgs: { openAtLogin: noArgs.openAtLogin, executableWillLaunchAtLogin: noArgs.executableWillLaunchAtLogin },
|
||||
expectedArgs: [HIDDEN_ARG]
|
||||
});
|
||||
}
|
||||
const db = openDb(paths.dbFile);
|
||||
const repo = new NoteRepository(db);
|
||||
@@ -63,17 +98,29 @@ 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);
|
||||
refreshTrayOllama(status.ok);
|
||||
refreshTray({ ollamaOk: status.ok });
|
||||
},
|
||||
onTelemetry: (ev) => {
|
||||
if (ev.kind === 'ollama_unreachable') {
|
||||
@@ -87,12 +134,11 @@ 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 도 즉시 갱신.
|
||||
refreshTray(repo.countToday());
|
||||
refreshTrayFailedCount(repo.countFailed());
|
||||
refreshTray({ todayCount: repo.countToday(), failedCount: repo.countFailed() });
|
||||
},
|
||||
logger,
|
||||
telemetry
|
||||
@@ -114,7 +160,7 @@ app.whenReady().then(async () => {
|
||||
registerCaptureApi(capture, getQuickCaptureWindow);
|
||||
registerInboxApi({
|
||||
repo, continuity, capture, health, intent,
|
||||
getInboxWindow
|
||||
getInboxWindow, settings: settingsSvc, providerHolder
|
||||
});
|
||||
|
||||
const hotkeys = new HotkeyService();
|
||||
@@ -131,7 +177,9 @@ app.whenReady().then(async () => {
|
||||
await worker.loadFromDb();
|
||||
|
||||
const gc = new MediaGc(db, store);
|
||||
void gc.run().then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>));
|
||||
void gc.run()
|
||||
.then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>))
|
||||
.catch((e) => logger.warn('media.gc.failed', { reason: String(e) }));
|
||||
|
||||
const exportSvc = new ExportService(repo, store);
|
||||
const importSvc = new ImportService(repo, store);
|
||||
@@ -174,10 +222,10 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
createTray(
|
||||
() => createInboxWindow(),
|
||||
() => showQuickCapture(),
|
||||
async () => {
|
||||
createTray({
|
||||
showInbox: () => createInboxWindow(),
|
||||
showCapture: () => showQuickCapture(),
|
||||
runBackup: async () => {
|
||||
try {
|
||||
const r = await backup.runDaily();
|
||||
new Notification({
|
||||
@@ -196,7 +244,7 @@ app.whenReady().then(async () => {
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
runExport: async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '내보낼 폴더 선택',
|
||||
@@ -230,7 +278,7 @@ app.whenReady().then(async () => {
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
runImport: async () => {
|
||||
const win = getInboxWindow();
|
||||
const dirOpts: Electron.OpenDialogOptions = {
|
||||
title: '복원할 백업 폴더 선택',
|
||||
@@ -292,7 +340,7 @@ app.whenReady().then(async () => {
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
runSync: async () => {
|
||||
// runSync — 트레이 "지금 동기화"
|
||||
try {
|
||||
const r = await syncSvc.sync();
|
||||
@@ -315,7 +363,7 @@ app.whenReady().then(async () => {
|
||||
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
|
||||
}
|
||||
},
|
||||
/* runExportTelemetry */ async () => {
|
||||
runExportTelemetry: async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '사용 로그를 내보낼 폴더 선택',
|
||||
@@ -344,17 +392,20 @@ app.whenReady().then(async () => {
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
/* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); },
|
||||
/* runRetryAllFailed */ () => { void capture.retryAllFailed(); }
|
||||
);
|
||||
runOllamaRecheck: () => { void health.runOnce({ manual: true }); },
|
||||
runRetryAllFailed: () => { void capture.retryAllFailed(); },
|
||||
runOpenOllamaSettings: () => {
|
||||
const win = getInboxWindow();
|
||||
if (win) win.webContents.send('inbox:openOllamaSettings');
|
||||
}
|
||||
});
|
||||
|
||||
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
|
||||
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
|
||||
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
|
||||
refreshTray(repo.countToday());
|
||||
refreshTrayFailedCount(repo.countFailed());
|
||||
refreshTray({ todayCount: repo.countToday(), failedCount: repo.countFailed() });
|
||||
trayInterval = setInterval(() => {
|
||||
refreshTray(repo.countToday());
|
||||
refreshTray({ todayCount: repo.countToday() });
|
||||
}, 60_000);
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -34,7 +39,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
deps.repo.setDueDate(arg.noteId, arg.date);
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:delete', async (_e, noteId: string) => {
|
||||
ipcMain.handle('inbox:trash', async (_e, noteId: string) => {
|
||||
await deps.capture.deleteNote(noteId);
|
||||
});
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
|
||||
import type { Note, NoteMedia, NoteTag } from '@shared/types';
|
||||
import { todayInKstString } from '../util/kstDate.js';
|
||||
import { kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
|
||||
export interface CreateNoteInput { rawText: string; }
|
||||
|
||||
@@ -78,7 +78,7 @@ export class NoteRepository {
|
||||
}
|
||||
|
||||
findById(id: string): Note | null {
|
||||
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any;
|
||||
const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as Record<string, unknown>;
|
||||
if (!row) return null;
|
||||
return this.hydrate(row);
|
||||
}
|
||||
@@ -92,21 +92,21 @@ export class NoteRepository {
|
||||
WHERE deleted_at IS NULL AND created_at < ?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(opts.cursor, limit) as any[])
|
||||
.all(opts.cursor, limit) as Record<string, unknown>[])
|
||||
: (this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(limit) as any[]);
|
||||
.all(limit) as Record<string, unknown>[]);
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
listAll(): Note[] {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
|
||||
.all() as any[];
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
@@ -410,6 +410,31 @@ export class NoteRepository {
|
||||
.run(now, id);
|
||||
}
|
||||
|
||||
restoreNote(id: string): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined;
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id);
|
||||
|
||||
// v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성
|
||||
if (before?.ai_status === 'failed') {
|
||||
this.db.prepare(
|
||||
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
|
||||
).run(now, id);
|
||||
this.db.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
).run(id, now);
|
||||
} else if (before?.ai_status === 'pending') {
|
||||
// pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent)
|
||||
this.db.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
).run(id, now);
|
||||
}
|
||||
// done 노트는 재처리 안 함 (이미 결과 있음)
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
permanentDelete(id: string): void {
|
||||
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
|
||||
}
|
||||
@@ -428,7 +453,7 @@ export class NoteRepository {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit));
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
|
||||
.all(limit) as any[];
|
||||
.all(limit) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
@@ -576,7 +601,7 @@ export class NoteRepository {
|
||||
* Caller may inject `now` for testability; defaults to `new Date()`.
|
||||
*/
|
||||
findExpiredCandidates(now: Date = new Date()): Note[] {
|
||||
const today = todayInKstString(now);
|
||||
const today = kstTodayIso(now);
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
@@ -586,18 +611,18 @@ export class NoteRepository {
|
||||
AND ai_status = 'done'
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
)
|
||||
.all(today) as any[];
|
||||
.all(today) as Record<string, unknown>[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
|
||||
.all() as any[];
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => ({
|
||||
noteId: r.note_id,
|
||||
attempts: r.attempts,
|
||||
nextRunAt: r.next_run_at
|
||||
noteId: r.note_id as string,
|
||||
attempts: r.attempts as number,
|
||||
nextRunAt: r.next_run_at as string
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -613,39 +638,39 @@ export class NoteRepository {
|
||||
.run(nextRunAt, lastError.slice(0, 500), noteId);
|
||||
}
|
||||
|
||||
private hydrate(row: any): Note {
|
||||
private hydrate(row: Record<string, unknown>): Note {
|
||||
const tags = this.db
|
||||
.prepare(
|
||||
`SELECT t.name, nt.source
|
||||
FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = ? ORDER BY t.name`
|
||||
)
|
||||
.all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>;
|
||||
.all(row.id as string) as Array<{ name: string; source: 'ai' | 'user' }>;
|
||||
const media = this.db
|
||||
.prepare(
|
||||
`SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?`
|
||||
)
|
||||
.all(row.id) as NoteMedia[];
|
||||
.all(row.id as string) as NoteMedia[];
|
||||
return {
|
||||
id: row.id,
|
||||
rawText: row.raw_text,
|
||||
aiTitle: row.ai_title,
|
||||
aiSummary: row.ai_summary,
|
||||
aiStatus: row.ai_status,
|
||||
aiError: row.ai_error,
|
||||
aiProvider: row.ai_provider,
|
||||
aiGeneratedAt: row.ai_generated_at,
|
||||
titleEditedByUser: row.title_edited_by_user === 1,
|
||||
summaryEditedByUser: row.summary_edited_by_user === 1,
|
||||
userIntent: row.user_intent,
|
||||
intentPromptedAt: row.intent_prompted_at,
|
||||
dueDate: row.due_date ?? null,
|
||||
dueDateEditedByUser: row.due_date_edited_by_user === 1,
|
||||
deletedAt: row.deleted_at ?? null,
|
||||
lastRecalledAt: row.last_recalled_at ?? null,
|
||||
recallDismissedAt: row.recall_dismissed_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
id: row.id as string,
|
||||
rawText: row.raw_text as string,
|
||||
aiTitle: row.ai_title as string | null,
|
||||
aiSummary: row.ai_summary as string | null,
|
||||
aiStatus: row.ai_status as 'pending' | 'done' | 'failed',
|
||||
aiError: row.ai_error as string | null,
|
||||
aiProvider: row.ai_provider as string | null,
|
||||
aiGeneratedAt: row.ai_generated_at as string | null,
|
||||
titleEditedByUser: (row.title_edited_by_user as number) === 1,
|
||||
summaryEditedByUser: (row.summary_edited_by_user as number) === 1,
|
||||
userIntent: row.user_intent as string | null,
|
||||
intentPromptedAt: row.intent_prompted_at as string | null,
|
||||
dueDate: (row.due_date as string | null) ?? null,
|
||||
dueDateEditedByUser: (row.due_date_edited_by_user as number) === 1,
|
||||
deletedAt: (row.deleted_at as string | null) ?? null,
|
||||
lastRecalledAt: (row.last_recalled_at as string | null) ?? null,
|
||||
recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
tags: tags as NoteTag[],
|
||||
media
|
||||
};
|
||||
|
||||
@@ -88,9 +88,14 @@ export class CaptureService {
|
||||
|
||||
async restoreNote(noteId: string): Promise<void> {
|
||||
// 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note || note.deletedAt === null) return;
|
||||
this.repo.restore(noteId);
|
||||
const before = this.repo.findById(noteId);
|
||||
if (!before || before.deletedAt === null) return;
|
||||
// v0.2.6 #10 — production path: repo.restoreNote (ai_status reset + pending_jobs 재생성)
|
||||
this.repo.restoreNote(noteId);
|
||||
// v0.2.6 #10 — in-memory AiWorker queue 갱신: DB 갱신만으로는 다음 앱 실행 시까지 처리 X
|
||||
if (before.aiStatus === 'failed' || before.aiStatus === 'pending') {
|
||||
await this.deps.enqueue(noteId);
|
||||
}
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { validateEvent, TelemetryEvent } from './telemetryEvents.js';
|
||||
import type { AiFailedReason } from './telemetryEvents.js';
|
||||
import { aggregateStats } from './telemetryStats.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
function todayKstIso(now: Date): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
}
|
||||
import { kstTodayIso, DAY_MS } from '../../shared/util/kstDate.js';
|
||||
|
||||
export interface TelemetryServiceOptions {
|
||||
silent?: boolean;
|
||||
@@ -18,7 +12,7 @@ export interface TelemetryServiceOptions {
|
||||
export type EmitInput =
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
|
||||
| { kind: 'trash'; payload: { noteId: string } }
|
||||
| { kind: 'restore'; payload: { noteId: string } }
|
||||
| { kind: 'permanent_delete'; payload: { noteId: string } }
|
||||
@@ -52,8 +46,8 @@ export class TelemetryService {
|
||||
} catch {
|
||||
return { removed };
|
||||
}
|
||||
const cutoff = new Date(this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000);
|
||||
const cutoffIso = todayKstIso(cutoff); // KST 일자 비교
|
||||
const cutoff = new Date(this.now().getTime() - this.retentionDays * DAY_MS);
|
||||
const cutoffIso = kstTodayIso(cutoff); // KST 일자 비교
|
||||
for (const name of entries) {
|
||||
const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
|
||||
if (!m) continue;
|
||||
@@ -76,7 +70,7 @@ export class TelemetryService {
|
||||
const nowDate = this.now();
|
||||
const ts = nowDate.toISOString();
|
||||
const event = validateEvent({ ts, kind: input.kind, payload: input.payload });
|
||||
const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`);
|
||||
const filePath = join(this.dir, `events-${kstTodayIso(nowDate)}.jsonl`);
|
||||
try {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
|
||||
@@ -94,8 +88,8 @@ export class TelemetryService {
|
||||
} catch {
|
||||
return events;
|
||||
}
|
||||
const cutoffMs = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
|
||||
const cutoffIso = todayKstIso(new Date(cutoffMs));
|
||||
const cutoffMs = this.now().getTime() - this.retentionDays * DAY_MS;
|
||||
const cutoffIso = kstTodayIso(new Date(cutoffMs));
|
||||
// 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로
|
||||
// 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨.
|
||||
const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/;
|
||||
|
||||
@@ -12,11 +12,12 @@ const AiSucceededPayload = z.object({
|
||||
attempts: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']);
|
||||
export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']);
|
||||
export type AiFailedReason = z.infer<typeof AiFailedReasonSchema>;
|
||||
|
||||
const AiFailedPayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
reason: AiFailedReason,
|
||||
reason: AiFailedReasonSchema,
|
||||
attempts: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
@@ -92,3 +93,23 @@ export type TelemetryKind = TelemetryEvent['kind'];
|
||||
export function validateEvent(raw: unknown): TelemetryEvent {
|
||||
return TelemetryEventSchema.parse(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.6 #21 — type predicate helper. payload.noteId 가 있는 event kind 만 narrow.
|
||||
* union 확장 시 NO_NOTE_ID_KINDS Set 한 곳만 갱신.
|
||||
*/
|
||||
const NO_NOTE_ID_KINDS = new Set<TelemetryKind>([
|
||||
'empty_trash',
|
||||
'expired_banner_shown',
|
||||
'expired_batch_trash',
|
||||
'ollama_unreachable',
|
||||
'ollama_recovered',
|
||||
'ollama_recheck_manual',
|
||||
'ai_retry_manual',
|
||||
'tag_vocab_hit',
|
||||
'tag_vocab_miss'
|
||||
]);
|
||||
|
||||
export function hasNoteId(ev: TelemetryEvent): ev is Extract<TelemetryEvent, { payload: { noteId: string } }> {
|
||||
return !NO_NOTE_ID_KINDS.has(ev.kind);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { TelemetryEvent } from './telemetryEvents.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
import { kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
|
||||
function kstDate(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
const k = new Date(d.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
return kstTodayIso(new Date(ts));
|
||||
}
|
||||
|
||||
interface DailyRow {
|
||||
@@ -133,12 +129,18 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
} else if (ev.kind === 'recall_snoozed') {
|
||||
row.recall_snoozed += 1;
|
||||
recallSnoozedCount += 1;
|
||||
} else {
|
||||
// v0.2.6 #8 — 새 telemetry kind 추가 시 본 함수 분기 누락을 컴파일 단계에서 catch.
|
||||
const _exhaustive: never = ev;
|
||||
void _exhaustive;
|
||||
}
|
||||
}
|
||||
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
const aiTotal = aiSucceeded + aiFailed;
|
||||
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
|
||||
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
|
||||
// v0.2.6 #9 — 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시
|
||||
// 100% 가능, unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분).
|
||||
const trashRecoveryRate = trashCount === 0
|
||||
? 'N/A'
|
||||
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
|
||||
|
||||
176
src/main/tray.ts
176
src/main/tray.ts
@@ -1,48 +1,101 @@
|
||||
import electron from 'electron';
|
||||
import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
|
||||
const { app, Tray, Menu, nativeImage } = electron;
|
||||
import { platform, release, EOL } from 'node:os';
|
||||
const { app, Tray, Menu, nativeImage, dialog, shell, clipboard } = electron;
|
||||
|
||||
function showAboutDialog(): void {
|
||||
const version = app.getVersion();
|
||||
const electronVersion = process.versions.electron ?? '?';
|
||||
const nodeVersion = process.versions.node ?? '?';
|
||||
const profileDir = app.getPath('userData');
|
||||
// OS EOL 사용 — 클립보드 → Notepad 등에서 줄바꿈 정상.
|
||||
const detail = [
|
||||
`버전: ${version}`,
|
||||
`Electron: ${electronVersion}`,
|
||||
`Node: ${nodeVersion}`,
|
||||
`OS: ${platform()} ${release()}`,
|
||||
`데이터 위치: ${profileDir}`
|
||||
].join(EOL);
|
||||
void dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Inkling 정보',
|
||||
message: `Inkling ${version}`,
|
||||
detail,
|
||||
buttons: ['확인', '데이터 위치 열기', '정보 복사'],
|
||||
defaultId: 0,
|
||||
cancelId: 0
|
||||
}).then((r) => {
|
||||
if (r.response === 1) void shell.openPath(profileDir);
|
||||
if (r.response === 2) clipboard.writeText(`Inkling ${version}${EOL}${detail}`);
|
||||
}).catch(() => {
|
||||
// dialog reject 는 일반 사용에서 발생 X — main process crash 등 예외 케이스 silent.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.6 C2 — 트레이 메뉴 콜백 묶음. createTray 가 1-arg 로 받음.
|
||||
*/
|
||||
export interface TrayCallbacks {
|
||||
showInbox: () => void;
|
||||
showCapture: () => void;
|
||||
runBackup: () => void;
|
||||
runExport: () => void;
|
||||
runImport: () => void;
|
||||
runSync: () => void;
|
||||
runExportTelemetry: () => void;
|
||||
runOllamaRecheck: () => void;
|
||||
runRetryAllFailed: () => void;
|
||||
runOpenOllamaSettings: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.6 C3 — 메뉴 라벨/활성화에 영향 주는 reactive state. refreshTray() 로 partial 갱신.
|
||||
*/
|
||||
export interface TrayState {
|
||||
ollamaOk: boolean;
|
||||
todayCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
let tray: TrayType | null = null;
|
||||
let _showInbox: () => void = () => {};
|
||||
let _showCapture: () => void = () => {};
|
||||
let _runBackup: () => void = () => {};
|
||||
let _runExport: () => void = () => {};
|
||||
let _runImport: () => void = () => {};
|
||||
let _runSync: () => void = () => {};
|
||||
let _runExportTelemetry: () => void = () => {};
|
||||
let _runOllamaRecheck: () => void = () => {};
|
||||
let _ollamaOk = true;
|
||||
let _todayCount = 0;
|
||||
let _runRetryAllFailed: () => void = () => {};
|
||||
let _failedCount = 0;
|
||||
let _callbacks: TrayCallbacks | null = null;
|
||||
let _state: TrayState = { ollamaOk: true, todayCount: 0, failedCount: 0 };
|
||||
|
||||
function buildMenu() {
|
||||
function buildMenu(): electron.Menu {
|
||||
const items: MenuItemConstructorOptions[] = [];
|
||||
const cb = _callbacks;
|
||||
if (!cb) {
|
||||
// createTray 호출 전이면 빈 메뉴 (defensive)
|
||||
return Menu.buildFromTemplate([{ label: '로딩 중...', enabled: false }]);
|
||||
}
|
||||
// F4-C: count > 0 시 비활성 라벨로 정체성 신호 노출. count = 0 시 메뉴를 자연스럽게 시작.
|
||||
if (_todayCount > 0) {
|
||||
items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false });
|
||||
if (_state.todayCount > 0) {
|
||||
items.push({ label: `오늘 ${_state.todayCount}번 잡아둠`, enabled: false });
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push({ label: '보관한 메모 보기', click: _showInbox });
|
||||
items.push({ label: '한 줄 적기', click: _showCapture });
|
||||
items.push({ label: '보관한 메모 보기', click: cb.showInbox });
|
||||
items.push({ label: '한 줄 적기', click: cb.showCapture });
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: '지금 백업', click: _runBackup });
|
||||
items.push({ label: '내보내기...', click: _runExport });
|
||||
items.push({ label: '백업에서 복원...', click: _runImport });
|
||||
items.push({ label: '지금 동기화', click: _runSync });
|
||||
items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry });
|
||||
items.push({ label: '지금 백업', click: cb.runBackup });
|
||||
items.push({ label: '내보내기...', click: cb.runExport });
|
||||
items.push({ label: '백업에서 복원...', click: cb.runImport });
|
||||
items.push({ label: '지금 동기화', click: cb.runSync });
|
||||
items.push({ label: '사용 로그 내보내기...', click: cb.runExportTelemetry });
|
||||
items.push({
|
||||
label: 'Ollama 재확인',
|
||||
enabled: !_ollamaOk,
|
||||
click: _runOllamaRecheck
|
||||
enabled: !_state.ollamaOk,
|
||||
click: cb.runOllamaRecheck
|
||||
});
|
||||
items.push({
|
||||
label: `지금 AI 처리 (실패 ${_failedCount}건)`,
|
||||
enabled: _failedCount > 0,
|
||||
click: _runRetryAllFailed
|
||||
label: `지금 AI 처리 (실패 ${_state.failedCount}건)`,
|
||||
enabled: _state.failedCount > 0,
|
||||
click: cb.runRetryAllFailed
|
||||
});
|
||||
items.push({ label: 'Ollama 설정...', click: cb.runOpenOllamaSettings });
|
||||
if (app.isPackaged) {
|
||||
const { openAtLogin } = app.getLoginItemSettings();
|
||||
// v0.2.6 #45 — args 명시 전달로 openAtLogin 비교 정확도. setLoginItemSettings 가
|
||||
// args 와 함께 LoginItem 등록하므로 read 시도 같은 args 로 비교해야 매치됨.
|
||||
const { openAtLogin } = app.getLoginItemSettings({ args: ['--hidden'] });
|
||||
items.push({
|
||||
label: '윈도우 시작 시 자동 실행',
|
||||
type: 'checkbox',
|
||||
@@ -58,64 +111,37 @@ function buildMenu() {
|
||||
} else {
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push({ label: 'Inkling 정보...', click: showAboutDialog });
|
||||
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
|
||||
return Menu.buildFromTemplate(items);
|
||||
}
|
||||
|
||||
export function createTray(
|
||||
showInbox: () => void,
|
||||
showCapture: () => void,
|
||||
runBackup: () => void,
|
||||
runExport: () => void,
|
||||
runImport: () => void,
|
||||
runSync: () => void,
|
||||
runExportTelemetry: () => void,
|
||||
runOllamaRecheck: () => void,
|
||||
runRetryAllFailed: () => void
|
||||
): TrayType {
|
||||
_showInbox = showInbox;
|
||||
_showCapture = showCapture;
|
||||
_runBackup = runBackup;
|
||||
_runExport = runExport;
|
||||
_runImport = runImport;
|
||||
_runSync = runSync;
|
||||
_runExportTelemetry = runExportTelemetry;
|
||||
_runOllamaRecheck = runOllamaRecheck;
|
||||
_runRetryAllFailed = runRetryAllFailed;
|
||||
/**
|
||||
* v0.2.6 C2 — 1-arg createTray. 기존 10 positional 폐기.
|
||||
*/
|
||||
export function createTray(callbacks: TrayCallbacks): TrayType {
|
||||
_callbacks = callbacks;
|
||||
const icon = nativeImage.createEmpty();
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
|
||||
tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
tray.on('click', showInbox);
|
||||
tray.on('click', callbacks.showInbox);
|
||||
return tray;
|
||||
}
|
||||
|
||||
/**
|
||||
* F4-C 환경 앵커 — tooltip + 메뉴 첫 항목을 오늘 캡처 수로 갱신.
|
||||
* `src/main/index.ts` 가 60s interval / AiWorker onUpdate 시점에 호출.
|
||||
* v0.2.6 C3 — 통합 state 갱신. partial 으로 받아 _state merge + 메뉴 재빌드.
|
||||
*
|
||||
* Replaces: refreshTrayOllama(ok), refreshTrayFailedCount(count), 기존 refreshTray(todayCount).
|
||||
*
|
||||
* 호출 예:
|
||||
* refreshTray({ todayCount: 5 });
|
||||
* refreshTray({ ollamaOk: false });
|
||||
* refreshTray({ failedCount: 2 });
|
||||
*/
|
||||
export function refreshTray(todayCount: number): void {
|
||||
_todayCount = todayCount;
|
||||
if (tray === null) return;
|
||||
tray.setToolTip(`Inkling — 오늘 ${todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #1 — Ollama 상태가 변할 때 main 의 health.onUpdate 가 호출.
|
||||
* 메뉴의 "Ollama 재확인" 활성/비활성 상태 갱신.
|
||||
*/
|
||||
export function refreshTrayOllama(ok: boolean): void {
|
||||
_ollamaOk = ok;
|
||||
if (tray === null) return;
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #2 — AiWorker.onUpdate 시 실패 카운트 변하면 메뉴 라벨 + enabled 갱신.
|
||||
*/
|
||||
export function refreshTrayFailedCount(count: number): void {
|
||||
_failedCount = count;
|
||||
export function refreshTray(state: Partial<TrayState>): void {
|
||||
_state = { ..._state, ...state };
|
||||
if (tray === null) return;
|
||||
tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Calendar date (YYYY-MM-DD) in Asia/Seoul timezone for the given instant.
|
||||
*
|
||||
* v0.2.3 #5 — used by NoteRepository.findExpiredCandidates to compare against
|
||||
* notes.due_date (also stored as YYYY-MM-DD per slice §F1).
|
||||
*/
|
||||
export function todayInKstString(now: Date): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(
|
||||
Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())
|
||||
).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms of the next 00:00 KST strictly after `now`.
|
||||
*
|
||||
* v0.2.3 #5 — used by store.snoozeExpired to compute the in-memory snooze
|
||||
* deadline ("오늘 그만").
|
||||
*/
|
||||
export function nextKstMidnightMs(now: number): number {
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
// Floor to KST midnight, then add one day.
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
return nextKstMidnight - KST_OFFSET_MS;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const api: InklingApi = {
|
||||
updateAiFields: (noteId, fields) =>
|
||||
ipcRenderer.invoke('inbox:updateAi', { noteId, fields }),
|
||||
setDueDate: (noteId, date) => ipcRenderer.invoke('inbox:setDueDate', { noteId, date }),
|
||||
deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
|
||||
deleteNote: (noteId) => ipcRenderer.invoke('inbox:trash', noteId),
|
||||
setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }),
|
||||
dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId),
|
||||
getContinuity: () => ipcRenderer.invoke('inbox:continuity'),
|
||||
@@ -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); }}
|
||||
@@ -144,7 +147,6 @@ export function App(): React.ReactElement {
|
||||
trashNotes.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="trash"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
onUpdated={(u) => upsertNote(u)}
|
||||
onRestore={() => void restoreNote(n.id)}
|
||||
onPermanentDelete={() => void permanentDeleteNote(n.id)}
|
||||
@@ -155,6 +157,10 @@ export function App(): React.ReactElement {
|
||||
)}
|
||||
</main>
|
||||
<TagUndoToast />
|
||||
<OllamaSettingsModal
|
||||
open={ollamaSettingsOpen}
|
||||
onClose={() => setOllamaSettingsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
27
src/renderer/inbox/components/Banner.tsx
Normal file
27
src/renderer/inbox/components/Banner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* v0.2.6 #24+#41 — 4 banner 의 inline style 중복 제거. severity 별 theme map.
|
||||
*/
|
||||
const THEMES = {
|
||||
warning: { bg: '#fff7e6', border: '#d99500', text: '#946100' },
|
||||
error: { bg: '#fce4e4', border: '#a33', text: '#a33' },
|
||||
info: { bg: '#e8f0fe', border: '#4a7ec0', text: '#234' }
|
||||
} as const;
|
||||
|
||||
interface Props {
|
||||
severity: 'warning' | 'error' | 'info';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Banner({ severity, children }: Props): React.ReactElement {
|
||||
const t = THEMES[severity];
|
||||
return (
|
||||
<div style={{
|
||||
background: t.bg, border: `1px solid ${t.border}`, color: t.text,
|
||||
borderRadius: 6, padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function ExpiryBanner(): React.ReactElement | null {
|
||||
const candidates = useInbox((s) => s.expiredCandidates);
|
||||
@@ -72,10 +73,7 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff7e6', border: '1px solid #d99500', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
<Banner severity="warning">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>⏰ <b>오늘 기준 만료 {candidates.length}개</b></span>
|
||||
<button
|
||||
@@ -152,6 +150,6 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function FailedBanner(): React.ReactElement | null {
|
||||
const count = useInbox((s) => s.failedCount);
|
||||
const retryAllFailed = useInbox((s) => s.retryAllFailed);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fce4e4', border: '1px solid #a33', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 8
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
retryAllFailed().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('retryAllFailed failed', e);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
background: '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
<Banner severity="error">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
retryAllFailed().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('retryAllFailed failed', e);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
background: '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { pushTagUndo } from './TagUndoToast.js';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
onDeleted: () => void;
|
||||
onDeleted?: () => void; // inbox mode 전용 (trash mode 에서 미사용)
|
||||
onUpdated: (n: Note) => void;
|
||||
mode?: 'inbox' | 'trash'; // default 'inbox'
|
||||
onRestore?: () => void;
|
||||
@@ -119,7 +119,7 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
async function handleDelete() {
|
||||
if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
|
||||
await inboxApi.deleteNote(note.id);
|
||||
onDeleted();
|
||||
onDeleted?.();
|
||||
}
|
||||
|
||||
async function saveTitle(next: string) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { Banner } from './Banner.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;
|
||||
@@ -10,7 +15,8 @@ export function OllamaBanner(): React.ReactElement | null {
|
||||
? '`ollama pull gemma4:e4b` 실행 후 앱을 재시작해주세요.'
|
||||
: 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
|
||||
return (
|
||||
<div className="banner warn" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<Banner severity="warning">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
|
||||
<span style={{ flex: 1 }}>⚠ {message}</span>
|
||||
<button
|
||||
@@ -28,12 +34,26 @@ 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 }}>
|
||||
진단: {status.reason}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
140
src/renderer/inbox/components/OllamaSettingsModal.tsx
Normal file
140
src/renderer/inbox/components/OllamaSettingsModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../../shared/constants.js';
|
||||
|
||||
const EndpointSchema = z.string().url();
|
||||
|
||||
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 {
|
||||
// v0.2.6 #42 — client-side URL validation, server-side healthCheck 전에 명확한 메시지
|
||||
const parseResult = EndpointSchema.safeParse(endpoint);
|
||||
if (!parseResult.success) {
|
||||
setError('유효한 URL 형식이 아닙니다 (예: http://localhost:11434)');
|
||||
return;
|
||||
}
|
||||
if (model.trim().length === 0) {
|
||||
setError('모델명을 입력하세요');
|
||||
return;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { Banner } from './Banner.js';
|
||||
|
||||
export function RecallBanner(): React.ReactElement | null {
|
||||
const candidate = useInbox((s) => s.recallCandidate);
|
||||
@@ -47,10 +48,7 @@ export function RecallBanner(): React.ReactElement | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
<Banner severity="info">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>💭 <b>오늘 회상해볼 노트</b></span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
|
||||
@@ -90,7 +88,7 @@ export function RecallBanner(): React.ReactElement | null {
|
||||
더 이상
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Note, WeeklyContinuity } from '@shared/types';
|
||||
import { inboxApi } from './api.js';
|
||||
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
|
||||
|
||||
export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||
|
||||
@@ -177,12 +178,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
snoozeExpired() {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
||||
set({ expiredSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
|
||||
},
|
||||
async recheckOllama() {
|
||||
const status = await inboxApi.ollamaRecheck();
|
||||
@@ -212,12 +208,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
set({ recallCandidate, recallSnoozeUntilMs: null });
|
||||
},
|
||||
async snoozeRecall() {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
||||
set({ recallSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
|
||||
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
|
||||
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
|
||||
const candidate = get().recallCandidate;
|
||||
|
||||
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 {
|
||||
|
||||
42
src/shared/util/kstDate.ts
Normal file
42
src/shared/util/kstDate.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* KST timezone helpers — main + renderer 양쪽에서 import 가능.
|
||||
* v0.2.6 C1: backlog #3+#19+#34 통합 (기존 src/main/util/kstDate.ts 이동).
|
||||
*/
|
||||
|
||||
export const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* KST 자정 기준 today YYYY-MM-DD.
|
||||
*
|
||||
* 기존 todayInKstString (NoteRepository.findExpiredCandidates),
|
||||
* TelemetryService.todayKstIso, telemetryStats.kstDate, AiWorker.todayKstAsIso
|
||||
* 4 callsite 통합.
|
||||
*/
|
||||
export function kstTodayIso(now: Date = new Date()): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 KST 자정의 epoch ms (UTC).
|
||||
*
|
||||
* 기존 nextKstMidnightMs (store.snoozeExpired) + store.snoozeRecall inline 통합.
|
||||
*/
|
||||
export function nextKstMidnightMs(now: number = Date.now()): number {
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnightFloor = Math.floor(kstNow / DAY_MS) * DAY_MS;
|
||||
const nextKstMidnight = kstMidnightFloor + DAY_MS;
|
||||
return nextKstMidnight - KST_OFFSET_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* KST today (00:00 KST 의 UTC Date 객체). AiWorker 의 dueDateParser 가 candidate 비교용.
|
||||
*
|
||||
* 기존 AiWorker.todayKstAsDate 통합.
|
||||
*/
|
||||
export function kstTodayAsDate(now: Date = new Date()): Date {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
|
||||
}
|
||||
@@ -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); }) }
|
||||
});
|
||||
|
||||
@@ -324,6 +324,52 @@ describe('CaptureService.trashExpiredBatch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService.restoreNote — enqueue on failed/pending (#10 production path)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let enqueued: string[];
|
||||
let svc: CaptureService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-restore-'));
|
||||
store = new MediaStore(tmp);
|
||||
enqueued = [];
|
||||
svc = new CaptureService(repo, store, {
|
||||
enqueue: async (id) => { enqueued.push(id); },
|
||||
celebrate: () => {}
|
||||
});
|
||||
});
|
||||
|
||||
it('restoreNote calls worker.enqueue when restoring failed note', async () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.markAiFailed(id, 'unreachable');
|
||||
repo.trash(id, new Date().toISOString());
|
||||
enqueued.length = 0; // reset
|
||||
|
||||
await svc.restoreNote(id);
|
||||
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
expect(enqueued).toContain(id);
|
||||
});
|
||||
|
||||
it('restoreNote does not enqueue done note', async () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
repo.trash(id, new Date().toISOString());
|
||||
enqueued.length = 0; // reset
|
||||
|
||||
await svc.restoreNote(id);
|
||||
|
||||
expect(repo.findById(id)!.aiStatus).toBe('done');
|
||||
expect(enqueued).not.toContain(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService.retryAllFailed', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,6 +267,49 @@ describe('NoteRepository', () => {
|
||||
repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' });
|
||||
expect(repo.findRecallCandidate()?.id).toBe(d);
|
||||
});
|
||||
|
||||
it('restoreNote re-enqueues failed note (ai_status reset to pending + pending_jobs INSERT)', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.markAiFailed(id, 'unreachable');
|
||||
repo.trash(id, new Date().toISOString());
|
||||
expect(repo.findById(id)!.aiStatus).toBe('failed');
|
||||
|
||||
repo.restoreNote(id);
|
||||
|
||||
const after = repo.findById(id)!;
|
||||
expect(after.deletedAt).toBeNull();
|
||||
expect(after.aiStatus).toBe('pending');
|
||||
expect(after.aiError).toBeNull();
|
||||
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
|
||||
expect(job).toBeDefined();
|
||||
});
|
||||
|
||||
it('restoreNote does not re-enqueue done note', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
repo.trash(id, new Date().toISOString());
|
||||
expect(repo.findById(id)!.aiStatus).toBe('done');
|
||||
|
||||
repo.restoreNote(id);
|
||||
|
||||
expect(repo.findById(id)!.aiStatus).toBe('done');
|
||||
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
|
||||
expect(job).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restoreNote re-enqueues pending note (defensive)', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
// 인공적으로 pending_jobs 비운 후 trash
|
||||
db.prepare('DELETE FROM pending_jobs WHERE note_id=?').run(id);
|
||||
repo.trash(id, new Date().toISOString());
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
|
||||
repo.restoreNote(id);
|
||||
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
|
||||
expect(job).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.trash', () => {
|
||||
@@ -449,6 +492,19 @@ describe('NoteRepository.countTrashed', () => {
|
||||
expect(repo.countTrashed()).toBe(10);
|
||||
expect(repo.listTrashed({ limit: 5 })).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('countTrashed returns accurate count (>200 not capped)', () => {
|
||||
const now = new Date().toISOString();
|
||||
for (let i = 0; i < 250; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
repo.trash(id, now);
|
||||
}
|
||||
expect(repo.countTrashed()).toBe(250);
|
||||
});
|
||||
|
||||
it('countTrashed returns 0 for empty trash', () => {
|
||||
expect(repo.countTrashed()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active queries exclude deleted notes', () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSy
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { TelemetryService } from '@main/services/TelemetryService.js';
|
||||
import { hasNoteId } from '@main/services/telemetryEvents.js';
|
||||
|
||||
describe('TelemetryService.emit', () => {
|
||||
let dir: string;
|
||||
@@ -147,11 +148,7 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
const events = await svc.readAllRecent();
|
||||
expect(events).toHaveLength(3);
|
||||
// discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패
|
||||
expect(events.map((e) =>
|
||||
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual' || e.kind === 'tag_vocab_hit' || e.kind === 'tag_vocab_miss')
|
||||
? null
|
||||
: e.payload.noteId
|
||||
)).toEqual(['a', 'b', 'b']);
|
||||
expect(events.map((e) => hasNoteId(e) ? e.payload.noteId : null)).toEqual(['a', 'b', 'b']);
|
||||
});
|
||||
|
||||
it('skips malformed lines (silent — invariant)', async () => {
|
||||
@@ -164,7 +161,7 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
expect(events).toHaveLength(1);
|
||||
const ev = events[0]!;
|
||||
expect(ev.kind).toBe('capture');
|
||||
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual' && ev.kind !== 'tag_vocab_hit' && ev.kind !== 'tag_vocab_miss') expect(ev.payload.noteId).toBe('a');
|
||||
if (hasNoteId(ev)) expect(ev.payload.noteId).toBe('a');
|
||||
});
|
||||
|
||||
it('returns [] when dir missing', async () => {
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { todayInKstString, nextKstMidnightMs } from '@main/util/kstDate.js';
|
||||
import { kstTodayIso, nextKstMidnightMs, kstTodayAsDate } from '@shared/util/kstDate.js';
|
||||
|
||||
describe('todayInKstString', () => {
|
||||
describe('kstTodayIso', () => {
|
||||
it('returns KST calendar date as YYYY-MM-DD', () => {
|
||||
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST
|
||||
expect(todayInKstString(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01');
|
||||
expect(kstTodayIso(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01');
|
||||
});
|
||||
|
||||
it('handles UTC→KST date rollover (UTC 23:30 → KST next day 08:30)', () => {
|
||||
expect(todayInKstString(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02');
|
||||
expect(kstTodayIso(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02');
|
||||
});
|
||||
|
||||
it('handles KST midnight exactly (UTC 15:00 = KST 00:00 next day)', () => {
|
||||
expect(todayInKstString(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02');
|
||||
expect(kstTodayIso(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02');
|
||||
});
|
||||
|
||||
it('boundary — UTC 14:59:59 still KST 23:59:59 same day', () => {
|
||||
// KST 5/4 23:59:59 = UTC 5/4 14:59:59
|
||||
const utcDate = new Date('2026-05-04T14:59:59Z');
|
||||
expect(kstTodayIso(utcDate)).toBe('2026-05-04');
|
||||
});
|
||||
|
||||
it('KST 5/5 00:30 (UTC 5/4 15:30) returns 2026-05-05', () => {
|
||||
const utcDate = new Date('2026-05-04T15:30:00Z');
|
||||
expect(kstTodayIso(utcDate)).toBe('2026-05-05');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,4 +45,19 @@ describe('nextKstMidnightMs', () => {
|
||||
expect(next - now).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
expect(next - now).toBeLessThan(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('KST 5/5 00:30 → next KST midnight = 5/6 00:00 KST = 5/5 15:00 UTC', () => {
|
||||
const utcMs = new Date('2026-05-04T15:30:00Z').getTime();
|
||||
const next = nextKstMidnightMs(utcMs);
|
||||
expect(new Date(next).toISOString()).toBe('2026-05-05T15:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kstTodayAsDate', () => {
|
||||
it('returns UTC Date at KST 00:00', () => {
|
||||
// KST 5/5 00:30 → KST 5/5 00:00 = UTC 5/4 15:00
|
||||
const utcDate = new Date('2026-05-04T15:30:00Z');
|
||||
const result = kstTodayAsDate(utcDate);
|
||||
expect(result.toISOString()).toBe('2026-05-05T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateEvent } from '@main/services/telemetryEvents.js';
|
||||
import { validateEvent, hasNoteId } from '@main/services/telemetryEvents.js';
|
||||
|
||||
describe('validateEvent — happy path', () => {
|
||||
it('accepts capture event', () => {
|
||||
@@ -333,3 +333,19 @@ describe('validateEvent — recall', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasNoteId', () => {
|
||||
it('returns true for noteId-bearing events', () => {
|
||||
const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
|
||||
const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'recall_shown', payload: { noteId: 'n1', ageDays: 14 } });
|
||||
expect(hasNoteId(e1)).toBe(true);
|
||||
expect(hasNoteId(e2)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for noteId-less events', () => {
|
||||
const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'empty_trash', payload: { count: 5 } });
|
||||
const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'tag_vocab_hit', payload: { tagId: 1, vocabSize: 10 } });
|
||||
expect(hasNoteId(e1)).toBe(false);
|
||||
expect(hasNoteId(e2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user